diff options
Diffstat (limited to 'packages/anastasis-webui/src')
110 files changed, 6332 insertions, 3041 deletions
diff --git a/packages/anastasis-webui/src/.babelrc b/packages/anastasis-webui/src/.babelrc index 123002210..05f4dcc81 100644 --- a/packages/anastasis-webui/src/.babelrc +++ b/packages/anastasis-webui/src/.babelrc @@ -1,5 +1,3 @@ { - "presets": [ - "preact-cli/babel" - ] + "presets": ["preact-cli/babel"] } diff --git a/packages/anastasis-webui/src/components/AsyncButton.tsx b/packages/anastasis-webui/src/components/AsyncButton.tsx index 92bef2219..8f855f29f 100644 --- a/packages/anastasis-webui/src/components/AsyncButton.tsx +++ b/packages/anastasis-webui/src/components/AsyncButton.tsx @@ -15,11 +15,12 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { ComponentChildren, h, VNode } from "preact"; +import { useLayoutEffect, useRef } from "preact/hooks"; // import { LoadingModal } from "../modal"; import { useAsync } from "../hooks/async"; // import { Translate } from "../../i18n"; @@ -28,22 +29,38 @@ type Props = { children: ComponentChildren; disabled?: boolean; onClick?: () => Promise<void>; + grabFocus?: boolean; [rest: string]: any; }; -export function AsyncButton({ onClick, disabled, children, ...rest }: Props): VNode { +export function AsyncButton({ + onClick, + grabFocus, + disabled, + children, + ...rest +}: Props): VNode { const { isLoading, request } = useAsync(onClick); + const buttonRef = useRef<HTMLButtonElement>(null); + useLayoutEffect(() => { + if (grabFocus) { + buttonRef.current?.focus(); + } + }, [grabFocus]); + // if (isSlow) { // return <LoadingModal onCancel={cancel} />; // } - if (isLoading) { + if (isLoading) { return <button class="button">Loading...</button>; } - return <span data-tooltip={rest['data-tooltip']} style={{marginLeft: 5}}> - <button {...rest} onClick={request} disabled={disabled}> - {children} - </button> - </span>; + return ( + <span data-tooltip={rest["data-tooltip"]} style={{ marginLeft: 5 }}> + <button {...rest} ref={buttonRef} onClick={request} disabled={disabled}> + {children} + </button> + </span> + ); } diff --git a/packages/anastasis-webui/src/components/Notifications.tsx b/packages/anastasis-webui/src/components/Notifications.tsx index c916020d7..e34550386 100644 --- a/packages/anastasis-webui/src/components/Notifications.tsx +++ b/packages/anastasis-webui/src/components/Notifications.tsx @@ -15,9 +15,9 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { h, VNode } from "preact"; @@ -27,7 +27,7 @@ export interface Notification { type: MessageType; } -export type MessageType = 'INFO' | 'WARN' | 'ERROR' | 'SUCCESS' +export type MessageType = "INFO" | "WARN" | "ERROR" | "SUCCESS"; interface Props { notifications: Notification[]; @@ -36,24 +36,39 @@ interface Props { function messageStyle(type: MessageType): string { switch (type) { - case "INFO": return "message is-info"; - case "WARN": return "message is-warning"; - case "ERROR": return "message is-danger"; - case "SUCCESS": return "message is-success"; - default: return "message" + case "INFO": + return "message is-info"; + case "WARN": + return "message is-warning"; + case "ERROR": + return "message is-danger"; + case "SUCCESS": + return "message is-success"; + default: + return "message"; } } -export function Notifications({ notifications, removeNotification }: Props): VNode { - return <div class="block"> - {notifications.map((n,i) => <article key={i} class={messageStyle(n.type)}> - <div class="message-header"> - <p>{n.message}</p> - <button class="delete" onClick={() => removeNotification && removeNotification(n)} /> - </div> - {n.description && <div class="message-body"> - {n.description} - </div>} - </article>)} - </div> -}
\ No newline at end of file +export function Notifications({ + notifications, + removeNotification, +}: Props): VNode { + return ( + <div class="block"> + {notifications.map((n, i) => ( + <article key={i} class={messageStyle(n.type)}> + <div class="message-header"> + <p>{n.message}</p> + {removeNotification && ( + <button + class="delete" + onClick={() => removeNotification && removeNotification(n)} + /> + )} + </div> + {n.description && <div class="message-body">{n.description}</div>} + </article> + ))} + </div> + ); +} diff --git a/packages/anastasis-webui/src/components/QR.tsx b/packages/anastasis-webui/src/components/QR.tsx index 48f1a7c12..9a05f6097 100644 --- a/packages/anastasis-webui/src/components/QR.tsx +++ b/packages/anastasis-webui/src/components/QR.tsx @@ -21,15 +21,28 @@ import qrcode from "qrcode-generator"; export function QR({ text }: { text: string }): VNode { const divRef = useRef<HTMLDivElement>(null); useEffect(() => { - const qr = qrcode(0, 'L'); + const qr = qrcode(0, "L"); qr.addData(text); qr.make(); - if (divRef.current) divRef.current.innerHTML = qr.createSvgTag({ - scalable: true, - }); + if (divRef.current) + divRef.current.innerHTML = qr.createSvgTag({ + scalable: true, + }); }); - return <div style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}> - <div style={{ width: '50%', minWidth: 200, maxWidth: 300 }} ref={divRef} /> - </div>; + return ( + <div + style={{ + width: "100%", + display: "flex", + flexDirection: "column", + alignItems: "center", + }} + > + <div + style={{ width: "50%", minWidth: 200, maxWidth: 300 }} + ref={divRef} + /> + </div> + ); } diff --git a/packages/anastasis-webui/src/components/app.tsx b/packages/anastasis-webui/src/components/app.tsx index c6b4cfc14..4c6683c0c 100644 --- a/packages/anastasis-webui/src/components/app.tsx +++ b/packages/anastasis-webui/src/components/app.tsx @@ -1,6 +1,5 @@ import { FunctionalComponent, h } from "preact"; import { TranslationProvider } from "../context/translation"; - import AnastasisClient from "../pages/home"; const App: FunctionalComponent = () => { diff --git a/packages/anastasis-webui/src/components/fields/DateInput.tsx b/packages/anastasis-webui/src/components/fields/DateInput.tsx index 3148c953f..18ef89908 100644 --- a/packages/anastasis-webui/src/components/fields/DateInput.tsx +++ b/packages/anastasis-webui/src/components/fields/DateInput.tsx @@ -1,4 +1,4 @@ -import { format, isAfter, parse, sub, subYears } from "date-fns"; +import { format, subYears } from "date-fns"; import { h, VNode } from "preact"; import { useLayoutEffect, useRef, useState } from "preact/hooks"; import { DatePicker } from "../picker/DatePicker"; @@ -9,6 +9,7 @@ export interface DateInputProps { tooltip?: string; error?: string; years?: Array<number>; + onConfirm?: () => void; bind: [string, (x: string) => void]; } @@ -19,56 +20,71 @@ export function DateInput(props: DateInputProps): VNode { inputRef.current?.focus(); } }, [props.grabFocus]); - const [opened, setOpened] = useState(false) + const [opened, setOpened] = useState(false); const value = props.bind[0] || ""; - const [dirty, setDirty] = useState(false) - const showError = dirty && props.error + const [dirty, setDirty] = useState(false); + const showError = dirty && props.error; - const calendar = subYears(new Date(), 30) - - return <div class="field"> - <label class="label"> - {props.label} - {props.tooltip && <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> - <i class="mdi mdi-information" /> - </span>} - </label> - <div class="control"> - <div class="field has-addons"> - <p class="control"> - <input - type="text" - class={showError ? 'input is-danger' : 'input'} - value={value} - onInput={(e) => { - const text = e.currentTarget.value - setDirty(true) - props.bind[1](text); - }} - ref={inputRef} /> - </p> - <p class="control"> - <a class="button" onClick={() => { setOpened(true) }}> - <span class="icon"><i class="mdi mdi-calendar" /></span> - </a> - </p> + const calendar = subYears(new Date(), 30); + + return ( + <div class="field"> + <label class="label"> + {props.label} + {props.tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + <div class="control"> + <div class="field has-addons"> + <p class="control"> + <input + type="text" + class={showError ? "input is-danger" : "input"} + value={value} + onKeyPress={(e) => { + if (e.key === 'Enter' && props.onConfirm) { + props.onConfirm() + } + }} + onInput={(e) => { + const text = e.currentTarget.value; + setDirty(true); + props.bind[1](text); + }} + ref={inputRef} + /> + </p> + <p class="control"> + <a + class="button" + onClick={() => { + setOpened(true); + }} + > + <span class="icon"> + <i class="mdi mdi-calendar" /> + </span> + </a> + </p> + </div> </div> + <p class="help">Using the format yyyy-mm-dd</p> + {showError && <p class="help is-danger">{props.error}</p>} + <DatePicker + opened={opened} + initialDate={calendar} + years={props.years} + closeFunction={() => setOpened(false)} + dateReceiver={(d) => { + setDirty(true); + const v = format(d, "yyyy-MM-dd"); + props.bind[1](v); + }} + /> </div> - <p class="help">Using the format yyyy-mm-dd</p> - {showError && <p class="help is-danger">{props.error}</p>} - <DatePicker - opened={opened} - initialDate={calendar} - years={props.years} - closeFunction={() => setOpened(false)} - dateReceiver={(d) => { - setDirty(true) - const v = format(d, 'yyyy-MM-dd') - props.bind[1](v); - }} - /> - </div> - ; - + ); } diff --git a/packages/anastasis-webui/src/components/fields/EmailInput.tsx b/packages/anastasis-webui/src/components/fields/EmailInput.tsx index e21418fea..4c35c0686 100644 --- a/packages/anastasis-webui/src/components/fields/EmailInput.tsx +++ b/packages/anastasis-webui/src/components/fields/EmailInput.tsx @@ -7,6 +7,7 @@ export interface TextInputProps { error?: string; placeholder?: string; tooltip?: string; + onConfirm?: () => void; bind: [string, (x: string) => void]; } @@ -18,27 +19,39 @@ export function EmailInput(props: TextInputProps): VNode { } }, [props.grabFocus]); const value = props.bind[0]; - const [dirty, setDirty] = useState(false) - const showError = dirty && props.error - return (<div class="field"> - <label class="label"> - {props.label} - {props.tooltip && <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> - <i class="mdi mdi-information" /> - </span>} - </label> - <div class="control has-icons-right"> - <input - value={value} - required - placeholder={props.placeholder} - type="email" - class={showError ? 'input is-danger' : 'input'} - onInput={(e) => {setDirty(true); props.bind[1]((e.target as HTMLInputElement).value)}} - ref={inputRef} - style={{ display: "block" }} /> + const [dirty, setDirty] = useState(false); + const showError = dirty && props.error; + return ( + <div class="field"> + <label class="label"> + {props.label} + {props.tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + <div class="control has-icons-right"> + <input + value={value} + required + placeholder={props.placeholder} + type="email" + class={showError ? "input is-danger" : "input"} + onKeyPress={(e) => { + if (e.key === 'Enter' && props.onConfirm) { + props.onConfirm() + } + }} + onInput={(e) => { + setDirty(true); + props.bind[1]((e.target as HTMLInputElement).value); + }} + ref={inputRef} + style={{ display: "block" }} + /> + </div> + {showError && <p class="help is-danger">{props.error}</p>} </div> - {showError && <p class="help is-danger">{props.error}</p>} - </div> ); } diff --git a/packages/anastasis-webui/src/components/fields/FileInput.tsx b/packages/anastasis-webui/src/components/fields/FileInput.tsx index 8b144ea43..adf51afb0 100644 --- a/packages/anastasis-webui/src/components/fields/FileInput.tsx +++ b/packages/anastasis-webui/src/components/fields/FileInput.tsx @@ -15,16 +15,31 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { h, VNode } from "preact"; import { useLayoutEffect, useRef, useState } from "preact/hooks"; -import { TextInputProps } from "./TextInput"; -const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024 +const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024; + +export interface FileTypeContent { + content: string; + type: string; + name: string; +} + +export interface FileInputProps { + label: string; + grabFocus?: boolean; + disabled?: boolean; + error?: string; + placeholder?: string; + tooltip?: string; + onChange: (v: FileTypeContent | undefined) => void; +} -export function FileInput(props: TextInputProps): VNode { +export function FileInput(props: FileInputProps): VNode { const inputRef = useRef<HTMLInputElement>(null); useLayoutEffect(() => { if (props.grabFocus) { @@ -32,50 +47,58 @@ export function FileInput(props: TextInputProps): VNode { } }, [props.grabFocus]); - const value = props.bind[0]; - // const [dirty, setDirty] = useState(false) - const image = useRef<HTMLInputElement>(null) - const [sizeError, setSizeError] = useState(false) - function onChange(v: string): void { - // setDirty(true); - props.bind[1](v); - } - return <div class="field"> - <label class="label"> - <a onClick={() => image.current?.click()}> - {props.label} - </a> - {props.tooltip && <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> - <i class="mdi mdi-information" /> - </span>} - </label> - <div class="control"> - <input - ref={image} style={{ display: 'none' }} - type="file" name={String(name)} - onChange={e => { - const f: FileList | null = e.currentTarget.files - if (!f || f.length != 1) { - return onChange("") - } - if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { - setSizeError(true) - return onChange("") - } - setSizeError(false) - return f[0].arrayBuffer().then(b => { - const b64 = btoa( - new Uint8Array(b) - .reduce((data, byte) => data + String.fromCharCode(byte), '') - ) - return onChange(`data:${f[0].type};base64,${b64}` as any) - }) - }} /> - {props.error && <p class="help is-danger">{props.error}</p>} - {sizeError && <p class="help is-danger"> - File should be smaller than 1 MB - </p>} + const fileInputRef = useRef<HTMLInputElement>(null); + const [sizeError, setSizeError] = useState(false); + return ( + <div class="field"> + <label class="label"> + <a class="button" onClick={(e) => fileInputRef.current?.click()}> + <div class="icon is-small "> + <i class="mdi mdi-folder" /> + </div> + <span> + {props.label} + </span> + </a> + {props.tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + <div class="control"> + <input + ref={fileInputRef} + style={{ display: "none" }} + type="file" + // name={String(name)} + onChange={(e) => { + const f: FileList | null = e.currentTarget.files; + if (!f || f.length != 1) { + return props.onChange(undefined); + } + console.log(f) + if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { + setSizeError(true); + return props.onChange(undefined); + } + setSizeError(false); + return f[0].arrayBuffer().then((b) => { + const b64 = btoa( + new Uint8Array(b).reduce( + (data, byte) => data + String.fromCharCode(byte), + "", + ), + ); + return props.onChange({content: `data:${f[0].type};base64,${b64}`, name: f[0].name, type: f[0].type}); + }); + }} + /> + {props.error && <p class="help is-danger">{props.error}</p>} + {sizeError && ( + <p class="help is-danger">File should be smaller than 1 MB</p> + )} + </div> </div> - </div> + ); } - diff --git a/packages/anastasis-webui/src/components/fields/ImageInput.tsx b/packages/anastasis-webui/src/components/fields/ImageInput.tsx index d5bf643d4..3f8cc58dd 100644 --- a/packages/anastasis-webui/src/components/fields/ImageInput.tsx +++ b/packages/anastasis-webui/src/components/fields/ImageInput.tsx @@ -15,15 +15,15 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { h, VNode } from "preact"; import { useLayoutEffect, useRef, useState } from "preact/hooks"; import emptyImage from "../../assets/empty.png"; import { TextInputProps } from "./TextInput"; -const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024 +const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024; export function ImageInput(props: TextInputProps): VNode { const inputRef = useRef<HTMLInputElement>(null); @@ -35,47 +35,59 @@ export function ImageInput(props: TextInputProps): VNode { const value = props.bind[0]; // const [dirty, setDirty] = useState(false) - const image = useRef<HTMLInputElement>(null) - const [sizeError, setSizeError] = useState(false) + const image = useRef<HTMLInputElement>(null); + const [sizeError, setSizeError] = useState(false); function onChange(v: string): void { // setDirty(true); props.bind[1](v); } - return <div class="field"> - <label class="label"> - {props.label} - {props.tooltip && <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> - <i class="mdi mdi-information" /> - </span>} - </label> - <div class="control"> - <img src={!value ? emptyImage : value} style={{ width: 200, height: 200 }} onClick={() => image.current?.click()} /> - <input - ref={image} style={{ display: 'none' }} - type="file" name={String(name)} - onChange={e => { - const f: FileList | null = e.currentTarget.files - if (!f || f.length != 1) { - return onChange(emptyImage) - } - if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { - setSizeError(true) - return onChange(emptyImage) - } - setSizeError(false) - return f[0].arrayBuffer().then(b => { - const b64 = btoa( - new Uint8Array(b) - .reduce((data, byte) => data + String.fromCharCode(byte), '') - ) - return onChange(`data:${f[0].type};base64,${b64}` as any) - }) - }} /> - {props.error && <p class="help is-danger">{props.error}</p>} - {sizeError && <p class="help is-danger"> - Image should be smaller than 1 MB - </p>} + return ( + <div class="field"> + <label class="label"> + {props.label} + {props.tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + <div class="control"> + <img + src={!value ? emptyImage : value} + style={{ width: 200, height: 200 }} + onClick={() => image.current?.click()} + /> + <input + ref={image} + style={{ display: "none" }} + type="file" + name={String(name)} + onChange={(e) => { + const f: FileList | null = e.currentTarget.files; + if (!f || f.length != 1) { + return onChange(emptyImage); + } + if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { + setSizeError(true); + return onChange(emptyImage); + } + setSizeError(false); + return f[0].arrayBuffer().then((b) => { + const b64 = btoa( + new Uint8Array(b).reduce( + (data, byte) => data + String.fromCharCode(byte), + "", + ), + ); + return onChange(`data:${f[0].type};base64,${b64}` as any); + }); + }} + /> + {props.error && <p class="help is-danger">{props.error}</p>} + {sizeError && ( + <p class="help is-danger">Image should be smaller than 1 MB</p> + )} + </div> </div> - </div> + ); } - diff --git a/packages/anastasis-webui/src/components/fields/NumberInput.tsx b/packages/anastasis-webui/src/components/fields/NumberInput.tsx index 2afb242b8..4856131c7 100644 --- a/packages/anastasis-webui/src/components/fields/NumberInput.tsx +++ b/packages/anastasis-webui/src/components/fields/NumberInput.tsx @@ -7,10 +7,11 @@ export interface TextInputProps { error?: string; placeholder?: string; tooltip?: string; + onConfirm?: () => void; bind: [string, (x: string) => void]; } -export function NumberInput(props: TextInputProps): VNode { +export function PhoneNumberInput(props: TextInputProps): VNode { const inputRef = useRef<HTMLInputElement>(null); useLayoutEffect(() => { if (props.grabFocus) { @@ -18,26 +19,38 @@ export function NumberInput(props: TextInputProps): VNode { } }, [props.grabFocus]); const value = props.bind[0]; - const [dirty, setDirty] = useState(false) - const showError = dirty && props.error - return (<div class="field"> - <label class="label"> - {props.label} - {props.tooltip && <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> - <i class="mdi mdi-information" /> - </span>} - </label> - <div class="control has-icons-right"> - <input - value={value} - type="number" - placeholder={props.placeholder} - class={showError ? 'input is-danger' : 'input'} - onInput={(e) => {setDirty(true); props.bind[1]((e.target as HTMLInputElement).value)}} - ref={inputRef} - style={{ display: "block" }} /> + const [dirty, setDirty] = useState(false); + const showError = dirty && props.error; + return ( + <div class="field"> + <label class="label"> + {props.label} + {props.tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + <div class="control has-icons-right"> + <input + value={value} + type="tel" + placeholder={props.placeholder} + class={showError ? "input is-danger" : "input"} + onKeyPress={(e) => { + if (e.key === 'Enter' && props.onConfirm) { + props.onConfirm() + } + }} + onInput={(e) => { + setDirty(true); + props.bind[1]((e.target as HTMLInputElement).value); + }} + ref={inputRef} + style={{ display: "block" }} + /> + </div> + {showError && <p class="help is-danger">{props.error}</p>} </div> - {showError && <p class="help is-danger">{props.error}</p>} - </div> ); } diff --git a/packages/anastasis-webui/src/components/fields/TextInput.tsx b/packages/anastasis-webui/src/components/fields/TextInput.tsx index c093689c5..efa95d84e 100644 --- a/packages/anastasis-webui/src/components/fields/TextInput.tsx +++ b/packages/anastasis-webui/src/components/fields/TextInput.tsx @@ -4,9 +4,11 @@ import { useLayoutEffect, useRef, useState } from "preact/hooks"; export interface TextInputProps { label: string; grabFocus?: boolean; + disabled?: boolean; error?: string; placeholder?: string; tooltip?: string; + onConfirm?: () => void; bind: [string, (x: string) => void]; } @@ -18,25 +20,38 @@ export function TextInput(props: TextInputProps): VNode { } }, [props.grabFocus]); const value = props.bind[0]; - const [dirty, setDirty] = useState(false) - const showError = dirty && props.error - return (<div class="field"> - <label class="label"> - {props.label} - {props.tooltip && <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> - <i class="mdi mdi-information" /> - </span>} - </label> - <div class="control has-icons-right"> - <input - value={value} - placeholder={props.placeholder} - class={showError ? 'input is-danger' : 'input'} - onInput={(e) => {setDirty(true); props.bind[1]((e.target as HTMLInputElement).value)}} - ref={inputRef} - style={{ display: "block" }} /> + const [dirty, setDirty] = useState(false); + const showError = dirty && props.error; + return ( + <div class="field"> + <label class="label"> + {props.label} + {props.tooltip && ( + <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span> + )} + </label> + <div class="control has-icons-right"> + <input + value={value} + disabled={props.disabled} + placeholder={props.placeholder} + class={showError ? "input is-danger" : "input"} + onKeyPress={(e) => { + if (e.key === 'Enter' && props.onConfirm) { + props.onConfirm() + } + }} + onInput={(e) => { + setDirty(true); + props.bind[1]((e.target as HTMLInputElement).value); + }} + ref={inputRef} + style={{ display: "block" }} + /> + </div> + {showError && <p class="help is-danger">{props.error}</p>} </div> - {showError && <p class="help is-danger">{props.error}</p>} - </div> ); } diff --git a/packages/anastasis-webui/src/components/menu/LangSelector.tsx b/packages/anastasis-webui/src/components/menu/LangSelector.tsx index 0f91abd7e..fa22a29c0 100644 --- a/packages/anastasis-webui/src/components/menu/LangSelector.tsx +++ b/packages/anastasis-webui/src/components/menu/LangSelector.tsx @@ -15,59 +15,78 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { h, VNode } from "preact"; import { useState } from "preact/hooks"; -import langIcon from '../../assets/icons/languageicon.svg'; +import langIcon from "../../assets/icons/languageicon.svg"; import { useTranslationContext } from "../../context/translation"; -import { strings as messages } from '../../i18n/strings' +import { strings as messages } from "../../i18n/strings"; type LangsNames = { - [P in keyof typeof messages]: string -} + [P in keyof typeof messages]: string; +}; const names: LangsNames = { - es: 'Español [es]', - en: 'English [en]', - fr: 'Français [fr]', - de: 'Deutsch [de]', - sv: 'Svenska [sv]', - it: 'Italiano [it]', -} + es: "Español [es]", + en: "English [en]", + fr: "Français [fr]", + de: "Deutsch [de]", + sv: "Svenska [sv]", + it: "Italiano [it]", +}; function getLangName(s: keyof LangsNames | string): string { - if (names[s]) return names[s] - return String(s) + if (names[s]) return names[s]; + return String(s); } export function LangSelector(): VNode { - const [updatingLang, setUpdatingLang] = useState(false) - const { lang, changeLanguage } = useTranslationContext() + const [updatingLang, setUpdatingLang] = useState(false); + const { lang, changeLanguage } = useTranslationContext(); - return <div class="dropdown is-active "> - <div class="dropdown-trigger"> - <button class="button has-tooltip-left" - data-tooltip="change language selection" - aria-haspopup="true" - aria-controls="dropdown-menu" onClick={() => setUpdatingLang(!updatingLang)}> - <div class="icon is-small is-left"> - <img src={langIcon} /> - </div> - <span>{getLangName(lang)}</span> - <div class="icon is-right"> - <i class="mdi mdi-chevron-down" /> + return ( + <div class="dropdown is-active "> + <div class="dropdown-trigger"> + <button + class="button has-tooltip-left" + data-tooltip="change language selection" + aria-haspopup="true" + aria-controls="dropdown-menu" + onClick={() => setUpdatingLang(!updatingLang)} + > + <div class="icon is-small is-left"> + <img src={langIcon} /> + </div> + <span>{getLangName(lang)}</span> + <div class="icon is-right"> + <i class="mdi mdi-chevron-down" /> + </div> + </button> + </div> + {updatingLang && ( + <div class="dropdown-menu" id="dropdown-menu" role="menu"> + <div class="dropdown-content"> + {Object.keys(messages) + .filter((l) => l !== lang) + .map((l) => ( + <a + key={l} + class="dropdown-item" + value={l} + onClick={() => { + changeLanguage(l); + setUpdatingLang(false); + }} + > + {getLangName(l)} + </a> + ))} + </div> </div> - </button> + )} </div> - {updatingLang && <div class="dropdown-menu" id="dropdown-menu" role="menu"> - <div class="dropdown-content"> - {Object.keys(messages) - .filter((l) => l !== lang) - .map(l => <a key={l} class="dropdown-item" value={l} onClick={() => { changeLanguage(l); setUpdatingLang(false) }}>{getLangName(l)}</a>)} - </div> - </div>} - </div> -}
\ No newline at end of file + ); +} diff --git a/packages/anastasis-webui/src/components/menu/NavigationBar.tsx b/packages/anastasis-webui/src/components/menu/NavigationBar.tsx index 935951ab9..8d5a0473b 100644 --- a/packages/anastasis-webui/src/components/menu/NavigationBar.tsx +++ b/packages/anastasis-webui/src/components/menu/NavigationBar.tsx @@ -15,13 +15,13 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { h, VNode } from 'preact'; -import logo from '../../assets/logo.jpeg'; -import { LangSelector } from './LangSelector'; +import { h, VNode } from "preact"; +import logo from "../../assets/logo.jpeg"; +import { LangSelector } from "./LangSelector"; interface Props { onMobileMenu: () => void; @@ -29,30 +29,51 @@ interface Props { } export function NavigationBar({ onMobileMenu, title }: Props): VNode { - return (<nav class="navbar is-fixed-top" role="navigation" aria-label="main navigation"> - <div class="navbar-brand"> - <span class="navbar-item" style={{ fontSize: 24, fontWeight: 900 }}>{title}</span> - - <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" onClick={(e) => { - onMobileMenu() - e.stopPropagation() - }}> - <span aria-hidden="true" /> - <span aria-hidden="true" /> - <span aria-hidden="true" /> - </a> - </div> - - <div class="navbar-menu "> - <a class="navbar-start is-justify-content-center is-flex-grow-1" href="https://taler.net"> - <img src={logo} style={{ height: 50, maxHeight: 50 }} /> - </a> - <div class="navbar-end"> - <div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}> - {/* <LangSelector /> */} + return ( + <nav + class="navbar is-fixed-top" + role="navigation" + aria-label="main navigation" + > + <div class="navbar-brand"> + <span class="navbar-item" style={{ fontSize: 24, fontWeight: 900 }}> + {title} + </span> + <a + href="mailto:contact@anastasis.lu" + style={{ alignSelf: "center", padding: "0.5em" }} + > + Contact us + </a> + <a + href="https://bugs.anastasis.li/" + style={{ alignSelf: "center", padding: "0.5em" }} + > + Report a bug + </a> + {/* <a + role="button" + class="navbar-burger" + aria-label="menu" + aria-expanded="false" + onClick={(e) => { + onMobileMenu(); + e.stopPropagation(); + }} + > + <span aria-hidden="true" /> + <span aria-hidden="true" /> + <span aria-hidden="true" /> + </a> */} + </div> + + <div class="navbar-menu "> + <div class="navbar-end"> + <div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}> + {/* <LangSelector /> */} + </div> </div> </div> - </div> - </nav> + </nav> ); -}
\ No newline at end of file +} diff --git a/packages/anastasis-webui/src/components/menu/SideBar.tsx b/packages/anastasis-webui/src/components/menu/SideBar.tsx index 72655662f..c73369dd6 100644 --- a/packages/anastasis-webui/src/components/menu/SideBar.tsx +++ b/packages/anastasis-webui/src/components/menu/SideBar.tsx @@ -15,16 +15,15 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { Fragment, h, VNode } from 'preact'; -import { BackupStates, RecoveryStates } from '../../../../anastasis-core/lib'; -import { useAnastasisContext } from '../../context/anastasis'; -import { Translate } from '../../i18n'; -import { LangSelector } from './LangSelector'; +import { Fragment, h, VNode } from "preact"; +import { BackupStates, RecoveryStates } from "../../../../anastasis-core/lib"; +import { useAnastasisContext } from "../../context/anastasis"; +import { Translate } from "../../i18n"; +import { LangSelector } from "./LangSelector"; interface Props { mobile?: boolean; @@ -32,10 +31,10 @@ interface Props { export function Sidebar({ mobile }: Props): VNode { // const config = useConfigContext(); - const config = { version: 'none' } + const config = { version: "none" }; // FIXME: add replacement for __VERSION__ with the current version - const process = { env: { __VERSION__: '0.0.0' } } - const reducer = useAnastasisContext()! + const process = { env: { __VERSION__: "0.0.0" } }; + const reducer = useAnastasisContext()!; return ( <aside class="aside is-placed-left is-expanded"> @@ -44,114 +43,235 @@ export function Sidebar({ mobile }: Props): VNode { </div>} */} <div class="aside-tools"> <div class="aside-tools-label"> - <div><b>Anastasis</b> Reducer</div> - <div class="is-size-7 has-text-right" style={{ lineHeight: 0, marginTop: -10 }}> - {process.env.__VERSION__} ({config.version}) + <div> + <b>Anastasis</b> + </div> + <div + class="is-size-7 has-text-right" + style={{ lineHeight: 0, marginTop: -10 }} + > + Version {process.env.__VERSION__} ({config.version}) </div> </div> </div> <div class="menu is-menu-main"> - {!reducer.currentReducerState && + {!reducer.currentReducerState && ( <p class="menu-label"> <Translate>Backup or Recorver</Translate> </p> - } + )} <ul class="menu-list"> - {!reducer.currentReducerState && + {!reducer.currentReducerState && ( <li> <div class="ml-4"> - <span class="menu-item-label"><Translate>Select one option</Translate></span> - </div> - </li> - } - {reducer.currentReducerState && reducer.currentReducerState.backup_state ? <Fragment> - <li class={reducer.currentReducerState.backup_state === BackupStates.ContinentSelecting || - reducer.currentReducerState.backup_state === BackupStates.CountrySelecting ? 'is-active' : ''}> - <div class="ml-4"> - <span class="menu-item-label"><Translate>Location</Translate></span> - </div> - </li> - <li class={reducer.currentReducerState.backup_state === BackupStates.UserAttributesCollecting ? 'is-active' : ''}> - <div class="ml-4"> - <span class="menu-item-label"><Translate>Personal information</Translate></span> - </div> - </li> - <li class={reducer.currentReducerState.backup_state === BackupStates.AuthenticationsEditing ? 'is-active' : ''}> - <div class="ml-4"> - - <span class="menu-item-label"><Translate>Authorization methods</Translate></span> - </div> - </li> - <li class={reducer.currentReducerState.backup_state === BackupStates.PoliciesReviewing ? 'is-active' : ''}> - <div class="ml-4"> - - <span class="menu-item-label"><Translate>Policies</Translate></span> - </div> - </li> - <li class={reducer.currentReducerState.backup_state === BackupStates.SecretEditing ? 'is-active' : ''}> - <div class="ml-4"> - - <span class="menu-item-label"><Translate>Secret input</Translate></span> + <span class="menu-item-label"> + <Translate>Select one option</Translate> + </span> </div> </li> - {/* <li class={reducer.currentReducerState.backup_state === BackupStates.PoliciesPaying ? 'is-active' : ''}> + )} + {reducer.currentReducerState && + reducer.currentReducerState.backup_state ? ( + <Fragment> + <li + class={ + reducer.currentReducerState.backup_state === + BackupStates.ContinentSelecting || + reducer.currentReducerState.backup_state === + BackupStates.CountrySelecting + ? "is-active" + : "" + } + > + <div class="ml-4"> + <span class="menu-item-label"> + <Translate>Location</Translate> + </span> + </div> + </li> + <li + class={ + reducer.currentReducerState.backup_state === + BackupStates.UserAttributesCollecting + ? "is-active" + : "" + } + > + <div class="ml-4"> + <span class="menu-item-label"> + <Translate>Personal information</Translate> + </span> + </div> + </li> + <li + class={ + reducer.currentReducerState.backup_state === + BackupStates.AuthenticationsEditing + ? "is-active" + : "" + } + > + <div class="ml-4"> + <span class="menu-item-label"> + <Translate>Authorization methods</Translate> + </span> + </div> + </li> + <li + class={ + reducer.currentReducerState.backup_state === + BackupStates.PoliciesReviewing + ? "is-active" + : "" + } + > + <div class="ml-4"> + <span class="menu-item-label"> + <Translate>Policies</Translate> + </span> + </div> + </li> + <li + class={ + reducer.currentReducerState.backup_state === + BackupStates.SecretEditing + ? "is-active" + : "" + } + > + <div class="ml-4"> + <span class="menu-item-label"> + <Translate>Secret input</Translate> + </span> + </div> + </li> + {/* <li class={reducer.currentReducerState.backup_state === BackupStates.PoliciesPaying ? 'is-active' : ''}> <div class="ml-4"> <span class="menu-item-label"><Translate>Payment (optional)</Translate></span> </div> </li> */} - <li class={reducer.currentReducerState.backup_state === BackupStates.BackupFinished ? 'is-active' : ''}> - <div class="ml-4"> - - <span class="menu-item-label"><Translate>Backup completed</Translate></span> - </div> - </li> - {/* <li class={reducer.currentReducerState.backup_state === BackupStates.TruthsPaying ? 'is-active' : ''}> + <li + class={ + reducer.currentReducerState.backup_state === + BackupStates.BackupFinished + ? "is-active" + : "" + } + > + <div class="ml-4"> + <span class="menu-item-label"> + <Translate>Backup completed</Translate> + </span> + </div> + </li> + {/* <li class={reducer.currentReducerState.backup_state === BackupStates.TruthsPaying ? 'is-active' : ''}> <div class="ml-4"> <span class="menu-item-label"><Translate>Truth Paying</Translate></span> </div> </li> */} - </Fragment> : (reducer.currentReducerState && reducer.currentReducerState?.recovery_state && <Fragment> - <li class={reducer.currentReducerState.recovery_state === RecoveryStates.ContinentSelecting || - reducer.currentReducerState.recovery_state === RecoveryStates.CountrySelecting ? 'is-active' : ''}> - <div class="ml-4"> - <span class="menu-item-label"><Translate>Location</Translate></span> - </div> - </li> - <li class={reducer.currentReducerState.recovery_state === RecoveryStates.UserAttributesCollecting ? 'is-active' : ''}> - <div class="ml-4"> - <span class="menu-item-label"><Translate>Personal information</Translate></span> - </div> - </li> - <li class={reducer.currentReducerState.recovery_state === RecoveryStates.SecretSelecting ? 'is-active' : ''}> - <div class="ml-4"> - <span class="menu-item-label"><Translate>Secret selection</Translate></span> - </div> - </li> - <li class={reducer.currentReducerState.recovery_state === RecoveryStates.ChallengeSelecting || - reducer.currentReducerState.recovery_state === RecoveryStates.ChallengeSolving ? 'is-active' : ''}> - <div class="ml-4"> - <span class="menu-item-label"><Translate>Solve Challenges</Translate></span> - </div> - </li> - <li class={reducer.currentReducerState.recovery_state === RecoveryStates.RecoveryFinished ? 'is-active' : ''}> - <div class="ml-4"> - <span class="menu-item-label"><Translate>Secret recovered</Translate></span> - </div> - </li> - </Fragment>)} - {reducer.currentReducerState && + </Fragment> + ) : ( + reducer.currentReducerState && + reducer.currentReducerState?.recovery_state && ( + <Fragment> + <li + class={ + reducer.currentReducerState.recovery_state === + RecoveryStates.ContinentSelecting || + reducer.currentReducerState.recovery_state === + RecoveryStates.CountrySelecting + ? "is-active" + : "" + } + > + <div class="ml-4"> + <span class="menu-item-label"> + <Translate>Location</Translate> + </span> + </div> + </li> + <li + class={ + reducer.currentReducerState.recovery_state === + RecoveryStates.UserAttributesCollecting + ? "is-active" + : "" + } + > + <div class="ml-4"> + <span class="menu-item-label"> + <Translate>Personal information</Translate> + </span> + </div> + </li> + <li + class={ + reducer.currentReducerState.recovery_state === + RecoveryStates.SecretSelecting + ? "is-active" + : "" + } + > + <div class="ml-4"> + <span class="menu-item-label"> + <Translate>Secret selection</Translate> + </span> + </div> + </li> + <li + class={ + reducer.currentReducerState.recovery_state === + RecoveryStates.ChallengeSelecting || + reducer.currentReducerState.recovery_state === + RecoveryStates.ChallengeSolving + ? "is-active" + : "" + } + > + <div class="ml-4"> + <span class="menu-item-label"> + <Translate>Solve Challenges</Translate> + </span> + </div> + </li> + <li + class={ + reducer.currentReducerState.recovery_state === + RecoveryStates.RecoveryFinished + ? "is-active" + : "" + } + > + <div class="ml-4"> + <span class="menu-item-label"> + <Translate>Secret recovered</Translate> + </span> + </div> + </li> + </Fragment> + ) + )} + {reducer.currentReducerState && ( <li> <div class="buttons ml-4"> - <button class="button is-danger is-right" onClick={() => reducer.reset()}>Reset session</button> + <button + class="button is-danger is-right" + onClick={() => reducer.reset()} + > + Reset session + </button> </div> </li> - } - + )} + {/* <li> + <div class="buttons ml-4"> + <button class="button is-info is-right" >Manage providers</button> + </div> + </li> */} </ul> </div> </aside> ); } - diff --git a/packages/anastasis-webui/src/components/menu/index.tsx b/packages/anastasis-webui/src/components/menu/index.tsx index febcd79c8..99d0f7646 100644 --- a/packages/anastasis-webui/src/components/menu/index.tsx +++ b/packages/anastasis-webui/src/components/menu/index.tsx @@ -15,41 +15,53 @@ */ import { ComponentChildren, Fragment, h, VNode } from "preact"; -import Match from 'preact-router/match'; +import Match from "preact-router/match"; import { useEffect, useState } from "preact/hooks"; import { NavigationBar } from "./NavigationBar"; import { Sidebar } from "./SideBar"; - - - interface MenuProps { title: string; } -function WithTitle({ title, children }: { title: string; children: ComponentChildren }): VNode { +function WithTitle({ + title, + children, +}: { + title: string; + children: ComponentChildren; +}): VNode { useEffect(() => { - document.title = `Taler Backoffice: ${title}` - }, [title]) - return <Fragment>{children}</Fragment> + document.title = `${title}`; + }, [title]); + return <Fragment>{children}</Fragment>; } export function Menu({ title }: MenuProps): VNode { - const [mobileOpen, setMobileOpen] = useState(false) - - return <Match>{({ path }: { path: string }) => { - const titleWithSubtitle = title // title ? title : (!admin ? getInstanceTitle(path, instance) : getAdminTitle(path, instance)) - return (<WithTitle title={titleWithSubtitle}> - <div class={mobileOpen ? "has-aside-mobile-expanded" : ""} onClick={() => setMobileOpen(false)}> - <NavigationBar onMobileMenu={() => setMobileOpen(!mobileOpen)} title={titleWithSubtitle} /> - - <Sidebar mobile={mobileOpen} /> - - </div> - </WithTitle> - ) - }}</Match> - + const [mobileOpen, setMobileOpen] = useState(false); + + return ( + <Match> + {({ path }: { path: string }) => { + const titleWithSubtitle = title; // title ? title : (!admin ? getInstanceTitle(path, instance) : getAdminTitle(path, instance)) + return ( + <WithTitle title={titleWithSubtitle}> + <div + class={mobileOpen ? "has-aside-mobile-expanded" : ""} + onClick={() => setMobileOpen(false)} + > + <NavigationBar + onMobileMenu={() => setMobileOpen(!mobileOpen)} + title={titleWithSubtitle} + /> + + <Sidebar mobile={mobileOpen} /> + </div> + </WithTitle> + ); + }} + </Match> + ); } interface NotYetReadyAppMenuProps { @@ -60,37 +72,57 @@ interface NotYetReadyAppMenuProps { interface NotifProps { notification?: Notification; } -export function NotificationCard({ notification: n }: NotifProps): VNode | null { - if (!n) return null - return <div class="notification"> - <div class="columns is-vcentered"> - <div class="column is-12"> - <article class={n.type === 'ERROR' ? "message is-danger" : (n.type === 'WARN' ? "message is-warning" : "message is-info")}> - <div class="message-header"> - <p>{n.message}</p> - </div> - {n.description && - <div class="message-body"> - {n.description} - </div>} - </article> +export function NotificationCard({ + notification: n, +}: NotifProps): VNode | null { + if (!n) return null; + return ( + <div class="notification"> + <div class="columns is-vcentered"> + <div class="column is-12"> + <article + class={ + n.type === "ERROR" + ? "message is-danger" + : n.type === "WARN" + ? "message is-warning" + : "message is-info" + } + > + <div class="message-header"> + <p>{n.message}</p> + </div> + {n.description && <div class="message-body">{n.description}</div>} + </article> + </div> </div> </div> - </div> + ); } -export function NotYetReadyAppMenu({ onLogout, title }: NotYetReadyAppMenuProps): VNode { - const [mobileOpen, setMobileOpen] = useState(false) +export function NotYetReadyAppMenu({ + onLogout, + title, +}: NotYetReadyAppMenuProps): VNode { + const [mobileOpen, setMobileOpen] = useState(false); useEffect(() => { - document.title = `Taler Backoffice: ${title}` - }, [title]) - - return <div class={mobileOpen ? "has-aside-mobile-expanded" : ""} onClick={() => setMobileOpen(false)}> - <NavigationBar onMobileMenu={() => setMobileOpen(!mobileOpen)} title={title} /> - {onLogout && <Sidebar mobile={mobileOpen} />} - </div> - + document.title = `Taler Backoffice: ${title}`; + }, [title]); + + return ( + <div + class="has-aside-mobile-expanded" + // class={mobileOpen ? "has-aside-mobile-expanded" : ""} + onClick={() => setMobileOpen(false)} + > + <NavigationBar + onMobileMenu={() => setMobileOpen(!mobileOpen)} + title={title} + /> + {onLogout && <Sidebar mobile={mobileOpen} />} + </div> + ); } export interface Notification { @@ -99,6 +131,5 @@ export interface Notification { type: MessageType; } -export type ValueOrFunction<T> = T | ((p: T) => T) -export type MessageType = 'INFO' | 'WARN' | 'ERROR' | 'SUCCESS' - +export type ValueOrFunction<T> = T | ((p: T) => T); +export type MessageType = "INFO" | "WARN" | "ERROR" | "SUCCESS"; diff --git a/packages/anastasis-webui/src/components/picker/DatePicker.tsx b/packages/anastasis-webui/src/components/picker/DatePicker.tsx index eb5d8145d..d689db386 100644 --- a/packages/anastasis-webui/src/components/picker/DatePicker.tsx +++ b/packages/anastasis-webui/src/components/picker/DatePicker.tsx @@ -15,9 +15,9 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { h, Component } from "preact"; @@ -34,83 +34,71 @@ interface State { selectYearMode: boolean; currentDate: Date; } -const now = new Date() +const now = new Date(); const monthArrShortFull = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December' -] + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; const monthArrShort = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec' -] - -const dayArr = [ - 'Sun', - 'Mon', - 'Tue', - 'Wed', - 'Thu', - 'Fri', - 'Sat' -] - -const yearArr: number[] = [] - + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +]; + +const dayArr = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +const yearArr: number[] = []; // inspired by https://codepen.io/m4r1vs/pen/MOOxyE export class DatePicker extends Component<Props, State> { - closeDatePicker() { this.props.closeFunction && this.props.closeFunction(); // Function gets passed by parent } /** - * Gets fired when a day gets clicked. - * @param {object} e The event thrown by the <span /> element clicked - */ + * Gets fired when a day gets clicked. + * @param {object} e The event thrown by the <span /> element clicked + */ dayClicked(e: any) { - const element = e.target; // the actual element clicked - if (element.innerHTML === '') return false; // don't continue if <span /> empty + if (element.innerHTML === "") return false; // don't continue if <span /> empty // get date from clicked element (gets attached when rendered) - const date = new Date(element.getAttribute('data-value')); + const date = new Date(element.getAttribute("data-value")); // update the state this.setState({ currentDate: date }); - this.passDateToParent(date) + this.passDateToParent(date); } /** - * returns days in month as array - * @param {number} month the month to display - * @param {number} year the year to display - */ + * returns days in month as array + * @param {number} month the month to display + * @param {number} year the year to display + */ getDaysByMonth(month: number, year: number) { - const calendar = []; const date = new Date(year, month, 1); // month to display @@ -122,15 +110,17 @@ export class DatePicker extends Component<Props, State> { // the calendar is 7*6 fields big, so 42 loops for (let i = 0; i < 42; i++) { - if (i >= firstDay && day !== null) day = day + 1; if (day !== null && day > lastDate) day = null; // append the calendar Array calendar.push({ - day: (day === 0 || day === null) ? null : day, // null or number - date: (day === 0 || day === null) ? null : new Date(year, month, day), // null or Date() - today: (day === now.getDate() && month === now.getMonth() && year === now.getFullYear()) // boolean + day: day === 0 || day === null ? null : day, // null or number + date: day === 0 || day === null ? null : new Date(year, month, day), // null or Date() + today: + day === now.getDate() && + month === now.getMonth() && + year === now.getFullYear(), // boolean }); } @@ -138,51 +128,48 @@ export class DatePicker extends Component<Props, State> { } /** - * Display previous month by updating state - */ + * Display previous month by updating state + */ displayPrevMonth() { if (this.state.displayedMonth <= 0) { this.setState({ displayedMonth: 11, - displayedYear: this.state.displayedYear - 1 + displayedYear: this.state.displayedYear - 1, }); - } - else { + } else { this.setState({ - displayedMonth: this.state.displayedMonth - 1 + displayedMonth: this.state.displayedMonth - 1, }); } } /** - * Display next month by updating state - */ + * Display next month by updating state + */ displayNextMonth() { if (this.state.displayedMonth >= 11) { this.setState({ displayedMonth: 0, - displayedYear: this.state.displayedYear + 1 + displayedYear: this.state.displayedYear + 1, }); - } - else { + } else { this.setState({ - displayedMonth: this.state.displayedMonth + 1 + displayedMonth: this.state.displayedMonth + 1, }); } } /** - * Display the selected month (gets fired when clicking on the date string) - */ + * Display the selected month (gets fired when clicking on the date string) + */ displaySelectedMonth() { if (this.state.selectYearMode) { this.toggleYearSelector(); - } - else { + } else { if (!this.state.currentDate) return false; this.setState({ displayedMonth: this.state.currentDate.getMonth(), - displayedYear: this.state.currentDate.getFullYear() + displayedYear: this.state.currentDate.getFullYear(), }); } } @@ -194,17 +181,21 @@ export class DatePicker extends Component<Props, State> { changeDisplayedYear(e: any) { const element = e.target; this.toggleYearSelector(); - this.setState({ displayedYear: parseInt(element.innerHTML, 10), displayedMonth: 0 }); + this.setState({ + displayedYear: parseInt(element.innerHTML, 10), + displayedMonth: 0, + }); } /** - * Pass the selected date to parent when 'OK' is clicked - */ + * Pass the selected date to parent when 'OK' is clicked + */ passSavedDateDateToParent() { - this.passDateToParent(this.state.currentDate) + this.passDateToParent(this.state.currentDate); } passDateToParent(date: Date) { - if (typeof this.props.dateReceiver === 'function') this.props.dateReceiver(date); + if (typeof this.props.dateReceiver === "function") + this.props.dateReceiver(date); this.closeDatePicker(); } @@ -233,94 +224,133 @@ export class DatePicker extends Component<Props, State> { currentDate: initial, displayedMonth: initial.getMonth(), displayedYear: initial.getFullYear(), - selectYearMode: false - } + selectYearMode: false, + }; } render() { - - const { currentDate, displayedMonth, displayedYear, selectYearMode } = this.state; + const { + currentDate, + displayedMonth, + displayedYear, + selectYearMode, + } = this.state; return ( <div> - <div class={`datePicker ${ this.props.opened && "datePicker--opened"}`}> - + <div class={`datePicker ${this.props.opened && "datePicker--opened"}`}> <div class="datePicker--titles"> - <h3 style={{ - color: selectYearMode ? 'rgba(255,255,255,.87)' : 'rgba(255,255,255,.57)' - }} onClick={this.toggleYearSelector}>{currentDate.getFullYear()}</h3> - <h2 style={{ - color: !selectYearMode ? 'rgba(255,255,255,.87)' : 'rgba(255,255,255,.57)' - }} onClick={this.displaySelectedMonth}> - {dayArr[currentDate.getDay()]}, {monthArrShort[currentDate.getMonth()]} {currentDate.getDate()} + <h3 + style={{ + color: selectYearMode + ? "rgba(255,255,255,.87)" + : "rgba(255,255,255,.57)", + }} + onClick={this.toggleYearSelector} + > + {currentDate.getFullYear()} + </h3> + <h2 + style={{ + color: !selectYearMode + ? "rgba(255,255,255,.87)" + : "rgba(255,255,255,.57)", + }} + onClick={this.displaySelectedMonth} + > + {dayArr[currentDate.getDay()]},{" "} + {monthArrShort[currentDate.getMonth()]} {currentDate.getDate()} </h2> </div> - {!selectYearMode && <nav> - <span onClick={this.displayPrevMonth} class="icon"><i style={{ transform: 'rotate(180deg)' }} class="mdi mdi-forward" /></span> - <h4>{monthArrShortFull[displayedMonth]} {displayedYear}</h4> - <span onClick={this.displayNextMonth} class="icon"><i class="mdi mdi-forward" /></span> - </nav>} + {!selectYearMode && ( + <nav> + <span onClick={this.displayPrevMonth} class="icon"> + <i + style={{ transform: "rotate(180deg)" }} + class="mdi mdi-forward" + /> + </span> + <h4> + {monthArrShortFull[displayedMonth]} {displayedYear} + </h4> + <span onClick={this.displayNextMonth} class="icon"> + <i class="mdi mdi-forward" /> + </span> + </nav> + )} <div class="datePicker--scroll"> - - {!selectYearMode && <div class="datePicker--calendar" > - - <div class="datePicker--dayNames"> - {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day,i) => <span key={i}>{day}</span>)} - </div> - - <div onClick={this.dayClicked} class="datePicker--days"> - - {/* + {!selectYearMode && ( + <div class="datePicker--calendar"> + <div class="datePicker--dayNames"> + {["S", "M", "T", "W", "T", "F", "S"].map((day, i) => ( + <span key={i}>{day}</span> + ))} + </div> + + <div onClick={this.dayClicked} class="datePicker--days"> + {/* Loop through the calendar object returned by getDaysByMonth(). */} - {this.getDaysByMonth(this.state.displayedMonth, this.state.displayedYear) - .map( - day => { - let selected = false; - - if (currentDate && day.date) selected = (currentDate.toLocaleDateString() === day.date.toLocaleDateString()); - - return (<span key={day.day} - class={(day.today ? 'datePicker--today ' : '') + (selected ? 'datePicker--selected' : '')} + {this.getDaysByMonth( + this.state.displayedMonth, + this.state.displayedYear, + ).map((day) => { + let selected = false; + + if (currentDate && day.date) + selected = + currentDate.toLocaleDateString() === + day.date.toLocaleDateString(); + + return ( + <span + key={day.day} + class={ + (day.today ? "datePicker--today " : "") + + (selected ? "datePicker--selected" : "") + } disabled={!day.date} data-value={day.date} > {day.day} - </span>) - } - ) - } - + </span> + ); + })} + </div> </div> - - </div>} - - {selectYearMode && <div class="datePicker--selectYear"> - {(this.props.years || yearArr).map(year => ( - <span key={year} class={(year === displayedYear) ? 'selected' : ''} onClick={this.changeDisplayedYear}> - {year} - </span> - ))} - - </div>} - + )} + + {selectYearMode && ( + <div class="datePicker--selectYear"> + {(this.props.years || yearArr).map((year) => ( + <span + key={year} + class={year === displayedYear ? "selected" : ""} + onClick={this.changeDisplayedYear} + > + {year} + </span> + ))} + </div> + )} </div> </div> - <div class="datePicker--background" onClick={this.closeDatePicker} style={{ - display: this.props.opened ? 'block' : 'none', - }} + <div + class="datePicker--background" + onClick={this.closeDatePicker} + style={{ + display: this.props.opened ? "block" : "none", + }} /> - </div> - ) + ); } } - for (let i = 2010; i <= now.getFullYear() + 10; i++) { yearArr.push(i); } diff --git a/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx b/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx index 275c80fa6..7f96cc15b 100644 --- a/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx +++ b/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx @@ -15,36 +15,41 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { h, FunctionalComponent } from 'preact'; -import { useState } from 'preact/hooks'; -import { DurationPicker as TestedComponent } from './DurationPicker'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { h, FunctionalComponent } from "preact"; +import { useState } from "preact/hooks"; +import { DurationPicker as TestedComponent } from "./DurationPicker"; export default { - title: 'Components/Picker/Duration', + title: "Components/Picker/Duration", component: TestedComponent, argTypes: { - onCreate: { action: 'onCreate' }, - goBack: { action: 'goBack' }, - } + onCreate: { action: "onCreate" }, + goBack: { action: "goBack" }, + }, }; -function createExample<Props>(Component: FunctionalComponent<Props>, props: Partial<Props>) { - const r = (args: any) => <Component {...args} /> - r.args = props - return r +function createExample<Props>( + Component: FunctionalComponent<Props>, + props: Partial<Props>, +) { + const r = (args: any) => <Component {...args} />; + r.args = props; + return r; } export const Example = createExample(TestedComponent, { - days: true, minutes: true, hours: true, seconds: true, - value: 10000000 + days: true, + minutes: true, + hours: true, + seconds: true, + value: 10000000, }); export const WithState = () => { - const [v,s] = useState<number>(1000000) - return <TestedComponent value={v} onChange={s} days minutes hours seconds /> -} + const [v, s] = useState<number>(1000000); + return <TestedComponent value={v} onChange={s} days minutes hours seconds />; +}; diff --git a/packages/anastasis-webui/src/components/picker/DurationPicker.tsx b/packages/anastasis-webui/src/components/picker/DurationPicker.tsx index 235a63e2d..8a1faf4d0 100644 --- a/packages/anastasis-webui/src/components/picker/DurationPicker.tsx +++ b/packages/anastasis-webui/src/components/picker/DurationPicker.tsx @@ -15,9 +15,9 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -30,75 +30,123 @@ export interface Props { seconds?: boolean; days?: boolean; onChange: (value: number) => void; - value: number + value: number; } // inspiration taken from https://github.com/flurmbo/react-duration-picker -export function DurationPicker({ days, hours, minutes, seconds, onChange, value }: Props): VNode { - const ss = 1000 - const ms = ss * 60 - const hs = ms * 60 - const ds = hs * 24 - const i18n = useTranslator() - - return <div class="rdp-picker"> - {days && <DurationColumn unit={i18n`days`} max={99} - value={Math.floor(value / ds)} - onDecrease={value >= ds ? () => onChange(value - ds) : undefined} - onIncrease={value < 99 * ds ? () => onChange(value + ds) : undefined} - onChange={diff => onChange(value + diff * ds)} - />} - {hours && <DurationColumn unit={i18n`hours`} max={23} min={1} - value={Math.floor(value / hs) % 24} - onDecrease={value >= hs ? () => onChange(value - hs) : undefined} - onIncrease={value < 99 * ds ? () => onChange(value + hs) : undefined} - onChange={diff => onChange(value + diff * hs)} - />} - {minutes && <DurationColumn unit={i18n`minutes`} max={59} min={1} - value={Math.floor(value / ms) % 60} - onDecrease={value >= ms ? () => onChange(value - ms) : undefined} - onIncrease={value < 99 * ds ? () => onChange(value + ms) : undefined} - onChange={diff => onChange(value + diff * ms)} - />} - {seconds && <DurationColumn unit={i18n`seconds`} max={59} - value={Math.floor(value / ss) % 60} - onDecrease={value >= ss ? () => onChange(value - ss) : undefined} - onIncrease={value < 99 * ds ? () => onChange(value + ss) : undefined} - onChange={diff => onChange(value + diff * ss)} - />} - </div> +export function DurationPicker({ + days, + hours, + minutes, + seconds, + onChange, + value, +}: Props): VNode { + const ss = 1000; + const ms = ss * 60; + const hs = ms * 60; + const ds = hs * 24; + const i18n = useTranslator(); + + return ( + <div class="rdp-picker"> + {days && ( + <DurationColumn + unit={i18n`days`} + max={99} + value={Math.floor(value / ds)} + onDecrease={value >= ds ? () => onChange(value - ds) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + ds) : undefined} + onChange={(diff) => onChange(value + diff * ds)} + /> + )} + {hours && ( + <DurationColumn + unit={i18n`hours`} + max={23} + min={1} + value={Math.floor(value / hs) % 24} + onDecrease={value >= hs ? () => onChange(value - hs) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + hs) : undefined} + onChange={(diff) => onChange(value + diff * hs)} + /> + )} + {minutes && ( + <DurationColumn + unit={i18n`minutes`} + max={59} + min={1} + value={Math.floor(value / ms) % 60} + onDecrease={value >= ms ? () => onChange(value - ms) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + ms) : undefined} + onChange={(diff) => onChange(value + diff * ms)} + /> + )} + {seconds && ( + <DurationColumn + unit={i18n`seconds`} + max={59} + value={Math.floor(value / ss) % 60} + onDecrease={value >= ss ? () => onChange(value - ss) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + ss) : undefined} + onChange={(diff) => onChange(value + diff * ss)} + /> + )} + </div> + ); } interface ColProps { - unit: string, - min?: number, - max: number, - value: number, + unit: string; + min?: number; + max: number; + value: number; onIncrease?: () => void; onDecrease?: () => void; onChange?: (diff: number) => void; } -function InputNumber({ initial, onChange }: { initial: number, onChange: (n: number) => void }) { - const [value, handler] = useState<{v:string}>({ - v: toTwoDigitString(initial) - }) - - return <input - value={value.v} - onBlur={(e) => onChange(parseInt(value.v, 10))} - onInput={(e) => { - e.preventDefault() - const n = Number.parseInt(e.currentTarget.value, 10); - if (isNaN(n)) return handler({v:toTwoDigitString(initial)}) - return handler({v:toTwoDigitString(n)}) - }} - style={{ width: 50, border: 'none', fontSize: 'inherit', background: 'inherit' }} /> -} +function InputNumber({ + initial, + onChange, +}: { + initial: number; + onChange: (n: number) => void; +}) { + const [value, handler] = useState<{ v: string }>({ + v: toTwoDigitString(initial), + }); -function DurationColumn({ unit, min = 0, max, value, onIncrease, onDecrease, onChange }: ColProps): VNode { + return ( + <input + value={value.v} + onBlur={(e) => onChange(parseInt(value.v, 10))} + onInput={(e) => { + e.preventDefault(); + const n = Number.parseInt(e.currentTarget.value, 10); + if (isNaN(n)) return handler({ v: toTwoDigitString(initial) }); + return handler({ v: toTwoDigitString(n) }); + }} + style={{ + width: 50, + border: "none", + fontSize: "inherit", + background: "inherit", + }} + /> + ); +} - const cellHeight = 35 +function DurationColumn({ + unit, + min = 0, + max, + value, + onIncrease, + onDecrease, + onChange, +}: ColProps): VNode { + const cellHeight = 35; return ( <div class="rdp-column-container"> <div class="rdp-masked-div"> @@ -106,49 +154,58 @@ function DurationColumn({ unit, min = 0, max, value, onIncrease, onDecrease, onC <hr class="rdp-reticule" style={{ top: cellHeight * 3 - 1 }} /> <div class="rdp-column" style={{ top: 0 }}> - <div class="rdp-cell" key={value - 2}> - {onDecrease && <button style={{ width: '100%', textAlign: 'center', margin: 5 }} - onClick={onDecrease}> - <span class="icon"> - <i class="mdi mdi-chevron-up" /> - </span> - </button>} + {onDecrease && ( + <button + style={{ width: "100%", textAlign: "center", margin: 5 }} + onClick={onDecrease} + > + <span class="icon"> + <i class="mdi mdi-chevron-up" /> + </span> + </button> + )} </div> <div class="rdp-cell" key={value - 1}> - {value > min ? toTwoDigitString(value - 1) : ''} + {value > min ? toTwoDigitString(value - 1) : ""} </div> <div class="rdp-cell rdp-center" key={value}> - {onChange ? - <InputNumber initial={value} onChange={(n) => onChange(n - value)} /> : + {onChange ? ( + <InputNumber + initial={value} + onChange={(n) => onChange(n - value)} + /> + ) : ( toTwoDigitString(value) - } + )} <div>{unit}</div> </div> <div class="rdp-cell" key={value + 1}> - {value < max ? toTwoDigitString(value + 1) : ''} + {value < max ? toTwoDigitString(value + 1) : ""} </div> <div class="rdp-cell" key={value + 2}> - {onIncrease && <button style={{ width: '100%', textAlign: 'center', margin: 5 }} - onClick={onIncrease}> - <span class="icon"> - <i class="mdi mdi-chevron-down" /> - </span> - </button>} + {onIncrease && ( + <button + style={{ width: "100%", textAlign: "center", margin: 5 }} + onClick={onIncrease} + > + <span class="icon"> + <i class="mdi mdi-chevron-down" /> + </span> + </button> + )} </div> - </div> </div> </div> ); } - function toTwoDigitString(n: number) { if (n < 10) { return `0${n}`; } return `${n}`; -}
\ No newline at end of file +} diff --git a/packages/anastasis-webui/src/context/anastasis.ts b/packages/anastasis-webui/src/context/anastasis.ts index e7f93ed43..c2e7b2a47 100644 --- a/packages/anastasis-webui/src/context/anastasis.ts +++ b/packages/anastasis-webui/src/context/anastasis.ts @@ -15,19 +15,19 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createContext, h, VNode } from 'preact'; -import { useContext } from 'preact/hooks'; -import { AnastasisReducerApi } from '../hooks/use-anastasis-reducer'; +import { createContext, h, VNode } from "preact"; +import { useContext } from "preact/hooks"; +import { AnastasisReducerApi } from "../hooks/use-anastasis-reducer"; type Type = AnastasisReducerApi | undefined; -const initial = undefined +const initial = undefined; -const Context = createContext<Type>(initial) +const Context = createContext<Type>(initial); interface Props { value: AnastasisReducerApi; @@ -36,6 +36,6 @@ interface Props { export const AnastasisProvider = ({ value, children }: Props): VNode => { return h(Context.Provider, { value, children }); -} +}; -export const useAnastasisContext = (): Type => useContext(Context);
\ No newline at end of file +export const useAnastasisContext = (): Type => useContext(Context); diff --git a/packages/anastasis-webui/src/context/translation.ts b/packages/anastasis-webui/src/context/translation.ts index 5ceb5d428..a47864d75 100644 --- a/packages/anastasis-webui/src/context/translation.ts +++ b/packages/anastasis-webui/src/context/translation.ts @@ -15,13 +15,13 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createContext, h, VNode } from 'preact' -import { useContext, useEffect } from 'preact/hooks' -import { useLang } from '../hooks' +import { createContext, h, VNode } from "preact"; +import { useContext, useEffect } from "preact/hooks"; +import { useLang } from "../hooks"; import * as jedLib from "jed"; import { strings } from "../i18n/strings"; @@ -31,13 +31,13 @@ interface Type { changeLanguage: (l: string) => void; } const initial = { - lang: 'en', + lang: "en", handler: null, changeLanguage: () => { // do not change anything - } -} -const Context = createContext<Type>(initial) + }, +}; +const Context = createContext<Type>(initial); interface Props { initial?: string; @@ -45,15 +45,22 @@ interface Props { forceLang?: string; } -export const TranslationProvider = ({ initial, children, forceLang }: Props): VNode => { - const [lang, changeLanguage] = useLang(initial) +export const TranslationProvider = ({ + initial, + children, + forceLang, +}: Props): VNode => { + const [lang, changeLanguage] = useLang(initial); useEffect(() => { if (forceLang) { - changeLanguage(forceLang) + changeLanguage(forceLang); } - }) - const handler = new jedLib.Jed(strings[lang] || strings['en']); - return h(Context.Provider, { value: { lang, handler, changeLanguage }, children }); -} + }); + const handler = new jedLib.Jed(strings[lang] || strings["en"]); + return h(Context.Provider, { + value: { lang, handler, changeLanguage }, + children, + }); +}; -export const useTranslationContext = (): Type => useContext(Context);
\ No newline at end of file +export const useTranslationContext = (): Type => useContext(Context); diff --git a/packages/anastasis-webui/src/declaration.d.ts b/packages/anastasis-webui/src/declaration.d.ts index 2c4b7cb3a..00b3d41d5 100644 --- a/packages/anastasis-webui/src/declaration.d.ts +++ b/packages/anastasis-webui/src/declaration.d.ts @@ -1,20 +1,20 @@ declare module "*.css" { - const mapping: Record<string, string>; - export default mapping; + const mapping: Record<string, string>; + export default mapping; } -declare module '*.svg' { - const content: any; - export default content; +declare module "*.svg" { + const content: any; + export default content; } -declare module '*.jpeg' { - const content: any; - export default content; +declare module "*.jpeg" { + const content: any; + export default content; } -declare module '*.png' { - const content: any; - export default content; +declare module "*.png" { + const content: any; + export default content; } -declare module 'jed' { - const x: any; - export = x; +declare module "jed" { + const x: any; + export = x; } diff --git a/packages/anastasis-webui/src/hooks/async.ts b/packages/anastasis-webui/src/hooks/async.ts index ea3ff6acf..0fc197554 100644 --- a/packages/anastasis-webui/src/hooks/async.ts +++ b/packages/anastasis-webui/src/hooks/async.ts @@ -15,9 +15,9 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { useState } from "preact/hooks"; // import { cancelPendingRequest } from "./backend"; @@ -34,36 +34,39 @@ export interface AsyncOperationApi<T> { error: string | undefined; } -export function useAsync<T>(fn?: (...args: any) => Promise<T>, { slowTolerance: tooLong }: Options = { slowTolerance: 1000 }): AsyncOperationApi<T> { +export function useAsync<T>( + fn?: (...args: any) => Promise<T>, + { slowTolerance: tooLong }: Options = { slowTolerance: 1000 }, +): AsyncOperationApi<T> { const [data, setData] = useState<T | undefined>(undefined); const [isLoading, setLoading] = useState<boolean>(false); const [error, setError] = useState<any>(undefined); - const [isSlow, setSlow] = useState(false) + const [isSlow, setSlow] = useState(false); const request = async (...args: any) => { if (!fn) return; setLoading(true); const handler = setTimeout(() => { - setSlow(true) - }, tooLong) + setSlow(true); + }, tooLong); try { - console.log("calling async", args) + console.log("calling async", args); const result = await fn(...args); - console.log("async back", result) + console.log("async back", result); setData(result); } catch (error) { setError(error); } setLoading(false); - setSlow(false) - clearTimeout(handler) + setSlow(false); + clearTimeout(handler); }; function cancel() { // cancelPendingRequest() setLoading(false); - setSlow(false) + setSlow(false); } return { @@ -72,6 +75,6 @@ export function useAsync<T>(fn?: (...args: any) => Promise<T>, { slowTolerance: data, isSlow, isLoading, - error + error, }; } diff --git a/packages/anastasis-webui/src/hooks/index.ts b/packages/anastasis-webui/src/hooks/index.ts index 15df4f154..9a1b50a11 100644 --- a/packages/anastasis-webui/src/hooks/index.ts +++ b/packages/anastasis-webui/src/hooks/index.ts @@ -15,81 +15,110 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { StateUpdater, useState } from "preact/hooks"; -export type ValueOrFunction<T> = T | ((p: T) => T) - +export type ValueOrFunction<T> = T | ((p: T) => T); const calculateRootPath = () => { - const rootPath = typeof window !== undefined ? window.location.origin + window.location.pathname : '/' - return rootPath -} - -export function useBackendURL(url?: string): [string, boolean, StateUpdater<string>, () => void] { - const [value, setter] = useNotNullLocalStorage('backend-url', url || calculateRootPath()) - const [triedToLog, setTriedToLog] = useLocalStorage('tried-login') + const rootPath = + typeof window !== undefined + ? window.location.origin + window.location.pathname + : "/"; + return rootPath; +}; + +export function useBackendURL( + url?: string, +): [string, boolean, StateUpdater<string>, () => void] { + const [value, setter] = useNotNullLocalStorage( + "backend-url", + url || calculateRootPath(), + ); + const [triedToLog, setTriedToLog] = useLocalStorage("tried-login"); const checkedSetter = (v: ValueOrFunction<string>) => { - setTriedToLog('yes') - return setter(p => (v instanceof Function ? v(p) : v).replace(/\/$/, '')) - } + setTriedToLog("yes"); + return setter((p) => (v instanceof Function ? v(p) : v).replace(/\/$/, "")); + }; const resetBackend = () => { - setTriedToLog(undefined) - } - return [value, !!triedToLog, checkedSetter, resetBackend] + setTriedToLog(undefined); + }; + return [value, !!triedToLog, checkedSetter, resetBackend]; } -export function useBackendDefaultToken(): [string | undefined, StateUpdater<string | undefined>] { - return useLocalStorage('backend-token') +export function useBackendDefaultToken(): [ + string | undefined, + StateUpdater<string | undefined>, +] { + return useLocalStorage("backend-token"); } -export function useBackendInstanceToken(id: string): [string | undefined, StateUpdater<string | undefined>] { - const [token, setToken] = useLocalStorage(`backend-token-${id}`) - const [defaultToken, defaultSetToken] = useBackendDefaultToken() +export function useBackendInstanceToken( + id: string, +): [string | undefined, StateUpdater<string | undefined>] { + const [token, setToken] = useLocalStorage(`backend-token-${id}`); + const [defaultToken, defaultSetToken] = useBackendDefaultToken(); // instance named 'default' use the default token - if (id === 'default') { - return [defaultToken, defaultSetToken] + if (id === "default") { + return [defaultToken, defaultSetToken]; } - return [token, setToken] + return [token, setToken]; } export function useLang(initial?: string): [string, StateUpdater<string>] { - const browserLang = typeof window !== "undefined" ? navigator.language || (navigator as any).userLanguage : undefined; - const defaultLang = (browserLang || initial || 'en').substring(0, 2) - return useNotNullLocalStorage('lang-preference', defaultLang) + const browserLang = + typeof window !== "undefined" + ? navigator.language || (navigator as any).userLanguage + : undefined; + const defaultLang = (browserLang || initial || "en").substring(0, 2); + return useNotNullLocalStorage("lang-preference", defaultLang); } -export function useLocalStorage(key: string, initialValue?: string): [string | undefined, StateUpdater<string | undefined>] { - const [storedValue, setStoredValue] = useState<string | undefined>((): string | undefined => { - return typeof window !== "undefined" ? window.localStorage.getItem(key) || initialValue : initialValue; +export function useLocalStorage( + key: string, + initialValue?: string, +): [string | undefined, StateUpdater<string | undefined>] { + const [storedValue, setStoredValue] = useState<string | undefined>((): + | string + | undefined => { + return typeof window !== "undefined" + ? window.localStorage.getItem(key) || initialValue + : initialValue; }); - const setValue = (value?: string | ((val?: string) => string | undefined)) => { - setStoredValue(p => { - const toStore = value instanceof Function ? value(p) : value + const setValue = ( + value?: string | ((val?: string) => string | undefined), + ) => { + setStoredValue((p) => { + const toStore = value instanceof Function ? value(p) : value; if (typeof window !== "undefined") { if (!toStore) { - window.localStorage.removeItem(key) + window.localStorage.removeItem(key); } else { window.localStorage.setItem(key, toStore); } } - return toStore - }) + return toStore; + }); }; return [storedValue, setValue]; } -export function useNotNullLocalStorage(key: string, initialValue: string): [string, StateUpdater<string>] { +export function useNotNullLocalStorage( + key: string, + initialValue: string, +): [string, StateUpdater<string>] { const [storedValue, setStoredValue] = useState<string>((): string => { - return typeof window !== "undefined" ? window.localStorage.getItem(key) || initialValue : initialValue; + return typeof window !== "undefined" + ? window.localStorage.getItem(key) || initialValue + : initialValue; }); const setValue = (value: string | ((val: string) => string)) => { @@ -97,7 +126,7 @@ export function useNotNullLocalStorage(key: string, initialValue: string): [stri setStoredValue(valueToStore); if (typeof window !== "undefined") { if (!valueToStore) { - window.localStorage.removeItem(key) + window.localStorage.removeItem(key); } else { window.localStorage.setItem(key, valueToStore); } @@ -106,5 +135,3 @@ export function useNotNullLocalStorage(key: string, initialValue: string): [stri return [storedValue, setValue]; } - - diff --git a/packages/anastasis-webui/src/i18n/index.tsx b/packages/anastasis-webui/src/i18n/index.tsx index 63c8e1934..6e2c4e79a 100644 --- a/packages/anastasis-webui/src/i18n/index.tsx +++ b/packages/anastasis-webui/src/i18n/index.tsx @@ -27,23 +27,25 @@ import { useTranslationContext } from "../context/translation"; export function useTranslator() { const ctx = useTranslationContext(); - const jed = ctx.handler - return function str(stringSeq: TemplateStringsArray, ...values: any[]): string { + const jed = ctx.handler; + return function str( + stringSeq: TemplateStringsArray, + ...values: any[] + ): string { const s = toI18nString(stringSeq); - if (!s) return s + if (!s) return s; const tr = jed .translate(s) .ifPlural(1, s) .fetch(...values); return tr; - } + }; } - /** * Convert template strings to a msgid */ - function toI18nString(stringSeq: ReadonlyArray<string>): string { +function toI18nString(stringSeq: ReadonlyArray<string>): string { let s = ""; for (let i = 0; i < stringSeq.length; i++) { s += stringSeq[i]; @@ -54,7 +56,6 @@ export function useTranslator() { return s; } - interface TranslateSwitchProps { target: number; children: ComponentChildren; @@ -110,7 +111,7 @@ function getTranslatedChildren( // Text result.push(tr[i]); } else { - const childIdx = Number.parseInt(tr[i],10) - 1; + const childIdx = Number.parseInt(tr[i], 10) - 1; result.push(placeholderChildren[childIdx]); } } @@ -131,9 +132,9 @@ function getTranslatedChildren( */ export function Translate({ children }: TranslateProps): VNode { const s = stringifyChildren(children); - const ctx = useTranslationContext() + const ctx = useTranslationContext(); const translation: string = ctx.handler.ngettext(s, s, 1); - const result = getTranslatedChildren(translation, children) + const result = getTranslatedChildren(translation, children); return <Fragment>{result}</Fragment>; } @@ -154,14 +155,16 @@ export function TranslateSwitch({ children, target }: TranslateSwitchProps) { let plural: VNode<TranslationPluralProps> | undefined; // const children = this.props.children; if (children) { - (children instanceof Array ? children : [children]).forEach((child: any) => { - if (child.type === TranslatePlural) { - plural = child; - } - if (child.type === TranslateSingular) { - singular = child; - } - }); + (children instanceof Array ? children : [children]).forEach( + (child: any) => { + if (child.type === TranslatePlural) { + plural = child; + } + if (child.type === TranslateSingular) { + singular = child; + } + }, + ); } if (!singular || !plural) { console.error("translation not found"); @@ -182,9 +185,12 @@ interface TranslationPluralProps { /** * See [[TranslateSwitch]]. */ -export function TranslatePlural({ children, target }: TranslationPluralProps): VNode { +export function TranslatePlural({ + children, + target, +}: TranslationPluralProps): VNode { const s = stringifyChildren(children); - const ctx = useTranslationContext() + const ctx = useTranslationContext(); const translation = ctx.handler.ngettext(s, s, 1); const result = getTranslatedChildren(translation, children); return <Fragment>{result}</Fragment>; @@ -193,11 +199,13 @@ export function TranslatePlural({ children, target }: TranslationPluralProps): V /** * See [[TranslateSwitch]]. */ -export function TranslateSingular({ children, target }: TranslationPluralProps): VNode { +export function TranslateSingular({ + children, + target, +}: TranslationPluralProps): VNode { const s = stringifyChildren(children); - const ctx = useTranslationContext() + const ctx = useTranslationContext(); const translation = ctx.handler.ngettext(s, s, target); const result = getTranslatedChildren(translation, children); return <Fragment>{result}</Fragment>; - } diff --git a/packages/anastasis-webui/src/i18n/strings.ts b/packages/anastasis-webui/src/i18n/strings.ts index b4f376ce0..d12e63e88 100644 --- a/packages/anastasis-webui/src/i18n/strings.ts +++ b/packages/anastasis-webui/src/i18n/strings.ts @@ -15,30 +15,30 @@ */ /*eslint quote-props: ["error", "consistent"]*/ -export const strings: {[s: string]: any} = {}; +export const strings: { [s: string]: any } = {}; -strings['de'] = { - "domain": "messages", - "locale_data": { - "messages": { +strings["de"] = { + domain: "messages", + locale_data: { + messages: { "": { - "domain": "messages", - "plural_forms": "nplurals=2; plural=(n != 1);", - "lang": "" + domain: "messages", + plural_forms: "nplurals=2; plural=(n != 1);", + lang: "", }, - } - } + }, + }, }; -strings['en'] = { - "domain": "messages", - "locale_data": { - "messages": { +strings["en"] = { + domain: "messages", + locale_data: { + messages: { "": { - "domain": "messages", - "plural_forms": "nplurals=2; plural=(n != 1);", - "lang": "" + domain: "messages", + plural_forms: "nplurals=2; plural=(n != 1);", + lang: "", }, - } - } + }, + }, }; diff --git a/packages/anastasis-webui/src/index.ts b/packages/anastasis-webui/src/index.ts index e78b9c194..4bd7b28f3 100644 --- a/packages/anastasis-webui/src/index.ts +++ b/packages/anastasis-webui/src/index.ts @@ -1,4 +1,4 @@ -import App from './components/app'; -import './scss/main.scss'; +import App from "./components/app"; +import "./scss/main.scss"; export default App; diff --git a/packages/anastasis-webui/src/manifest.json b/packages/anastasis-webui/src/manifest.json index 6b44a2b31..2752dad77 100644 --- a/packages/anastasis-webui/src/manifest.json +++ b/packages/anastasis-webui/src/manifest.json @@ -18,4 +18,4 @@ "sizes": "512x512" } ] -}
\ No newline at end of file +} diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/AddingProviderScreen.stories.tsx index 43807fefe..9b067127d 100644 --- a/packages/anastasis-webui/src/pages/home/AddingProviderScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,24 +15,23 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { AddingProviderScreen as TestedComponent } from './AddingProviderScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { AddingProviderScreen as TestedComponent } from "./AddingProviderScreen"; export default { - title: 'Pages/backup/AddingProviderScreen', + title: "Pages/ManageProvider", component: TestedComponent, args: { - order: 4, + order: 1, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; @@ -41,10 +39,31 @@ export const NewProvider = createExample(TestedComponent, { ...reducerStatesExample.authEditing, } as ReducerState); -export const NewSMSProvider = createExample(TestedComponent, { +export const NewProviderWithoutProviderList = createExample(TestedComponent, { ...reducerStatesExample.authEditing, -} as ReducerState, { providerType: 'sms'}); + authentication_providers: {}, +} as ReducerState); -export const NewIBANProvider = createExample(TestedComponent, { - ...reducerStatesExample.authEditing, -} as ReducerState, { providerType: 'iban' }); +export const NewVideoProvider = createExample( + TestedComponent, + { + ...reducerStatesExample.authEditing, + } as ReducerState, + { providerType: "video" }, +); + +export const NewSmsProvider = createExample( + TestedComponent, + { + ...reducerStatesExample.authEditing, + } as ReducerState, + { providerType: "sms" }, +); + +export const NewIBANProvider = createExample( + TestedComponent, + { + ...reducerStatesExample.authEditing, + } as ReducerState, + { providerType: "iban" }, +); diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx b/packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx index 9c83da49e..96b38e92d 100644 --- a/packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx @@ -1,101 +1,260 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; +import { AuthenticationProviderStatusOk } from "anastasis-core"; import { h, VNode } from "preact"; -import { useLayoutEffect, useRef, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import { TextInput } from "../../components/fields/TextInput"; +import { useAnastasisContext } from "../../context/anastasis"; import { authMethods, KnownAuthMethods } from "./authMethod"; import { AnastasisClientFrame } from "./index"; interface Props { providerType?: KnownAuthMethods; - cancel: () => void; + onCancel: () => void; } -export function AddingProviderScreen({ providerType, cancel }: Props): VNode { + +async function testProvider( + url: string, + expectedMethodType?: string, +): Promise<void> { + try { + const response = await fetch(new URL("config", url).href); + const json = await response.json().catch((d) => ({})); + if (!("methods" in json) || !Array.isArray(json.methods)) { + throw Error( + "This provider doesn't have authentication method. Check the provider URL", + ); + } + console.log("expected", expectedMethodType); + 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 Error( + `This provider does not support authentication method ${expectedMethodType}`, + ); + } + return; + } catch (e) { + console.log("error", e); + const error = + e instanceof Error + ? Error( + `There was an error testing this provider, try another one. ${e.message}`, + ) + : Error(`There was an error testing this provider, try another one.`); + throw error; + } +} + +export function AddingProviderScreen({ providerType, onCancel }: Props): VNode { + const reducer = useAnastasisContext(); + const [providerURL, setProviderURL] = useState(""); - const [error, setError] = useState<string | undefined>() - const providerLabel = providerType ? authMethods[providerType].label : undefined + const [error, setError] = useState<string | undefined>(); + const [testing, setTesting] = useState(false); + const providerLabel = providerType + ? authMethods[providerType].label + : undefined; - function testProvider(): void { - setError(undefined) + //FIXME: move this timeout logic into a hook + const timeout = useRef<number | undefined>(undefined); + useEffect(() => { + if (timeout) window.clearTimeout(timeout.current); + timeout.current = window.setTimeout(async () => { + const url = providerURL.endsWith("/") ? providerURL : providerURL + "/"; + if (!providerURL || authProviders.includes(url)) return; + try { + setTesting(true); + await testProvider(url, providerType); + // this is use as tested but everything when ok + // undefined will mean that the field is not dirty + setError(""); + } catch (e) { + console.log("tuvieja", e); + if (e instanceof Error) setError(e.message); + } + setTesting(false); + }, 200); + }, [providerURL, reducer]); - fetch(`${providerURL}/config`) - .then(r => r.json().catch(d => ({}))) - .then(r => { - if (!("methods" in r) || !Array.isArray(r.methods)) { - setError("This provider doesn't have authentication method. Check the provider URL") - return; - } - if (!providerLabel) { - setError("") - return - } - let found = false - for (let i = 0; i < r.methods.length && !found; i++) { - found = r.methods[i].type !== providerType - } - if (!found) { - setError(`This provider does not support authentication method ${providerLabel}`) - } - }) - .catch(e => { - setError(`There was an error testing this provider, try another one. ${e.message}`) - }) + if (!reducer) { + return <div>no reducer in context</div>; + } + if ( + !reducer.currentReducerState || + !("authentication_providers" in reducer.currentReducerState) + ) { + return <div>invalid state</div>; } - function addProvider(): void { - // addAuthMethod({ - // authentication_method: { - // type: "sms", - // instructions: `SMS to ${providerURL}`, - // challenge: encodeCrock(stringToBytes(providerURL)), - // }, - // }); + + async function addProvider(provider_url: string): Promise<void> { + await reducer?.transition("add_provider", { provider_url }); + onCancel(); + } + function deleteProvider(provider_url: string): void { + reducer?.transition("delete_provider", { provider_url }); } - const inputRef = useRef<HTMLInputElement>(null); - useLayoutEffect(() => { - inputRef.current?.focus(); - }, []); - let errors = !providerURL ? 'Add provider URL' : undefined + const allAuthProviders = + reducer.currentReducerState.authentication_providers || {}; + const authProviders = Object.keys(allAuthProviders).filter((provUrl) => { + const p = allAuthProviders[provUrl]; + if (!providerLabel) { + return p && "currency" in p; + } else { + return ( + p && + "currency" in p && + p.methods.findIndex((m) => m.type === providerType) !== -1 + ); + } + }); + + let errors = !providerURL ? "Add provider URL" : undefined; + let url: string | undefined; try { - new URL(providerURL) + url = new URL("", providerURL).href; } catch { - errors = 'Check the URL' + errors = "Check the URL"; } if (!!error && !errors) { - errors = error + errors = error; + } + if (!errors && authProviders.includes(url!)) { + errors = "That provider is already known"; } return ( - <AnastasisClientFrame hideNav - title={!providerLabel ? `Backup: Adding a provider` : `Backup: Adding a ${providerLabel} provider`} - hideNext={errors}> + <AnastasisClientFrame + hideNav + title="Backup: Manage providers" + hideNext={errors} + > <div> - <p> - Add a provider url {errors} - </p> + {!providerLabel ? ( + <p>Add a provider url</p> + ) : ( + <p>Add a provider url for a {providerLabel} service</p> + )} <div class="container"> <TextInput label="Provider URL" placeholder="https://provider.com" grabFocus - bind={[providerURL, setProviderURL]} /> - </div> - {!!error && <p class="block has-text-danger">{error}</p>} - {error === "" && <p class="block has-text-success">This provider worked!</p>} - <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> - <button class="button" onClick={testProvider}>TEST</button> + error={errors} + bind={[providerURL, setProviderURL]} + /> </div> - <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> - <button class="button" onClick={cancel}>Cancel</button> + <p class="block">Example: https://kudos.demo.anastasis.lu</p> + {testing && <p class="has-text-info">Testing</p>} + + <div + class="block" + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> <span data-tooltip={errors}> - <button class="button is-info" disabled={errors !== undefined} onClick={addProvider}>Add</button> + <button + class="button is-info" + disabled={error !== "" || testing} + onClick={() => addProvider(url!)} + > + Add + </button> </span> </div> + + {authProviders.length > 0 ? ( + !providerLabel ? ( + <p class="subtitle">Current providers</p> + ) : ( + <p class="subtitle"> + Current providers for {providerLabel} service + </p> + ) + ) : !providerLabel ? ( + <p class="subtitle">No known providers, add one.</p> + ) : ( + <p class="subtitle">No known providers for {providerLabel} service</p> + )} + + {authProviders.map((k) => { + const p = allAuthProviders[k] as AuthenticationProviderStatusOk; + return <TableRow url={k} info={p} onDelete={deleteProvider} />; + })} </div> </AnastasisClientFrame> ); } +function TableRow({ + url, + info, + onDelete, +}: { + onDelete: (s: string) => void; + url: string; + info: AuthenticationProviderStatusOk; +}) { + 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> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx index 549686616..d48e94403 100644 --- a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,76 +15,83 @@ */ /** -* -* @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 "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { AttributeEntryScreen as TestedComponent } from "./AttributeEntryScreen"; export default { - title: 'Pages/AttributeEntryScreen', + title: "Pages/PersonalInformation", component: TestedComponent, args: { - order: 4, + order: 3, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; export const Backup = createExample(TestedComponent, { ...reducerStatesExample.backupAttributeEditing, - required_attributes: [{ - name: 'first name', - label: 'first', - type: 'string', - uuid: 'asdasdsa1', - widget: 'wid', - }, { - name: 'last name', - label: 'second', - type: 'string', - uuid: 'asdasdsa2', - widget: 'wid', - }, { - name: 'birthdate', - label: 'birthdate', - type: 'date', - uuid: 'asdasdsa3', - widget: 'calendar', - }] + required_attributes: [ + { + name: "first name", + label: "first", + type: "string", + uuid: "asdasdsa1", + widget: "wid", + }, + { + name: "last name", + label: "second", + type: "string", + uuid: "asdasdsa2", + widget: "wid", + }, + { + name: "birthdate", + label: "birthdate", + type: "date", + uuid: "asdasdsa3", + widget: "calendar", + }, + ], } as ReducerState); export const Recovery = createExample(TestedComponent, { ...reducerStatesExample.recoveryAttributeEditing, - required_attributes: [{ - name: 'first', - label: 'first', - type: 'string', - uuid: 'asdasdsa1', - widget: 'wid', - }, { - name: 'pepe', - label: 'second', - type: 'string', - uuid: 'asdasdsa2', - widget: 'wid', - }, { - name: 'pepe2', - label: 'third', - type: 'date', - uuid: 'asdasdsa3', - widget: 'calendar', - }] + required_attributes: [ + { + name: "first", + label: "first", + type: "string", + uuid: "asdasdsa1", + widget: "wid", + }, + { + name: "pepe", + label: "second", + type: "string", + uuid: "asdasdsa2", + widget: "wid", + }, + { + name: "pepe2", + label: "third", + type: "date", + uuid: "asdasdsa3", + widget: "calendar", + }, + ], } as ReducerState); export const WithNoRequiredAttribute = createExample(TestedComponent, { ...reducerStatesExample.backupAttributeEditing, - required_attributes: undefined + required_attributes: undefined, } as ReducerState); const allWidgets = [ @@ -108,23 +114,22 @@ const allWidgets = [ "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" + 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 = createExample(TestedComponent, { ...reducerStatesExample.backupAttributeEditing, - required_attributes: allWidgets.map(w => ({ + required_attributes: allWidgets.map((w) => ({ name: w, label: `widget: ${w}`, type: typeForWidget(w), uuid: `uuid-${w}`, - widget: w - })) + widget: w, + })), } as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx index f86994c97..1b50779e0 100644 --- a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx @@ -1,33 +1,42 @@ -/* eslint-disable @typescript-eslint/camelcase */ import { UserAttributeSpec, validators } from "anastasis-core"; -import { Fragment, h, VNode } from "preact"; +import { isAfter, parse } from "date-fns"; +import { h, VNode } from "preact"; import { useState } from "preact/hooks"; +import { DateInput } from "../../components/fields/DateInput"; +import { PhoneNumberInput } from "../../components/fields/NumberInput"; +import { TextInput } from "../../components/fields/TextInput"; import { useAnastasisContext } from "../../context/anastasis"; +import { ConfirmModal } from "./ConfirmModal"; import { AnastasisClientFrame, withProcessLabel } from "./index"; -import { TextInput } from "../../components/fields/TextInput"; -import { DateInput } from "../../components/fields/DateInput"; -import { NumberInput } from "../../components/fields/NumberInput"; -import { isAfter, parse } from "date-fns"; 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 [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 || [] + 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) - hasErrors = hasErrors || error !== undefined + const value = attrs[spec.name]; + const error = checkIfValid(value, spec); + hasErrors = hasErrors || error !== undefined; return ( <AttributeEntryField key={i} @@ -35,23 +44,42 @@ export function AttributeEntryScreen(): VNode { setValue={(v: string) => setAttrs({ ...attrs, [spec.name]: v })} spec={spec} errorMessage={error} - value={value} /> + onConfirm={() => { + if (!hasErrors) { + setAskUserIfSure(true) + } + }} + value={value} + /> ); - }) + }); return ( <AnastasisClientFrame title={withProcessLabel(reducer, "Who are you?")} hideNext={hasErrors ? "Complete the form." : undefined} - onNext={() => reducer.transition("enter_user_attributes", { - identity_attributes: attrs, - })} + onNext={async () => setAskUserIfSure(true) } > - <div class="columns" style={{ maxWidth: 'unset' }}> - <div class="column is-half"> - {fieldList} - </div> - <div class="column is-is-half" > + {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={() => reducer.transition("enter_user_attributes", { + identity_attributes: attrs, + }).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. + </ConfirmModal> + ) : null} + + <div class="columns" style={{ maxWidth: "unset" }}> + <div class="column">{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> @@ -62,9 +90,12 @@ export function AttributeEntryScreen(): VNode { </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> + <li> + <span class="icon is-right"> + <i class="mdi mdi-circle-small" /> + </span> + The non-hashed version is not shared + </li> </ul> </div> </div> @@ -78,39 +109,43 @@ interface AttributeEntryFieldProps { setValue: (newValue: string) => void; spec: UserAttributeSpec; errorMessage: string | undefined; + onConfirm: () => void; } -const possibleBirthdayYear: Array<number> = [] +const possibleBirthdayYear: Array<number> = []; for (let i = 0; i < 100; i++) { - possibleBirthdayYear.push(2020 - i) + possibleBirthdayYear.push(2020 - i); } function AttributeEntryField(props: AttributeEntryFieldProps): VNode { - return ( <div> - {props.spec.type === 'date' && + {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' && - <NumberInput + <PhoneNumberInput grabFocus={props.isFirst} label={props.spec.label} + onConfirm={props.onConfirm} error={props.errorMessage} bind={[props.value, props.setValue]} /> } - {props.spec.type === 'string' && + {props.spec.type === "string" && ( <TextInput grabFocus={props.isFirst} label={props.spec.label} + onConfirm={props.onConfirm} error={props.errorMessage} bind={[props.value, props.setValue]} /> - } + )} <div class="block"> This stays private <span class="icon is-right"> @@ -120,40 +155,43 @@ function AttributeEntryField(props: AttributeEntryFieldProps): VNode { </div> ); } -const YEAR_REGEX = /^[0-9]+-[0-9]+-[0-9]+$/ - +const YEAR_REGEX = /^[0-9]+-[0-9]+-[0-9]+$/; -function checkIfValid(value: string, spec: UserAttributeSpec): string | undefined { - const pattern = spec['validation-regex'] +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 re = new RegExp(pattern); + if (!re.test(value)) return "The value is invalid"; } - const logic = spec['validation-logic'] + 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' + if (func && typeof func === "function" && !func(value)) + return "Please check the value"; } - const optional = spec.optional + const optional = spec.optional; if (!optional && !value) { - return 'This value is required' + return "This value is required"; } if ("date" === spec.type) { if (!YEAR_REGEX.test(value)) { - return "The date doesn't follow the format" + return "The date doesn't follow the format"; } try { - const v = parse(value, 'yyyy-MM-dd', new Date()); + 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" + 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" + return "A birthdate cannot be in the future"; } } catch (e) { - return "Could not parse the date" + return "Could not parse the date"; } } - return undefined + return undefined; } diff --git a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx index 5077c3eb0..8acf1c8c8 100644 --- a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,78 +15,84 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { AuthenticationEditorScreen as TestedComponent } from './AuthenticationEditorScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { AuthenticationEditorScreen as TestedComponent } from "./AuthenticationEditorScreen"; export default { - title: 'Pages/backup/AuthenticationEditorScreen', + title: "Pages/backup/AuthorizationMethod", component: TestedComponent, args: { - order: 5, + 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 = createExample( + TestedComponent, + reducerStatesExample.authEditing, +); export const OneAuthMethodConfigured = createExample(TestedComponent, { ...reducerStatesExample.authEditing, - authentication_methods: [{ - type: 'question', - instructions: 'what time is it?', - challenge: 'asd', - }] + authentication_methods: [ + { + type: "question", + instructions: "what time is it?", + challenge: "asd", + }, + ], } as ReducerState); - export const SomeMoreAuthMethodConfigured = 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', - }] + 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 = createExample(TestedComponent, { ...reducerStatesExample.authEditing, authentication_providers: {}, - authentication_methods: [] + authentication_methods: [], } as ReducerState); - - // type: string; - // instructions: string; - // challenge: string; - // mime_type?: string; diff --git a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx index 93ca81194..91195971d 100644 --- a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx @@ -1,61 +1,85 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { AuthMethod } from "anastasis-core"; +import { AuthMethod, ReducerStateBackup } from "anastasis-core"; import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { TextInput } from "../../components/fields/TextInput"; import { useAnastasisContext } from "../../context/anastasis"; -import { authMethods, KnownAuthMethods } from "./authMethod"; +import { AddingProviderScreen } from "./AddingProviderScreen"; +import { + authMethods, + AuthMethodSetupProps, + AuthMethodWithRemove, + isKnownAuthMethods, + KnownAuthMethods, +} from "./authMethod"; +import { ConfirmModal } from "./ConfirmModal"; import { AnastasisClientFrame } from "./index"; - - -const getKeys = Object.keys as <T extends object>(obj: T) => Array<keyof T> +const getKeys = Object.keys as <T extends object>(obj: T) => Array<keyof T>; export function AuthenticationEditorScreen(): VNode { - const [noProvidersAck, setNoProvidersAck] = useState(false) - const [selectedMethod, setSelectedMethod] = useState<KnownAuthMethods | undefined>(undefined); - const [addingProvider, setAddingProvider] = 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.currentReducerState.backup_state === undefined + ) { + return <div>invalid state</div>; } - const configuredAuthMethods: AuthMethod[] = reducer.currentReducerState.authentication_methods ?? []; - const haveMethodsConfigured = configuredAuthMethods.length > 0; + const configuredAuthMethods: AuthMethod[] = + reducer.currentReducerState.authentication_methods ?? []; function removeByIndex(index: number): void { - if (reducer) reducer.transition("delete_authentication", { - authentication_method: index, - }) + if (reducer) + reducer.transition("delete_authentication", { + authentication_method: index, + }); } - const camByType: { [s: string]: AuthMethodWithRemove[] } = {} + 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) + 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 ("http_status" in p && !("error_code" in p) && p.methods) { for (const meth of p.methods) { authAvailableSet.add(meth.type); } } } + if (manageProvider !== undefined) { + return ( + <AddingProviderScreen + onCancel={() => setManageProvider(undefined)} + providerType={ + isKnownAuthMethods(manageProvider) ? manageProvider : undefined + } + /> + ); + } + if (selectedMethod) { const cancel = (): void => setSelectedMethod(undefined); const addMethod = (args: any): void => { @@ -63,120 +87,154 @@ export function AuthenticationEditorScreen(): VNode { setSelectedMethod(undefined); }; - const AuthSetup = authMethods[selectedMethod].screen ?? AuthMethodNotImplemented; - return (<Fragment> - <AuthSetup - cancel={cancel} - configured={camByType[selectedMethod] || []} - addAuthMethod={addMethod} - method={selectedMethod} /> - - {!authAvailableSet.has(selectedMethod) && <ConfirmModal active - onCancel={cancel} description="No providers founds" label="Add a provider manually" - onConfirm={() => { - null - }} - > - We have found no trusted cloud providers for your recovery secret. You can add a provider manually. - To add a provider you must know the provider URL (e.g. https://provider.com) - <p> - <a>More about cloud providers</a> - </p> - </ConfirmModal>} - - </Fragment> + const AuthSetup = + authMethods[selectedMethod].setup ?? AuthMethodNotImplemented; + return ( + <Fragment> + <AuthSetup + cancel={cancel} + configured={camByType[selectedMethod] || []} + addAuthMethod={addMethod} + method={selectedMethod} + /> + + {!authAvailableSet.has(selectedMethod) && ( + <ConfirmModal + active + onCancel={cancel} + description="No providers founds" + 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> ); } - if (addingProvider !== undefined) { - return <div /> - } - function MethodButton(props: { method: KnownAuthMethods }): VNode { - if (authMethods[props.method].skip) return <div /> - + if (authMethods[props.method].skip) return <div />; + return ( <div class="block"> <button - style={{ justifyContent: 'space-between' }} + 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 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" > + {!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> - } + )} + {camByType[props.method] && ( + <span class="tag is-info">{camByType[props.method].length}</span> + )} </button> </div> ); } - const errors = !haveMethodsConfigured ? "There is not enough authentication methods." : undefined; + 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" hideNext={errors}> + <AnastasisClientFrame + title="Backup: Configure Authentication Methods" + hideNext={errors} + onNext={handleNext} + > <div class="columns"> - <div class="column is-half"> + <div class="column"> <div> - {getKeys(authMethods).map(method => <MethodButton key={method} method={method} />)} + {getKeys(authMethods).map((method) => ( + <MethodButton key={method} method={method} /> + ))} </div> - {authAvailableSet.size === 0 && <ConfirmModal active={!noProvidersAck} - onCancel={() => setNoProvidersAck(true)} description="No providers founds" label="Add a provider manually" - onConfirm={() => { - null - }} - > - We have found no trusted cloud providers for your recovery secret. You can add a provider manually. - To add a provider you must know the provider URL (e.g. https://provider.com) - <p> - <a>More about cloud providers</a> - </p> - </ConfirmModal>} + {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 founds" + 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 is-half"> + <div class="column"> <p class="block"> - When recovering your wallet, 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. + 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">Manage the backup provider's list</button> + <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>} + {authAvailableSet.size > 0 && ( + <p class="block"> + We couldn't find provider for some of the authentication methods. + </p> + )} </div> </div> </AnastasisClientFrame> ); } -type AuthMethodWithRemove = AuthMethod & { remove: () => void } -export interface AuthMethodSetupProps { - method: string; - addAuthMethod: (x: any) => void; - configured: AuthMethodWithRemove[]; - cancel: () => void; -} - function AuthMethodNotImplemented(props: AuthMethodSetupProps): VNode { return ( <AnastasisClientFrame hideNav title={`Add ${props.method} authentication`}> @@ -186,36 +244,3 @@ function AuthMethodNotImplemented(props: AuthMethodSetupProps): VNode { ); } - -function ConfirmModal({ active, description, onCancel, onConfirm, children, danger, disabled, label = 'Confirm' }: Props): 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} >Dismiss</button> - <div class="buttons is-right" style={{ width: '100%' }}> - <button class={danger ? "button is-danger " : "button is-info "} disabled={disabled} onClick={onConfirm} >{label}</button> - </div> - </footer> - </div> - <button class="modal-close is-large " aria-label="close" onClick={onCancel} /> - </div> -} - -interface Props { - active?: boolean; - description?: string; - onCancel?: () => void; - onConfirm?: () => void; - label?: string; - children?: ComponentChildren; - danger?: boolean; - disabled?: boolean; -} diff --git a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx index b71a79727..c3ff7e746 100644 --- a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,48 +15,51 @@ */ /** -* -* @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 "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { BackupFinishedScreen as TestedComponent } from "./BackupFinishedScreen"; export default { - title: 'Pages/backup/FinishedScreen', + title: "Pages/backup/Finished", component: TestedComponent, args: { - order: 9, + order: 8, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const WithoutName = createExample(TestedComponent, reducerStatesExample.backupFinished); +export const WithoutName = createExample( + TestedComponent, + reducerStatesExample.backupFinished, +); -export const WithName = createExample(TestedComponent, {...reducerStatesExample.backupFinished, - secret_name: 'super_secret', +export const WithName = createExample(TestedComponent, { + ...reducerStatesExample.backupFinished, + secret_name: "super_secret", } as ReducerState); export const WithDetails = 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_ms: "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_ms: new Date().getTime() + 60 * 60 * 24 * 1000, }, - 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 7938baca4..129f1e9e4 100644 --- a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx @@ -1,44 +1,50 @@ +import { AuthenticationProviderStatusOk } from "anastasis-core"; import { format } from "date-fns"; import { h, VNode } from "preact"; import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; 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.currentReducerState.backup_state === undefined + ) { + return <div>invalid state</div>; } - const details = reducer.currentReducerState.success_details + const details = reducer.currentReducerState.success_details; + const providers = reducer.currentReducerState.authentication_providers ?? {} - return (<AnastasisClientFrame hideNav title="Backup finished"> - {reducer.currentReducerState.secret_name ? <p> - Your backup of secret <b>"{reducer.currentReducerState.secret_name}"</b> was - successful. - </p> : - <p> - Your secret was successfully backed up. - </p>} + return ( + <AnastasisClientFrame hideNav title="Backup success!"> + <p>Your backup is complete.</p> - {details && <div class="block"> - <p>The backup is stored by the following providers:</p> - {Object.keys(details).map((x, i) => { - const sd = details[x]; - return ( - <div key={i} class="box"> - {x} - <p> - version {sd.policy_version} - {sd.policy_expiration.t_ms !== 'never' ? ` expires at: ${format(sd.policy_expiration.t_ms, 'dd-MM-yyyy')}` : ' without expiration date'} - </p> - </div> - ); - })} - </div>} - <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> - <button class="button" onClick={() => reducer.back()}>Back</button> - </div> - </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_ms !== "never" + ? ` expires at: ${format( + new Date(sd.policy_expiration.t_ms), + "dd-MM-yyyy", + )}` + : " without expiration date"} + </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 48115c798..56aee8763 100644 --- a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -20,12 +19,16 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { RecoveryStates, ReducerState } from "anastasis-core"; +import { + ChallengeFeedbackStatus, + RecoveryStates, + ReducerState, +} from "anastasis-core"; import { createExample, reducerStatesExample } from "../../utils"; import { ChallengeOverviewScreen as TestedComponent } from "./ChallengeOverviewScreen"; export default { - title: "Pages/recovery/ChallengeOverviewScreen", + title: "Pages/recovery/SolveChallenge/Overview", component: TestedComponent, args: { order: 5, @@ -176,16 +179,15 @@ export const OnePolicyWithAllTheChallengesInDifferentState = createExample( recovery_information: { policies: [ [ - { uuid: "1" }, - { uuid: "2" }, - { uuid: "3" }, - { uuid: "4" }, - { uuid: "5" }, - { uuid: "6" }, - { uuid: "7" }, - { uuid: "8" }, - { uuid: "9" }, - { uuid: "10" }, + { 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" }, ], ], challenges: [ @@ -193,20 +195,96 @@ export const OnePolicyWithAllTheChallengesInDifferentState = createExample( cost: "USD:1", instructions: 'in state "solved"', type: "question", - uuid: "1", + uuid: "uuid-1", }, { cost: "USD:1", instructions: 'in state "message"', type: "question", - uuid: "2", + uuid: "uuid-2", + }, + { + cost: "USD:1", + instructions: 'in state "auth iban"', + type: "question", + uuid: "uuid-3", + }, + { + cost: "USD:1", + instructions: 'in state "payment "', + type: "question", + uuid: "uuid-4", + }, + { + cost: "USD:1", + instructions: 'in state "rate limit"', + type: "question", + uuid: "uuid-5", + }, + { + cost: "USD:1", + instructions: 'in state "redirect"', + type: "question", + uuid: "uuid-6", + }, + { + cost: "USD:1", + instructions: 'in state "server failure"', + type: "question", + uuid: "uuid-7", + }, + { + cost: "USD:1", + instructions: 'in state "truth unknown"', + type: "question", + uuid: "uuid-8", + }, + { + cost: "USD:1", + instructions: 'in state "unsupported"', + type: "question", + uuid: "uuid-9", }, ], }, challenge_feedback: { - 1: { state: "solved" }, - 2: { state: "message", message: "Security question was not solved correctly" }, - // FIXME: add missing feedback states here! + "uuid-1": { state: ChallengeFeedbackStatus.Solved.toString() }, + "uuid-2": { + state: ChallengeFeedbackStatus.Message.toString(), + message: "Challenge should be solved", + }, + "uuid-3": { + state: ChallengeFeedbackStatus.AuthIban.toString(), + challenge_amount: "EUR:1", + credit_iban: "DE12345789000", + business_name: "Data Loss Incorporated", + wire_transfer_subject: "Anastasis 987654321", + }, + "uuid-4": { + state: ChallengeFeedbackStatus.Payment.toString(), + taler_pay_uri: "taler://pay/...", + provider: "https://localhost:8080/", + payment_secret: "3P4561HAMHRRYEYD6CM6J7TS5VTD5SR2K2EXJDZEFSX92XKHR4KG", + }, + "uuid-5": { + state: ChallengeFeedbackStatus.RateLimitExceeded.toString(), + // "error_code": 8121 + }, + "uuid-6": { + state: ChallengeFeedbackStatus.Redirect.toString(), + redirect_url: "https://videoconf.example.com/", + http_status: 303, + }, + "uuid-7": { + state: ChallengeFeedbackStatus.ServerFailure.toString(), + http_status: 500, + error_response: "some error message or error object", + }, + "uuid-8": { + state: ChallengeFeedbackStatus.TruthUnknown.toString(), + // "error_code": 8108 + }, + "uuid-9": { state: ChallengeFeedbackStatus.Unsupported.toString() }, }, } as ReducerState, ); diff --git a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx index ed34bbde2..d0c9b2f5d 100644 --- a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx @@ -3,6 +3,7 @@ import { h, VNode } from "preact"; import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; import { authMethods, KnownAuthMethods } from "./authMethod"; +import { AsyncButton } from "../../components/AsyncButton"; function OverviewFeedbackDisplay(props: { feedback?: ChallengeFeedback }) { const { feedback } = props; @@ -11,28 +12,37 @@ function OverviewFeedbackDisplay(props: { feedback?: ChallengeFeedback }) { } switch (feedback.state) { case ChallengeFeedbackStatus.Message: - return ( - <div> - <p>{feedback.message}</p> - </div> - ); + return <div class="block has-text-danger">{feedback.message}</div>; + case ChallengeFeedbackStatus.Solved: + return <div />; case ChallengeFeedbackStatus.Pending: case ChallengeFeedbackStatus.AuthIban: return null; + case ChallengeFeedbackStatus.ServerFailure: + return <div class="block has-text-danger">Server error.</div>; case ChallengeFeedbackStatus.RateLimitExceeded: - return <div>Rate limit exceeded.</div>; - case ChallengeFeedbackStatus.Redirect: - return <div>Redirect (FIXME: not supported)</div>; + return ( + <div class="block has-text-danger"> + There were to many failed attempts. + </div> + ); case ChallengeFeedbackStatus.Unsupported: - return <div>Challenge not supported by client.</div>; + 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>Truth unknown</div>; - default: return ( - <div> - <pre>{JSON.stringify(feedback)}</pre> + <div class="block has-text-danger"> + Provider doesn't recognize the challenge of the policy. Contact the + provider for further information. </div> ); + case ChallengeFeedbackStatus.Redirect: + default: + return <div />; } } @@ -72,19 +82,25 @@ export function ChallengeOverviewScreen(): VNode { 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); + 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 }; - }); + return { + isPolicySolved, + challenges, + corrupted: row.length > challenges.length, + }; + }) + .filter((p) => !p.corrupted); const atLeastThereIsOnePolicySolved = policiesWithInfo.find((p) => p.isPolicySolved) !== undefined; @@ -94,25 +110,124 @@ export function ChallengeOverviewScreen(): VNode { : undefined; return ( <AnastasisClientFrame hideNext={errors} title="Recovery: Solve challenges"> - {!policies.length ? ( + {!policiesWithInfo.length ? ( <p class="block"> No policies found, try with another version of the secret </p> - ) : policies.length === 1 ? ( + ) : 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 {policies.length} polices. You need to solve all the - challenges from one policy in order to recover your secret. + 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 isFree = !info.cost || info.cost.endsWith(":0"); 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> + ); + } + + 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> + <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.AuthIban: + case ChallengeFeedbackStatus.Payment: + return ( + <div> + <AsyncButton + class="button" + disabled={ + atLeastThereIsOnePolicySolved && !policy.isPolicySolved + } + onClick={selectChallenge} + > + Pay + </AsyncButton> + </div> + ); + case ChallengeFeedbackStatus.Redirect: + return ( + <div> + <AsyncButton + class="button" + disabled={ + atLeastThereIsOnePolicySolved && !policy.isPolicySolved + } + onClick={selectChallenge} + > + Go to {feedback.redirect_url} + </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 + </AsyncButton> + </div> + ); + } + } return ( <div key={uuid} @@ -131,21 +246,8 @@ export function ChallengeOverviewScreen(): VNode { </div> <OverviewFeedbackDisplay feedback={info.feedback} /> </div> - <div> - {method && info.feedback?.state !== "solved" ? ( - <a - class="button" - onClick={() => - reducer.transition("select_challenge", { uuid }) - } - > - {isFree ? "Solve" : `Pay and Solve`} - </a> - ) : null} - {info.feedback?.state === "solved" ? ( - <a class="button is-success"> Solved </a> - ) : null} - </div> + + <ChallengeButton id={uuid} feedback={info.feedback} /> </div> ); }); @@ -153,11 +255,13 @@ export function ChallengeOverviewScreen(): VNode { const policyName = policy.challenges .map((x) => x.info.type) .join(" + "); + const opa = !atLeastThereIsOnePolicySolved ? undefined : policy.isPolicySolved ? undefined : "0.6"; + return ( <div key={policy_index} diff --git a/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx index e5fe09e99..8c788e556 100644 --- a/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx @@ -15,24 +15,26 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../utils'; -import { ChallengePayingScreen as TestedComponent } from './ChallengePayingScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../utils"; +import { ChallengePayingScreen as TestedComponent } from "./ChallengePayingScreen"; export default { - title: 'Pages/recovery/__ChallengePayingScreen', + title: "Pages/recovery/__ChallengePaying", component: TestedComponent, args: { order: 10, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const Example = createExample(TestedComponent, reducerStatesExample.challengePaying); +export const Example = createExample( + TestedComponent, + reducerStatesExample.challengePaying, +); diff --git a/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx index 84896a2ec..ffcc8fafc 100644 --- a/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx @@ -3,19 +3,19 @@ import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; export function ChallengePayingScreen(): 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.currentReducerState.recovery_state === undefined + ) { + return <div>invalid state</div>; } - const payments = ['']; //reducer.currentReducerState.payments ?? + const payments = [""]; //reducer.currentReducerState.payments ?? return ( - <AnastasisClientFrame - hideNav - title="Recovery: Challenge Paying" - > + <AnastasisClientFrame hideNav title="Recovery: Challenge Paying"> <p> Some of the providers require a payment to store the encrypted authentication information. 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..c9c59c1b4 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/ConfirmModal.tsx @@ -0,0 +1,58 @@ +import { differenceInBusinessDays } from "date-fns"; +import { ComponentChildren, h, VNode } from "preact"; +import { useLayoutEffect, useRef } from "preact/hooks"; +import { AsyncButton } from "../../components/AsyncButton"; + +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 6bdb3515d..0948d603e 100644 --- a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx @@ -16,37 +16,42 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { ContinentSelectionScreen as TestedComponent } from './ContinentSelectionScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { ContinentSelectionScreen as TestedComponent } from "./ContinentSelectionScreen"; export default { - title: 'Pages/Location', + title: "Pages/Location", component: TestedComponent, args: { order: 2, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const BackupSelectContinent = createExample(TestedComponent, reducerStatesExample.backupSelectContinent); +export const BackupSelectContinent = createExample( + TestedComponent, + reducerStatesExample.backupSelectContinent, +); export const BackupSelectCountry = createExample(TestedComponent, { ...reducerStatesExample.backupSelectContinent, - selected_continent: 'Testcontinent', + selected_continent: "Testcontinent", } as ReducerState); -export const RecoverySelectContinent = createExample(TestedComponent, reducerStatesExample.recoverySelectContinent); +export const RecoverySelectContinent = createExample( + TestedComponent, + reducerStatesExample.recoverySelectContinent, +); export const RecoverySelectCountry = createExample(TestedComponent, { ...reducerStatesExample.recoverySelectContinent, - selected_continent: 'Testcontinent', + 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 0e43f982d..aafde6e8c 100644 --- a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx @@ -1,58 +1,81 @@ /* eslint-disable @typescript-eslint/camelcase */ +import { BackupStates, RecoveryStates } from "anastasis-core"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame, withProcessLabel } from "./index"; export function ContinentSelectionScreen(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); - //FIXME: remove this when #7056 is fixed - const countryFromReducer = (reducer?.currentReducerState as any).selected_country || "" - const [countryCode, setCountryCode] = useState( countryFromReducer ) + // 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 /> + if ( + !reducer || + !reducer.currentReducerState || + !("continents" in reducer.currentReducerState) + ) { + return <div />; } const selectContinent = (continent: string): void => { - reducer.transition("select_continent", { continent }) + reducer.transition("select_continent", { continent }); }; const selectCountry = (country: string): void => { - setCountryCode(country) + setCountryCode(country); }; - - + const continentList = reducer.currentReducerState.continents || []; const countryList = reducer.currentReducerState.countries || []; - const theContinent = reducer.currentReducerState.selected_continent || "" + const theContinent = reducer.currentReducerState.selected_continent || ""; // const cc = reducer.currentReducerState.selected_country || ""; - const theCountry = countryList.find(c => c.code === countryCode) - const selectCountryAction = () => { + const theCountry = countryList.find((c) => c.code === countryCode); + const selectCountryAction = async () => { //selection should be when the select box changes it value if (!theCountry) return; reducer.transition("select_country", { country_code: countryCode, currencies: [theCountry.currency], - }) - } + }); + }; // const step1 = reducer.currentReducerState.backup_state === BackupStates.ContinentSelecting || // reducer.currentReducerState.recovery_state === RecoveryStates.ContinentSelecting; - const errors = !theCountry ? "Select a country" : undefined + const errors = !theCountry ? "Select a country" : undefined; - return ( - <AnastasisClientFrame hideNext={errors} title={withProcessLabel(reducer, "Where do you live?")} onNext={selectCountryAction}> + 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(); + }; - <div class="columns" > + return ( + <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 => ( + <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> @@ -68,10 +91,17 @@ export function ContinentSelectionScreen(): VNode { <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 => ( + <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> @@ -93,12 +123,37 @@ export function ContinentSelectionScreen(): VNode { </div> <div class="column is-two-third"> <p> - Your location will help us to determine which personal information - ask you for the next step. + 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 recomment 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> + {/* + <p> + Because of the diversity of personally identifying information in + different countries and cultures, we do not support all countries + yet. If you want to improve the supported countries,{" "} + <a href="mailto:contact@anastasis.lu">contact us</a>. + </p> */} + </div> </div> </div> - </AnastasisClientFrame> ); } diff --git a/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx index fc339e48e..4cbeb8308 100644 --- a/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx @@ -16,94 +16,126 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { EditPoliciesScreen as TestedComponent } from './EditPoliciesScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { EditPoliciesScreen as TestedComponent } from "./EditPoliciesScreen"; export default { - title: 'Pages/backup/ReviewPoliciesScreen/EditPoliciesScreen', + title: "Pages/backup/ReviewPolicies/EditPolicies", args: { order: 6, }, component: TestedComponent, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const EditingAPolicy = createExample(TestedComponent, { - ...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, { index : 0}); - -export const CreatingAPolicy = createExample(TestedComponent, { - ...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, { index : 3}); +export const EditingAPolicy = createExample( + TestedComponent, + { + ...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, + { index: 0 }, +); +export const CreatingAPolicy = createExample( + TestedComponent, + { + ...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, + { index: 3 }, +); diff --git a/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx index 85cc96c46..198209399 100644 --- a/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx @@ -20,7 +20,6 @@ interface Props { index: number; cancel: () => void; confirm: (changes: MethodProvider[]) => void; - } export interface MethodProvider { @@ -28,106 +27,151 @@ export interface MethodProvider { provider: string; } -export function EditPoliciesScreen({ index: policy_index, cancel, confirm }: Props): VNode { - const [changedProvider, setChangedProvider] = useState<Array<string>>([]) +export function EditPoliciesScreen({ + index: policy_index, + cancel, + confirm, +}: Props): VNode { + const [changedProvider, setChangedProvider] = useState<Array<string>>([]); - 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.currentReducerState.backup_state === undefined + ) { + return <div>invalid state</div>; } - const selectableProviders: ProviderInfoByType = {} - const allProviders = Object.entries(reducer.currentReducerState.authentication_providers || {}) + const selectableProviders: ProviderInfoByType = {}; + const allProviders = Object.entries( + reducer.currentReducerState.authentication_providers || {}, + ); for (let index = 0; index < allProviders.length; index++) { - const [url, status] = allProviders[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 - }) + 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 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 + 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[] = [] + const newMethods: MethodProvider[] = []; allAuthMethods.forEach((method, index) => { - const oldValue = policy?.methods.find(m => m.authentication_method === index) + const oldValue = policy?.methods.find( + (m) => m.authentication_method === index, + ); if (changedProvider[index] === undefined && oldValue !== undefined) { - newMethods.push(oldValue) + newMethods.push(oldValue); } - if (changedProvider[index] !== undefined && changedProvider[index] !== "") { + if ( + changedProvider[index] !== undefined && + changedProvider[index] !== "" + ) { newMethods.push({ authentication_method: index, - provider: changedProvider[index] - }) + provider: changedProvider[index], + }); } - }) - confirm(newMethods) + }); + 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]; + 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} + 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> - ))} - </select> + {selectableProviders[type]?.map((prov) => ( + <option key={prov.url} value={prov.url}> + {prov.url} + </option> + ))} + </select> + </span> </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> + </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 e952ab28d..9bebcfbc9 100644 --- a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,35 +15,40 @@ */ /** -* -* @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 "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { PoliciesPayingScreen as TestedComponent } from "./PoliciesPayingScreen"; export default { - title: 'Pages/backup/PoliciesPayingScreen', + title: "Pages/backup/__PoliciesPaying", component: TestedComponent, args: { - order: 8, + order: 9, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const Example = createExample(TestedComponent, reducerStatesExample.policyPay); +export const Example = createExample( + TestedComponent, + reducerStatesExample.policyPay, +); export const WithSomePaymentRequest = 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 a470f5155..c3568b32d 100644 --- a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx @@ -3,20 +3,23 @@ import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; 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.currentReducerState.backup_state === undefined + ) { + return <div>invalid state</div>; } const payments = reducer.currentReducerState.policy_payment_requests ?? []; - + return ( <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 0d2ebb778..1c05cd6e1 100644 --- a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,30 +15,41 @@ */ /** -* -* @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 { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; +import { ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { RecoveryFinishedScreen as TestedComponent } from "./RecoveryFinishedScreen"; export default { - title: 'Pages/recovery/FinishedScreen', + title: "Pages/recovery/Finished", args: { order: 7, }, component: TestedComponent, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; export const GoodEnding = 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 = 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 a61ef9efa..d83482559 100644 --- a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx @@ -1,39 +1,80 @@ -import { - bytesToString, - decodeCrock -} from "@gnu-taler/taler-util"; +import { bytesToString, decodeCrock, encodeCrock } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { stringToBytes } from "qrcode-generator"; +import { QR } from "../../components/QR"; import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; 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.currentReducerState.recovery_state === undefined + ) { + return <div>invalid state</div>; } - const encodedSecret = reducer.currentReducerState.core_secret + const secretName = reducer.currentReducerState.recovery_document?.secret_name; + const encodedSecret = reducer.currentReducerState.core_secret; if (!encodedSecret) { - 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> + 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.value)) + const secret = bytesToString(decodeCrock(encodedSecret.value)); + const contentURI = `data:${encodedSecret.mime},${secret}`; + // const fileName = encodedSecret['filename'] + // data:plain/text;base64,asdasd return ( - <AnastasisClientFrame title="Recovery Finished" hideNav> - <p> - Secret: {secret} - </p> - <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> - <button class="button" onClick={() => reducer.back()}>Back</button> + <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}> + <button + class="button" + onClick={() => { + navigator.clipboard.writeText(secret); + setCopied(true); + }} + > + {!copied ? "Copy" : "Copied"} + </button> + <a class="button is-info" download="secret.txt" href={contentURI}> + <div class="icon is-small "> + <i class="mdi mdi-download" /> + </div> + <span>Save as</span> + </a> + </div> + <div class="block"> + <QR text={secret} /> </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 9f7e26c16..4a1cba6a8 100644 --- a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,44 +15,51 @@ */ /** -* -* @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 "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { ReviewPoliciesScreen as TestedComponent } from "./ReviewPoliciesScreen"; export default { - title: 'Pages/backup/ReviewPoliciesScreen', + title: "Pages/backup/ReviewPolicies", 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: [] + 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, { @@ -63,186 +69,193 @@ export const SomePoliciesWithMethods = createExample(TestedComponent, { methods: [ { authentication_method: 0, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 1, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 2, - provider: "https://kudos.demo.anastasis.lu/" - } - ] + provider: "https://kudos.demo.anastasis.lu/", + }, + ], }, { methods: [ { authentication_method: 0, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 1, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 3, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 0, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 1, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 4, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 0, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 2, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 3, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 0, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 2, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 4, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 0, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 3, - provider: "https://anastasis.demo.taler.net/" + provider: "https://anastasis.demo.taler.net/", }, { authentication_method: 4, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 1, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 2, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 3, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 1, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 2, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 4, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 1, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 3, - provider: "https://anastasis.demo.taler.net/" + provider: "https://anastasis.demo.taler.net/", }, { authentication_method: 4, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 2, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 3, - provider: "https://anastasis.demo.taler.net/" + provider: "https://anastasis.demo.taler.net/", }, { authentication_method: 4, - provider: "https://anastasis.demo.taler.net/" - } - ] - } + 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", + }, ], - 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 f93963f67..0ed08e037 100644 --- a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx @@ -1,24 +1,33 @@ -/* eslint-disable @typescript-eslint/camelcase */ +import { AuthenticationProviderStatusOk } from "anastasis-core"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; +import { AsyncButton } from "../../components/AsyncButton"; import { useAnastasisContext } from "../../context/anastasis"; import { authMethods, KnownAuthMethods } from "./authMethod"; +import { ConfirmModal } from "./ConfirmModal"; import { EditPoliciesScreen } from "./EditPoliciesScreen"; import { AnastasisClientFrame } from "./index"; export function ReviewPoliciesScreen(): VNode { - const [editingPolicy, setEditingPolicy] = useState<number | undefined>() - const reducer = useAnastasisContext() + const [editingPolicy, setEditingPolicy] = useState<number | undefined>(); + const [confirmReset, setConfirmReset] = useState(false); + 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.currentReducerState.backup_state === undefined + ) { + return <div>invalid state</div>; } - const configuredAuthMethods = 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 @@ -29,62 +38,145 @@ export function ReviewPoliciesScreen(): VNode { policy_index: editingPolicy, policy: newMethods, }); - setEditingPolicy(undefined) + setEditingPolicy(undefined); }} /> - ) + ); + } + async function resetPolicies(): Promise<void> { + if (!reducer) return Promise.resolve(); + return reducer.runTransaction(async (tx) => { + await tx.transition("back", {}); + await tx.transition("next", {}); + setConfirmReset(false); + }); } - const errors = policies.length < 1 ? 'Need more policies' : undefined + const errors = policies.length < 1 ? "Need more policies" : undefined; return ( - <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" style={{ justifyContent: 'flex-end' }} > - <button class="button is-success" onClick={() => setEditingPolicy(policies.length + 1)}>Add new policy</button> + <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"> + <AsyncButton class="button" onClick={async () => setConfirmReset(true)}> + Reset policies + </AsyncButton> + <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 => configuredAuthMethods[x.authentication_method] && ({ ...configuredAuthMethods[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="box" style={{ display: 'flex', justifyContent: 'space-between' }}> + <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.length && <p>No auth method found</p>} {methods.map((m, i) => { + const p = providers[ + m.provider + ] as AuthenticationProviderStatusOk; return ( - <p key={i} class="block" style={{ display: 'flex', alignItems: 'center' }}> + <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}>{m.provider}</a> + {m.instructions} recovery provided by{" "} + <a href={m.provider} target="_blank" rel="noreferrer"> + {p.business_name} + </a> </span> </p> ); })} </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 class="button is-danger block" onClick={() => reducer.transition("delete_policy", { policy_index })}>Delete</button> + <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 + class="button is-danger block" + onClick={() => + reducer.transition("delete_policy", { policy_index }) + } + > + Delete + </button> </div> </div> ); })} + {confirmReset && ( + <ConfirmModal + active + onCancel={() => setConfirmReset(false)} + description="Do you want to reset the policies to default state?" + label="Reset policies" + cancelLabel="Cancel" + onConfirm={resetPolicies} + > + <p> + All policies will be recalculated based on the authentication + providers configured and any change that you did will be lost + </p> + </ConfirmModal> + )} </AnastasisClientFrame> ); } diff --git a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx index 49dd8fca8..3f2c6a245 100644 --- a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,30 +15,29 @@ */ /** -* -* @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 "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { SecretEditorScreen as TestedComponent } from "./SecretEditorScreen"; export default { - title: 'Pages/backup/SecretEditorScreen', + title: "Pages/backup/SecretInput", 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', + secret_name: "someSecretName", } as ReducerState); export const WithoutName = createExample(TestedComponent, { diff --git a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx index 1b36a1b21..6d4ffbf88 100644 --- a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx @@ -1,41 +1,56 @@ -/* eslint-disable @typescript-eslint/camelcase */ 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 -} from "./index"; +import { AnastasisClientFrame } from "./index"; import { TextInput } from "../../components/fields/TextInput"; -import { FileInput } from "../../components/fields/FileInput"; +import { FileInput, FileTypeContent } from "../../components/fields/FileInput"; 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.currentReducerState.backup_state === undefined + ) { + return <div>invalid state</div>; } const secretNext = async (): Promise<void> => { + const secret = secretFile + ? { + value: encodeCrock(stringToBytes(secretValue)), + 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, }, @@ -43,31 +58,46 @@ export function SecretEditorScreen(): VNode { 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 + hideNext={errors} title="Backup: Provide secret to backup" onNext={() => secretNext()} > - <div> + <div class="block"> <TextInput - label="Secret's name:" + label="Secret name:" + tooltip="The secret name allows you to identify your secret when restoring it. It is a label that you can choose freely." grabFocus + onConfirm={goNextIfNoErrors} bind={[secretName, setSecretName]} /> </div> - <div> + <div class="block"> <TextInput + disabled={!!secretFile} + onConfirm={goNextIfNoErrors} label="Enter the secret as text:" bind={[secretValue, setSecretValue]} /> - <div style={{display:'flex',}}> - or - <FileInput - label="click here" - bind={[secretValue, setSecretValue]} - /> - to import a file - </div> + </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 6919eebad..01ce3f0a7 100644 --- a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx @@ -15,37 +15,35 @@ */ /** -* -* @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 "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { SecretSelectionScreen as TestedComponent } from "./SecretSelectionScreen"; export default { - title: 'Pages/recovery/SecretSelectionScreen', + title: "Pages/recovery/SecretSelection", component: TestedComponent, args: { order: 4, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; export const Example = createExample(TestedComponent, { ...reducerStatesExample.secretSelection, recovery_document: { - provider_url: 'https://kudos.demo.anastasis.lu/', - secret_name: 'secretName', + provider_url: "https://kudos.demo.anastasis.lu/", + secret_name: "secretName", version: 1, }, } as ReducerState); - export const NoRecoveryDocumentFound = createExample(TestedComponent, { ...reducerStatesExample.secretSelection, recovery_document: undefined, diff --git a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx index 8aa5ed2f7..7e517abfe 100644 --- a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx @@ -1,23 +1,31 @@ +import { AuthenticationProviderStatus, AuthenticationProviderStatusOk } from "anastasis-core"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { AsyncButton } from "../../components/AsyncButton"; -import { NumberInput } from "../../components/fields/NumberInput"; +import { PhoneNumberInput } from "../../components/fields/NumberInput"; import { useAnastasisContext } from "../../context/anastasis"; +import { AddingProviderScreen } from "./AddingProviderScreen"; import { AnastasisClientFrame } from "./index"; export function SecretSelectionScreen(): VNode { const [selectingVersion, setSelectingVersion] = useState<boolean>(false); - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); - const currentVersion = (reducer?.currentReducerState - && ("recovery_document" in reducer.currentReducerState) - && reducer.currentReducerState.recovery_document?.version) || 0; + const [manageProvider, setManageProvider] = useState(false); + const currentVersion = + (reducer?.currentReducerState && + "recovery_document" in reducer.currentReducerState && + reducer.currentReducerState.recovery_document?.version) || + 0; 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.recovery_state === undefined + ) { + return <div>invalid state</div>; } async function doSelectVersion(p: string, n: number): Promise<void> { @@ -31,66 +39,108 @@ export function SecretSelectionScreen(): VNode { }); } - const providerList = Object.keys(reducer.currentReducerState.authentication_providers ?? {}) - const recoveryDocument = reducer.currentReducerState.recovery_document + const provs = reducer.currentReducerState.authentication_providers ?? {}; + const recoveryDocument = reducer.currentReducerState.recovery_document; if (!recoveryDocument) { - return <ChooseAnotherProviderScreen - providers={providerList} selected="" - onChange={(newProv) => doSelectVersion(newProv, 0)} - /> + return ( + <ChooseAnotherProviderScreen + providers={provs} + selected="" + onChange={(newProv) => doSelectVersion(newProv, 0)} + /> + ); } if (selectingVersion) { - return <SelectOtherVersionProviderScreen providers={providerList} - provider={recoveryDocument.provider_url} version={recoveryDocument.version} - onCancel={() => setSelectingVersion(false)} - onConfirm={doSelectVersion} - /> + return ( + <SelectOtherVersionProviderScreen + providers={provs} + provider={recoveryDocument.provider_url} + version={recoveryDocument.version} + onCancel={() => setSelectingVersion(false)} + onConfirm={doSelectVersion} + /> + ); } + if (manageProvider) { + return <AddingProviderScreen onCancel={() => setManageProvider(false)} />; + } + + const provierInfo = 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">{recoveryDocument.provider_url}</h1> + <div class="box" style={{ border: "2px solid green" }}> + <h1 class="subtitle"> + {provierInfo.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>} + {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> + <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> + 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({ providers, selected, onChange }: { selected: string; providers: string[]; onChange: (prov: string) => void }): VNode { +function ChooseAnotherProviderScreen({ + providers, + selected, + onChange, +}: { + selected: string; + providers: { [url: string]: AuthenticationProviderStatus }; + onChange: (prov: string) => void; +}): VNode { return ( - <AnastasisClientFrame hideNext="Recovery document not found" title="Recovery: Problem"> + <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={selected}> - <option key="none" disabled selected value=""> Choose a provider </option> - {providers.map(prov => ( - <option key={prov} value={prov}> - {prov} + <select + onChange={(e) => onChange(e.currentTarget.value)} + value={selected} + > + <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" /> @@ -102,22 +152,37 @@ function ChooseAnotherProviderScreen({ providers, selected, onChange }: { select ); } -function SelectOtherVersionProviderScreen({ providers, provider, version, onConfirm, onCancel }: { onCancel: () => void; provider: string; version: number; providers: string[]; onConfirm: (prov: string, v: number) => Promise<void>; }): VNode { +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}`); + 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 {otherProvider}</h1> + <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>} + {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> @@ -125,13 +190,21 @@ function SelectOtherVersionProviderScreen({ providers, provider, version, onConf <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> - {providers.map(prov => ( - <option key={prov} value={prov}> - {prov} + <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" /> @@ -140,27 +213,43 @@ function SelectOtherVersionProviderScreen({ providers, provider, version, onConf </div> </div> <div class="container"> - <NumberInput + <PhoneNumberInput label="Version" placeholder="version number to recover" grabFocus - bind={[otherVersion, setOtherVersion]} /> + bind={[otherVersion, setOtherVersion]} + /> </div> </div> - <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> - <button class="button" onClick={onCancel}>Cancel</button> + <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> + <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> - <div class="column"> - . - </div> </div> - </AnastasisClientFrame> ); - } diff --git a/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx index cb6561b3f..76d0700db 100644 --- a/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,109 +15,63 @@ */ /** -* -* @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 { + ChallengeFeedbackStatus, + RecoveryStates, + ReducerState, +} from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { SolveScreen as TestedComponent } from "./SolveScreen"; export default { - title: 'Pages/recovery/SolveScreen', + title: "Pages/recovery/SolveChallenge/Solve", 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 NoInformation = createExample( + TestedComponent, + reducerStatesExample.challengeSolving, +); export const NotSupportedChallenge = createExample(TestedComponent, { ...reducerStatesExample.challengeSolving, recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'does P equals NP?', - type: 'chall-type', - uuid: 'ASDASDSAD!1' - }], + challenges: [ + { + cost: "USD:1", + 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 MismatchedChallengeId = createExample(TestedComponent, { ...reducerStatesExample.challengeSolving, recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'does P equals NP?', - 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: 'SMS to 555-5555', - 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: 'does P equals NP?', - type: 'question', - uuid: 'ASDASDSAD!1' - }], - policies: [], - }, - selected_challenge_uuid: 'ASDASDSAD!1' -} as ReducerState); - -export const EmailChallenge = createExample(TestedComponent, { - ...reducerStatesExample.challengeSolving, - recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'Email to sebasjm@some-domain.com', - type: 'email', - uuid: 'ASDASDSAD!1' - }], - policies: [], - }, - selected_challenge_uuid: 'ASDASDSAD!1' -} as ReducerState); - -export const PostChallenge = createExample(TestedComponent, { - ...reducerStatesExample.challengeSolving, - recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'Letter to address in postal code ABC123', - type: 'post', - uuid: 'ASDASDSAD!1' - }], + challenges: [ + { + cost: "USD:1", + 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 bc1a88db3..b87dad2ce 100644 --- a/packages/anastasis-webui/src/pages/home/SolveScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/SolveScreen.tsx @@ -1,50 +1,132 @@ -import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; +import { h, VNode } from "preact"; import { AnastasisClientFrame } from "."; import { ChallengeFeedback, ChallengeFeedbackStatus, - ChallengeInfo, } from "../../../../anastasis-core/lib"; -import { AsyncButton } from "../../components/AsyncButton"; -import { TextInput } from "../../components/fields/TextInput"; +import { Notifications } from "../../components/Notifications"; import { useAnastasisContext } from "../../context/anastasis"; +import { authMethods, KnownAuthMethods } from "./authMethod"; -function SolveOverviewFeedbackDisplay(props: { feedback?: ChallengeFeedback }) { +export function SolveOverviewFeedbackDisplay(props: { + feedback?: ChallengeFeedback; +}): VNode { const { feedback } = props; if (!feedback) { - return null; + return <div />; } switch (feedback.state) { case ChallengeFeedbackStatus.Message: return ( - <div> - <p>{feedback.message}</p> - </div> + <Notifications + notifications={[ + { + type: "INFO", + message: `Message from provider`, + description: feedback.message, + }, + ]} + /> + ); + case ChallengeFeedbackStatus.Payment: + return ( + <Notifications + notifications={[ + { + type: "INFO", + message: `Message from provider`, + description: ( + <span> + To pay you can <a href={feedback.taler_pay_uri}>click here</a> + </span> + ), + }, + ]} + /> ); - case ChallengeFeedbackStatus.Pending: case ChallengeFeedbackStatus.AuthIban: - return null; + return ( + <Notifications + notifications={[ + { + type: "INFO", + message: `Message from provider`, + description: `Need to send a wire transfer to "${feedback.business_name}"`, + }, + ]} + /> + ); + case ChallengeFeedbackStatus.ServerFailure: + return ( + <Notifications + notifications={[ + { + type: "ERROR", + message: `Server error: Code ${feedback.http_status}`, + description: feedback.error_response, + }, + ]} + /> + ); case ChallengeFeedbackStatus.RateLimitExceeded: - return <div>Rate limit exceeded.</div>; + return ( + <Notifications + notifications={[ + { + type: "ERROR", + message: `Message from provider`, + description: "There were to many failed attempts.", + }, + ]} + /> + ); case ChallengeFeedbackStatus.Redirect: - return <div>Redirect (FIXME: not supported)</div>; + return ( + <Notifications + notifications={[ + { + type: "INFO", + message: `Message from provider`, + description: ( + <span> + Please visit this link: <a>{feedback.redirect_url}</a> + </span> + ), + }, + ]} + /> + ); case ChallengeFeedbackStatus.Unsupported: - return <div>Challenge not supported by client.</div>; + 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 <div>Truth unknown</div>; - default: return ( - <div> - <pre>{JSON.stringify(feedback)}</pre> - </div> + <Notifications + notifications={[ + { + type: "ERROR", + message: `Provider doesn't recognize the type of challenge`, + description: "Contact the provider for further information", + }, + ]} + /> ); + default: + return <div />; } } export function SolveScreen(): VNode { const reducer = useAnastasisContext(); - const [answer, setAnswer] = useState(""); if (!reducer) { return ( @@ -78,161 +160,54 @@ export function SolveScreen(): VNode { 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> + <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 = - selectedChallenge === undefined - ? SolveUndefinedEntry - : dialogMap[selectedChallenge.type] ?? SolveUnsupportedEntry; - - async function onNext(): Promise<void> { - return reducer?.transition("solve_challenge", { answer }); - } - function onCancel(): void { - reducer?.back(); - } + const selectedChallenge = chArr.find((ch) => ch.uuid === selectedUuid); - return ( - <AnastasisClientFrame hideNav title="Recovery: Solve challenge"> - <SolveOverviewFeedbackDisplay - feedback={challengeFeedback[selectedUuid]} - /> - <SolveDialog - id={selectedUuid} - answer={answer} - setAnswer={setAnswer} - challenge={selectedChallenge} - feedback={challengeFeedback[selectedUuid]} - /> - - <div - style={{ - marginTop: "2em", - display: "flex", - justifyContent: "space-between", - }} - > - <button class="button" onClick={onCancel}> - Cancel - </button> - <AsyncButton class="button is-info" onClick={onNext}> - Confirm - </AsyncButton> - </div> - </AnastasisClientFrame> - ); -} - -export interface SolveEntryProps { - id: string; - challenge: ChallengeInfo; - feedback?: ChallengeFeedback; - answer: string; - setAnswer: (s: string) => void; -} - -function SolveSmsEntry({ - challenge, - answer, - setAnswer, -}: SolveEntryProps): VNode { - return ( - <Fragment> - <p> - An sms has been sent to "<b>{challenge.instructions}</b>". Type the code - below - </p> - <TextInput label="Answer" grabFocus bind={[answer, setAnswer]} /> - </Fragment> - ); -} -function SolveQuestionEntry({ - challenge, - answer, - setAnswer, -}: SolveEntryProps): VNode { - return ( - <Fragment> - <p>Type the answer to the following question:</p> - <pre>{challenge.instructions}</pre> - <TextInput label="Answer" grabFocus bind={[answer, setAnswer]} /> - </Fragment> - ); -} - -function SolvePostEntry({ - challenge, - answer, - setAnswer, -}: SolveEntryProps): VNode { - return ( - <Fragment> - <p> - instruction for post type challenge "<b>{challenge.instructions}</b>" - </p> - <TextInput label="Answer" grabFocus bind={[answer, setAnswer]} /> - </Fragment> - ); -} - -function SolveEmailEntry({ - challenge, - answer, - setAnswer, -}: SolveEntryProps): VNode { - return ( - <Fragment> - <p> - An email has been sent to "<b>{challenge.instructions}</b>". Type the - code below - </p> - <TextInput label="Answer" grabFocus bind={[answer, setAnswer]} /> - </Fragment> - ); -} + const SolveDialog = + !selectedChallenge || + !authMethods[selectedChallenge.type as KnownAuthMethods] + ? SolveNotImplemented + : authMethods[selectedChallenge.type as KnownAuthMethods].solve ?? + SolveNotImplemented; -function SolveUnsupportedEntry(props: SolveEntryProps): VNode { - return ( - <Fragment> - <p> - The challenge selected is not supported for this UI. Please update this - version or try using another policy. - </p> - <p> - <b>Challenge type:</b> {props.challenge.type} - </p> - </Fragment> - ); -} -function SolveUndefinedEntry(props: SolveEntryProps): VNode { - return ( - <Fragment> - <p> - There is no challenge information for id <b>"{props.id}"</b>. Try - resetting the recovery session. - </p> - </Fragment> - ); + return <SolveDialog id={selectedUuid} />; } diff --git a/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx index 657a2dd74..fcddaf87a 100644 --- a/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx @@ -15,24 +15,26 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../utils'; -import { StartScreen as TestedComponent } from './StartScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../utils"; +import { StartScreen as TestedComponent } from "./StartScreen"; export default { - title: 'Pages/StartScreen', + title: "Pages/Start", 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 = 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 d53df4cae..8b24ef684 100644 --- a/packages/anastasis-webui/src/pages/home/StartScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/StartScreen.tsx @@ -1,27 +1,36 @@ - import { h, VNode } from "preact"; import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; 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 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> + <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()}> - <div class="icon"><i class="mdi mdi-arrow-down" /></div> + <button + class="button is-info" + onClick={() => reducer.startRecover()} + > + <div class="icon"> + <i class="mdi mdi-arrow-down" /> + </div> <span>Recover a secret</span> </button> @@ -30,7 +39,6 @@ export function StartScreen(): VNode { <span>Restore a session</span> </button> */} </div> - </div> <div class="column" /> </div> diff --git a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx index 7568ccd69..245ad8889 100644 --- a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx @@ -15,29 +15,31 @@ */ /** -* -* @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 "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { TruthsPayingScreen as TestedComponent } from "./TruthsPayingScreen"; export default { - title: 'Pages/backup/__TruthsPayingScreen', + title: "Pages/backup/__TruthsPaying", 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 Example = createExample( + TestedComponent, + reducerStatesExample.truthsPaying, +); export const WithPaytoList = 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 0b32e0db5..6f95fa93b 100644 --- a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx @@ -3,19 +3,19 @@ import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; 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.currentReducerState.backup_state === undefined + ) { + return <div>invalid state</div>; } const payments = reducer.currentReducerState.payments ?? []; return ( - <AnastasisClientFrame - hideNext={"FIXME"} - title="Backup: Truths Paying" - > + <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 index e178a4955..080a7ab31 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,51 +15,67 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../../utils'; -import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; export default { - title: 'Pages/backup/authMethods/email', + title: "Pages/backup/AuthorizationMethod/AuthMethods/email", component: TestedComponent, args: { order: 5, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -const type: KnownAuthMethods = 'email' +const type: KnownAuthMethods = "email"; -export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [] -}); +export const Empty = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [], + }, +); -export const WithOneExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [{ - challenge: 'qwe', - type, - instructions: 'Email to sebasjm@email.com ', - remove: () => null - }] -}); +export const WithOneExample = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Email to sebasjm@email.com ", + remove: () => null, + }, + ], + }, +); -export const WithMoreExamples = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [{ - challenge: 'qwe', - type, - instructions: 'Email to sebasjm@email.com', - remove: () => null - },{ - challenge: 'qwe', - type, - instructions: 'Email to someone@sebasjm.com', - remove: () => null - }] -}); +export const WithMoreExamples = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Email to sebasjm@email.com", + remove: () => null, + }, + { + challenge: "qwe", + type, + instructions: "Email to someone@sebasjm.com", + remove: () => null, + }, + ], + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx index 1a6be1b61..556e3bdbf 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx @@ -1,59 +1,94 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; -import { AnastasisClientFrame } from "../index"; -import { TextInput } from "../../../components/fields/TextInput"; import { EmailInput } from "../../../components/fields/EmailInput"; +import { AnastasisClientFrame } from "../index"; +import { AuthMethodSetupProps } from "./index"; -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,}))$/ +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 { +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 + 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. + email. Add the uuid from the challenge </p> <div> <EmailInput label="Email address" error={emailError} + onConfirm={goNextIfNoErrors} placeholder="email@domain.com" - bind={[email, setEmail]} /> + 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>} + {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> + <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> + <button + class="button is-info" + disabled={errors !== undefined} + onClick={addEmailAuth} + > + Add + </button> </span> </div> </div> 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..729fa8a1b --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.stories.tsx @@ -0,0 +1,90 @@ +/* + 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 { ChallengeFeedbackStatus, ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; + +export default { + title: "Pages/recovery/SolveChallenge/AuthMethods/email", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "email"; + +export const WithoutFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "Email to me@domain.com", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, + { + id: "uuid-1", + }, +); + +export const PaymentFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "Email to me@domain.com", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + challenge_feedback: { + "uuid-1": { + state: ChallengeFeedbackStatus.Payment, + taler_pay_uri: "taler://pay/...", + provider: "https://localhost:8080/", + payment_secret: "3P4561HAMHRRYEYD6CM6J7TS5VTD5SR2K2EXJDZEFSX92XKHR4KG", + }, + }, + } as ReducerState, + { + id: "uuid-1", + }, +); 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..e50c3bb20 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.tsx @@ -0,0 +1,148 @@ +import { ChallengeFeedbackStatus, ChallengeInfo } from "anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton"; +import { TextInput } from "../../../components/fields/TextInput"; +import { useAnastasisContext } from "../../../context/anastasis"; +import { AnastasisClientFrame } from "../index"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen"; +import { AuthMethodSolveProps } from "./index"; + +export function AuthMethodEmailSolve({ id }: AuthMethodSolveProps): VNode { + const [answer, setAnswer] = useState("A-"); + 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.currentReducerState.recovery_state === undefined + ) { + 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(); + } + + const shouldHideConfirm = + feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded || + feedback?.state === ChallengeFeedbackStatus.Redirect || + feedback?.state === ChallengeFeedbackStatus.Unsupported || + feedback?.state === ChallengeFeedbackStatus.TruthUnknown; + + 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]} + placeholder="A-1234567812345678" + /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm && ( + <AsyncButton class="button is-info" onClick={onNext}> + 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 index 71f618646..c521e18fd 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,50 +15,66 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../../utils'; -import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; export default { - title: 'Pages/backup/authMethods/IBAN', + title: "Pages/backup/AuthorizationMethod/AuthMethods/IBAN", component: TestedComponent, args: { order: 5, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -const type: KnownAuthMethods = 'iban' +const type: KnownAuthMethods = "iban"; -export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [] -}); +export const Empty = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [], + }, +); -export const WithOneExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [{ - challenge: 'qwe', - type, - instructions: 'Wire transfer from QWEASD123123 with holder Sebastian', - remove: () => null - }] -}); -export const WithMoreExamples = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - 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 - }] -},); +export const WithOneExample = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Wire transfer from QWEASD123123 with holder Sebastian", + remove: () => null, + }, + ], + }, +); +export const WithMoreExamples = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + 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, + }, + ], + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx index c9edbfa07..501a40600 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx @@ -1,65 +1,111 @@ -/* eslint-disable @typescript-eslint/camelcase */ import { canonicalJson, encodeCrock, - stringToBytes + stringToBytes, } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; +import { AuthMethodSetupProps } from "."; import { TextInput } from "../../../components/fields/TextInput"; -import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; import { AnastasisClientFrame } from "../index"; -export function AuthMethodIbanSetup({ addAuthMethod, cancel, configured }: AuthMethodSetupProps): VNode { +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 - ) + 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. + 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" - bind={[name, setName]} /> + onConfirm={goNextIfNoErrors} + bind={[name, setName]} + /> <TextInput label="IBAN" placeholder="DE91100000000123456789" - bind={[account, setAccount]} /> + 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>} + {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> + <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> + <button + class="button is-info" + disabled={errors !== undefined} + onClick={addIbanAuth} + > + Add + </button> </span> </div> </div> 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..cbbc253e9 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.stories.tsx @@ -0,0 +1,60 @@ +/* + 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 { ChallengeFeedbackStatus, ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; + +export default { + title: "Pages/recovery/SolveChallenge/AuthMethods/Iban", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "iban"; + +export const WithoutFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, + { + id: "uuid-1", + }, +); 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..5cff7bf01 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.tsx @@ -0,0 +1,112 @@ +import { ChallengeFeedbackStatus, ChallengeInfo } from "anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton"; +import { TextInput } from "../../../components/fields/TextInput"; +import { useAnastasisContext } from "../../../context/anastasis"; +import { AnastasisClientFrame } from "../index"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen"; +import { AuthMethodSolveProps } from "./index"; + +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.currentReducerState.recovery_state === undefined + ) { + 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(); + } + + const shouldHideConfirm = + feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded || + feedback?.state === ChallengeFeedbackStatus.Redirect || + feedback?.state === ChallengeFeedbackStatus.Unsupported || + feedback?.state === ChallengeFeedbackStatus.TruthUnknown; + + 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 && ( + <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 index 0f1c17495..2977586ac 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx @@ -16,51 +16,67 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../../utils'; -import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; export default { - title: 'Pages/backup/authMethods/Post', + title: "Pages/backup/AuthorizationMethod/AuthMethods/Post", component: TestedComponent, args: { order: 5, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -const type: KnownAuthMethods = 'post' +const type: KnownAuthMethods = "post"; -export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [] -}); +export const Empty = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [], + }, +); -export const WithOneExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [{ - challenge: 'qwe', - type, - instructions: 'Letter to address in postal code QWE456', - remove: () => null - }] -}); +export const WithOneExample = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Letter to address in postal code QWE456", + remove: () => null, + }, + ], + }, +); -export const WithMoreExamples = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - 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 - }] -}); +export const WithMoreExamples = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + 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, + }, + ], + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx index bfeaaa832..04e00500c 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx @@ -1,15 +1,19 @@ -/* eslint-disable @typescript-eslint/camelcase */ import { - canonicalJson, encodeCrock, - stringToBytes + canonicalJson, + encodeCrock, + stringToBytes, } from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; +import { h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; -import { TextInput } from "../../../components/fields/TextInput"; import { AnastasisClientFrame } from ".."; +import { TextInput } from "../../../components/fields/TextInput"; +import { AuthMethodSetupProps } from "./index"; -export function AuthMethodPostSetup({ addAuthMethod, cancel, configured }: AuthMethodSetupProps): VNode { +export function AuthMethodPostSetup({ + addAuthMethod, + cancel, + configured, +}: AuthMethodSetupProps): VNode { const [fullName, setFullName] = useState(""); const [street, setStreet] = useState(""); const [city, setCity] = useState(""); @@ -33,68 +37,108 @@ export function AuthMethodPostSetup({ addAuthMethod, cancel, configured }: AuthM }); }; - 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 - ) - ) - ) - ) + 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. + 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 - label="City" bind={[city, setCity]} + onConfirm={goNextIfNoErrors} + label="City" + bind={[city, setCity]} /> </div> <div> <TextInput - label="Postal Code" bind={[postcode, setPostcode]} + 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> + {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> + <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..3b67ee884 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.stories.tsx @@ -0,0 +1,60 @@ +/* + 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 { ChallengeFeedbackStatus, ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; + +export default { + title: "Pages/recovery/SolveChallenge/AuthMethods/post", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "post"; + +export const WithoutFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, + { + id: "uuid-1", + }, +); 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..1bbbbfc03 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.tsx @@ -0,0 +1,117 @@ +import { ChallengeFeedbackStatus, ChallengeInfo } from "anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton"; +import { TextInput } from "../../../components/fields/TextInput"; +import { useAnastasisContext } from "../../../context/anastasis"; +import { AnastasisClientFrame } from "../index"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen"; +import { AuthMethodSolveProps } from "./index"; + +export function AuthMethodPostSolve({ id }: AuthMethodSolveProps): VNode { + const [answer, setAnswer] = useState("A-"); + + const reducer = useAnastasisContext(); + if (!reducer) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); + } + if ( + !reducer.currentReducerState || + reducer.currentReducerState.recovery_state === undefined + ) { + 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(); + } + + const shouldHideConfirm = + feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded || + feedback?.state === ChallengeFeedbackStatus.Redirect || + feedback?.state === ChallengeFeedbackStatus.Unsupported || + feedback?.state === ChallengeFeedbackStatus.TruthUnknown; + + return ( + <AnastasisClientFrame hideNav title="Postal Challenge"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p>Wait for the answer</p> + <TextInput + onConfirm={onNext} + label="Answer" + grabFocus + bind={[answer, setAnswer]} + /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm && ( + <AsyncButton class="button is-info" onClick={onNext}> + 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 index 3ba4a84ca..991301cbf 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx @@ -16,51 +16,69 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../../utils'; -import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; export default { - title: 'Pages/backup/authMethods/Question', + title: "Pages/backup/AuthorizationMethod/AuthMethods/Question", component: TestedComponent, args: { order: 5, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -const type: KnownAuthMethods = 'question' +const type: KnownAuthMethods = "question"; -export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [] -}); +export const Empty = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [], + }, +); -export const WithOneExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [{ - challenge: 'qwe', - type, - instructions: 'Is integer factorization polynomial? (non-quantum computer)', - remove: () => null - }] -}); +export const WithOneExample = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: + "Is integer factorization polynomial? (non-quantum computer)", + remove: () => null, + }, + ], + }, +); -export const WithMoreExamples = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [{ - challenge: 'qwe', - type, - instructions: 'Does P equal NP?', - remove: () => null - },{ - challenge: 'asd', - type, - instructions: 'Are continuous groups automatically differential groups?', - remove: () => null - }] -}); +export const WithMoreExamples = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Does P equal NP?", + remove: () => null, + }, + { + challenge: "asd", + type, + instructions: + "Are continuous groups automatically differential groups?", + remove: () => null, + }, + ], + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx index 04fa00d59..19260c4ff 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx @@ -1,33 +1,39 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; +import { AuthMethodSetupProps } from "./index"; import { AnastasisClientFrame } from "../index"; import { TextInput } from "../../../components/fields/TextInput"; -export function AuthMethodQuestionSetup({ cancel, addAuthMethod, configured }: AuthMethodSetupProps): VNode { +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 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 - ) + 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> - For2 security question authentication, you need to provide a question + 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. @@ -36,36 +42,67 @@ export function AuthMethodQuestionSetup({ cancel, addAuthMethod, configured }: A <TextInput label="Security question" grabFocus + onConfirm={goNextIfNoErrors} placeholder="Your question" - bind={[questionText, setQuestionText]} /> + 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> + <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> + <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>} + {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 > + </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..1fa9fd6ec --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.stories.tsx @@ -0,0 +1,258 @@ +/* + 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 { ChallengeFeedbackStatus, ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; + +export default { + title: "Pages/recovery/SolveChallenge/AuthMethods/question", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "question"; + +export const WithoutFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, + { + id: "uuid-1", + }, +); + +export const MessageFeedback = createExample(TestedComponent[type].solve, { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.Message, + message: "Challenge should be solved", + }, + }, +} as ReducerState); + +export const ServerFailureFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.ServerFailure, + http_status: 500, + error_response: "Couldn't connect to mysql", + }, + }, + } as ReducerState, +); + +export const RedirectFeedback = createExample(TestedComponent[type].solve, { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.Redirect, + http_status: 302, + redirect_url: "http://video.taler.net", + }, + }, +} as ReducerState); + +export const MessageRateLimitExceededFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.RateLimitExceeded, + }, + }, + } as ReducerState, +); + +export const UnsupportedFeedback = createExample(TestedComponent[type].solve, { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.Unsupported, + http_status: 500, + unsupported_method: "Question", + }, + }, +} as ReducerState); + +export const TruthUnknownFeedback = createExample(TestedComponent[type].solve, { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.TruthUnknown, + }, + }, +} as ReducerState); + +export const AuthIbanFeedback = createExample(TestedComponent[type].solve, { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.AuthIban, + challenge_amount: "EUR:1", + credit_iban: "DE12345789000", + business_name: "Data Loss Incorporated", + wire_transfer_subject: "Anastasis 987654321", + answer_code: 987654321, + // Fields that follow are only for compatibility with C reducer, + // will be removed eventually, + details: { + business_name: "foo", + challenge_amount: "foo", + credit_iban: "foo", + wire_transfer_subject: "foo", + }, + method: "iban", + }, + }, +} as ReducerState); + +export const PaymentFeedback = createExample(TestedComponent[type].solve, { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.Payment, + taler_pay_uri: "taler://pay/...", + provider: "https://localhost:8080/", + payment_secret: "3P4561HAMHRRYEYD6CM6J7TS5VTD5SR2K2EXJDZEFSX92XKHR4KG", + }, + }, +} 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..2636ca47c --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.tsx @@ -0,0 +1,121 @@ +import { ChallengeFeedbackStatus, ChallengeInfo } from "anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton"; +import { TextInput } from "../../../components/fields/TextInput"; +import { useAnastasisContext } from "../../../context/anastasis"; +import { AnastasisClientFrame } from "../index"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen"; +import { AuthMethodSolveProps } from "./index"; + +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.currentReducerState.recovery_state === undefined + ) { + 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(); + } + + const shouldHideConfirm = + feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded || + feedback?.state === ChallengeFeedbackStatus.Redirect || + feedback?.state === ChallengeFeedbackStatus.Unsupported || + feedback?.state === ChallengeFeedbackStatus.TruthUnknown; + + 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 && ( + <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 index ae8297ef7..3a44c7ad0 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx @@ -16,51 +16,67 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../../utils'; -import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; export default { - title: 'Pages/backup/authMethods/Sms', + title: "Pages/backup/AuthorizationMethod/AuthMethods/Sms", component: TestedComponent, args: { order: 5, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -const type: KnownAuthMethods = 'sms' +const type: KnownAuthMethods = "sms"; -export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [] -}); +export const Empty = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [], + }, +); -export const WithOneExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [{ - challenge: 'qwe', - type, - instructions: 'SMS to +11-1234-2345', - remove: () => null - }] -}); +export const WithOneExample = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "SMS to +11-1234-2345", + remove: () => null, + }, + ], + }, +); -export const WithMoreExamples = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [{ - challenge: 'qwe', - type, - instructions: 'SMS to +11-1234-2345', - remove: () => null - },{ - challenge: 'qwe', - type, - instructions: 'SMS to +11-5555-2345', - remove: () => null - }] -}); +export const WithMoreExamples = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "SMS to +11-1234-2345", + remove: () => null, + }, + { + challenge: "qwe", + type, + instructions: "SMS to +11-5555-2345", + remove: () => null, + }, + ], + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx index 9e85af2b2..e70b2a53b 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx @@ -1,15 +1,15 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; import { Fragment, h, VNode } from "preact"; import { useLayoutEffect, useRef, useState } from "preact/hooks"; -import { NumberInput } from "../../../components/fields/NumberInput"; -import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; +import { AuthMethodSetupProps } from "."; +import { PhoneNumberInput } from "../../../components/fields/NumberInput"; import { AnastasisClientFrame } from "../index"; -export function AuthMethodSmsSetup({ addAuthMethod, cancel, configured }: AuthMethodSetupProps): VNode { +export function AuthMethodSmsSetup({ + addAuthMethod, + cancel, + configured, +}: AuthMethodSetupProps): VNode { const [mobileNumber, setMobileNumber] = useState(""); const addSmsAuth = (): void => { addAuthMethod({ @@ -24,7 +24,10 @@ export function AuthMethodSmsSetup({ addAuthMethod, cancel, configured }: AuthMe useLayoutEffect(() => { inputRef.current?.focus(); }, []); - const errors = !mobileNumber ? 'Add a mobile number' : undefined + const errors = !mobileNumber ? "Add a mobile number" : undefined; + function goNextIfNoErrors(): void { + if (!errors) addSmsAuth(); + } return ( <AnastasisClientFrame hideNav title="Add SMS authentication"> <div> @@ -34,27 +37,57 @@ export function AuthMethodSmsSetup({ addAuthMethod, cancel, configured }: AuthMe receive via SMS. </p> <div class="container"> - <NumberInput + <PhoneNumberInput label="Mobile number" placeholder="Your mobile number" + onConfirm={goNextIfNoErrors} grabFocus - bind={[mobileNumber, setMobileNumber]} /> + bind={[mobileNumber, setMobileNumber]} + /> </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> + {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> + <button + class="button is-info" + disabled={errors !== undefined} + onClick={addSmsAuth} + > + Add + </button> </span> </div> </div> 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..e8961cccf --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.stories.tsx @@ -0,0 +1,60 @@ +/* + 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 { ChallengeFeedbackStatus, ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; + +export default { + title: "Pages/recovery/SolveChallenge/AuthMethods/sms", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "sms"; + +export const WithoutFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "SMS to +54 11 2233 4455", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, + { + id: "uuid-1", + }, +); 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..3370c76d0 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.tsx @@ -0,0 +1,148 @@ +import { ChallengeFeedbackStatus, ChallengeInfo } from "anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton"; +import { TextInput } from "../../../components/fields/TextInput"; +import { useAnastasisContext } from "../../../context/anastasis"; +import { AnastasisClientFrame } from "../index"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen"; +import { AuthMethodSolveProps } from "./index"; + +export function AuthMethodSmsSolve({ id }: AuthMethodSolveProps): VNode { + const [answer, setAnswer] = useState("A-"); + + 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.currentReducerState.recovery_state === undefined + ) { + 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(); + } + + const shouldHideConfirm = + feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded || + feedback?.state === ChallengeFeedbackStatus.Redirect || + feedback?.state === ChallengeFeedbackStatus.Unsupported || + feedback?.state === ChallengeFeedbackStatus.TruthUnknown; + + 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]} + placeholder="A-1234567812345678" + /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm && ( + <AsyncButton class="button is-info" onClick={onNext}> + 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 index 4e46b600e..bc4628828 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx @@ -16,49 +16,65 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../../utils'; -import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; export default { - title: 'Pages/backup/authMethods/TOTP', + title: "Pages/backup/AuthorizationMethod/AuthMethods/TOTP", component: TestedComponent, args: { order: 5, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -const type: KnownAuthMethods = 'totp' +const type: KnownAuthMethods = "totp"; -export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [] -}); -export const WithOneExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [{ - challenge: 'qwe', - type, - instructions: 'Enter 8 digits code for "Anastasis"', - remove: () => null - }] -}); -export const WithMoreExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - 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 - }] -}); +export const Empty = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [], + }, +); +export const WithOneExample = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: 'Enter 8 digits code for "Anastasis"', + remove: () => null, + }, + ], + }, +); +export const WithMoreExample = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + 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, + }, + ], + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx index fd0bd0224..6b0dd7a79 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx @@ -1,40 +1,46 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useMemo, useState } from "preact/hooks"; -import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; +import { AuthMethodSetupProps } from "./index"; import { AnastasisClientFrame } from "../index"; import { TextInput } from "../../../components/fields/TextInput"; import { QR } from "../../../components/QR"; import { base32enc, computeTOTPandCheck } from "./totp"; -export function AuthMethodTotpSetup({ addAuthMethod, cancel, configured }: AuthMethodSetupProps): VNode { +export function AuthMethodTotpSetup({ + addAuthMethod, + cancel, + configured, +}: AuthMethodSetupProps): VNode { const [name, setName] = useState("anastasis"); const [test, setTest] = useState(""); - const digits = 8 + const digits = 8; const secretKey = useMemo(() => { - const array = new Uint8Array(32) - return window.crypto.getRandomValues(array) - }, []) + const array = new Uint8Array(32); + return window.crypto.getRandomValues(array); + }, []); const secret32 = base32enc(secretKey); - const totpURL = `otpauth://totp/${name}?digits=${digits}&secret=${secret32}` + const totpURL = `otpauth://totp/${name}?digits=${digits}&secret=${secret32}`; - const addTotpAuth = (): void => addAuthMethod({ - authentication_method: { - type: "totp", - instructions: `Enter ${digits} digits code for "${name}"`, - challenge: encodeCrock(stringToBytes(totpURL)), - }, - }); + const addTotpAuth = (): void => + addAuthMethod({ + authentication_method: { + type: "totp", + instructions: `Enter ${digits} digits code for "${name}"`, + challenge: encodeCrock(stringToBytes(totpURL)), + }, + }); const testCodeMatches = computeTOTPandCheck(secretKey, 8, parseInt(test, 10)); - const errors = !name ? 'The TOTP name is missing' : ( - !testCodeMatches ? 'The test code doesnt match' : undefined - ); + const errors = !name + ? "The TOTP name is missing" + : !testCodeMatches + ? "The test code doesnt match" + : undefined; + function goNextIfNoErrors(): void { + if (!errors) addTotpAuth(); + } return ( <AnastasisClientFrame hideNav title="Add TOTP authentication"> <p> @@ -43,10 +49,7 @@ export function AuthMethodTotpSetup({ addAuthMethod, cancel, configured }: AuthM 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]} /> + <TextInput label="TOTP Name" grabFocus bind={[name, setName]} /> </div> <div style={{ height: 300 }}> <QR text={totpURL} /> @@ -56,23 +59,53 @@ export function AuthMethodTotpSetup({ addAuthMethod, cancel, configured }: AuthM </p> <TextInput label="Test code" - bind={[test, setTest]} /> - {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>} + onConfirm={goNextIfNoErrors} + bind={[test, setTest]} + /> + {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> + <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> + <button + class="button is-info" + disabled={errors !== undefined} + onClick={addTotpAuth} + > + Add + </button> </span> </div> </div> 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..8743c5a73 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.stories.tsx @@ -0,0 +1,60 @@ +/* + 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 { ChallengeFeedbackStatus, ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; + +export default { + title: "Pages/recovery/SolveChallenge/AuthMethods/totp", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "totp"; + +export const WithoutFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, + { + id: "uuid-1", + }, +); 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..347f9bf03 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.tsx @@ -0,0 +1,118 @@ +import { ChallengeFeedbackStatus, ChallengeInfo } from "anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton"; +import { TextInput } from "../../../components/fields/TextInput"; +import { useAnastasisContext } from "../../../context/anastasis"; +import { AnastasisClientFrame } from "../index"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen"; +import { AuthMethodSolveProps } from "./index"; + +export function AuthMethodTotpSolve({ 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.currentReducerState.recovery_state === undefined + ) { + 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(); + } + + const shouldHideConfirm = + feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded || + feedback?.state === ChallengeFeedbackStatus.Redirect || + feedback?.state === ChallengeFeedbackStatus.Unsupported || + feedback?.state === ChallengeFeedbackStatus.TruthUnknown; + + return ( + <AnastasisClientFrame hideNav title="TOTP Challenge"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p>enter the totp solution</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 && ( + <AsyncButton class="button is-info" onClick={onNext}> + Confirm + </AsyncButton> + )} + </div> + </AnastasisClientFrame> + ); +} +// NKE8 VD857T X033X6RG WEGPYP6D70 Q7YE XN8D2 ZN79SCN 231B4QK0 diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.stories.tsx index 3c4c7bf39..4aad0a097 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.stories.tsx @@ -16,51 +16,68 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createExample, reducerStatesExample } from '../../../utils'; -import { authMethods as TestedComponent, KnownAuthMethods } from './index'; -import logoImage from '../../../assets/logo.jpeg' +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; +import logoImage from "../../../assets/logo.jpeg"; export default { - title: 'Pages/backup/authMethods/Video', + title: "Pages/backup/AuthorizationMethod/AuthMethods/Video", component: TestedComponent, args: { order: 5, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -const type: KnownAuthMethods = 'video' +const type: KnownAuthMethods = "video"; -export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [] -}); +export const Empty = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [], + }, +); -export const WithOneExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [{ - challenge: 'qwe', - type, - instructions: logoImage, - remove: () => null - }] -}); +export const WithOneExample = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: logoImage, + remove: () => null, + }, + ], + }, +); -export const WithMoreExamples = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [{ - challenge: 'qwe', - type, - instructions: logoImage, - remove: () => null - },{ - challenge: 'qwe', - type, - instructions: logoImage, - remove: () => null - }] -}); +export const WithMoreExamples = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: logoImage, + remove: () => null, + }, + { + challenge: "qwe", + type, + instructions: logoImage, + remove: () => null, + }, + ], + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.tsx index 8be999b3f..04a129c4a 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.tsx @@ -1,54 +1,90 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { ImageInput } from "../../../components/fields/ImageInput"; -import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; +import { AuthMethodSetupProps } from "./index"; import { AnastasisClientFrame } from "../index"; -export function AuthMethodVideoSetup({cancel, addAuthMethod, configured}: AuthMethodSetupProps): VNode { +export function AuthMethodVideoSetup({ + cancel, + addAuthMethod, + configured, +}: AuthMethodSetupProps): VNode { const [image, setImage] = useState(""); const addVideoAuth = (): void => { addAuthMethod({ authentication_method: { type: "video", - instructions: 'Join a video call', + instructions: "Join a video call", challenge: encodeCrock(stringToBytes(image)), }, - }) + }); }; + function goNextIfNoErrors(): void { + addVideoAuth(); + } return ( <AnastasisClientFrame hideNav title="Add video authentication"> <p> - For video identification, you need to provide a passport-style - photograph. When recovering your secret, you will be asked to join a - video call. During that call, a human will use the photograph to - verify your identity. + For video identification, you need to provide a passport-style + photograph. When recovering your secret, you will be asked to join a + video call. During that call, a human will use the photograph to verify + your identity. </p> - <div style={{textAlign:'center'}}> + <div style={{ textAlign: "center" }}> <ImageInput label="Choose photograph" grabFocus - bind={[image, setImage]} /> + onConfirm={goNextIfNoErrors} + bind={[image, setImage]} + /> </div> - {configured.length > 0 && <section class="section"> + {configured.length > 0 && ( + <section class="section"> + <div class="block">Your photographs:</div> <div class="block"> - Your photographs: - </div><div class="block"> {configured.map((c, i) => { - return <div key={i} class="box" style={{ display: 'flex', justifyContent: 'space-between' }}> - <img style={{ marginTop: 'auto', marginBottom: 'auto', width: 100, height:100, border: 'solid 1px black' }} src={c.instructions} /> - <div style={{marginTop: 'auto', marginBottom: 'auto'}}><button class="button is-danger" onClick={c.remove}>Delete</button></div> - </div> + return ( + <div + key={i} + class="box" + style={{ display: "flex", justifyContent: "space-between" }} + > + <img + style={{ + marginTop: "auto", + marginBottom: "auto", + width: 100, + height: 100, + border: "solid 1px black", + }} + src={c.instructions} + /> + <div style={{ marginTop: "auto", marginBottom: "auto" }}> + <button class="button is-danger" onClick={c.remove}> + Delete + </button> + </div> + </div> + ); })} - </div></section>} + </div> + </section> + )} <div> - <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> - <button class="button" onClick={cancel}>Cancel</button> - <button class="button is-info" onClick={addVideoAuth}>Add</button> + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={cancel}> + Cancel + </button> + <button class="button is-info" onClick={addVideoAuth}> + Add + </button> </div> </div> </AnastasisClientFrame> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSolve.stories.tsx new file mode 100644 index 000000000..7c5511c5a --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSolve.stories.tsx @@ -0,0 +1,60 @@ +/* + 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 { ChallengeFeedbackStatus, ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; + +export default { + title: "Pages/recovery/SolveChallenge/AuthMethods/video", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "video"; + +export const WithoutFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, + { + id: "uuid-1", + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSolve.tsx new file mode 100644 index 000000000..efadb9a9a --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSolve.tsx @@ -0,0 +1,112 @@ +import { ChallengeFeedbackStatus, ChallengeInfo } from "anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton"; +import { TextInput } from "../../../components/fields/TextInput"; +import { useAnastasisContext } from "../../../context/anastasis"; +import { AnastasisClientFrame } from "../index"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen"; +import { AuthMethodSolveProps } from "./index"; + +export function AuthMethodVideoSolve({ 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.currentReducerState.recovery_state === undefined + ) { + 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(); + } + + const shouldHideConfirm = + feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded || + feedback?.state === ChallengeFeedbackStatus.Redirect || + feedback?.state === ChallengeFeedbackStatus.Unsupported || + feedback?.state === ChallengeFeedbackStatus.TruthUnknown; + + return ( + <AnastasisClientFrame hideNav title="Add email authentication"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p>You are gonna be called to check your identity</p> + <TextInput label="Answer" grabFocus bind={[answer, setAnswer]} /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm && ( + <AsyncButton class="button is-info" onClick={onNext}> + Confirm + </AsyncButton> + )} + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/index.tsx b/packages/anastasis-webui/src/pages/home/authMethod/index.tsx index 7b0cce883..b4f649488 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/index.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/index.tsx @@ -1,25 +1,60 @@ +import { AuthMethod } from "anastasis-core"; import { h, VNode } from "preact"; -import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; +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 videoIcon from "../../../assets/icons/auth_method/video.svg"; +import { AuthMethodEmailSetup as EmailSetup } from "./AuthMethodEmailSetup"; +import { AuthMethodEmailSolve as EmailSolve } from "./AuthMethodEmailSolve"; +import { AuthMethodIbanSetup as IbanSetup } from "./AuthMethodIbanSetup"; +import { AuthMethodPostSetup as PostalSetup } from "./AuthMethodPostSetup"; +import { AuthMethodQuestionSetup as QuestionSetup } from "./AuthMethodQuestionSetup"; +import { AuthMethodSmsSetup as SmsSetup } from "./AuthMethodSmsSetup"; +import { AuthMethodTotpSetup as TotpSetup } from "./AuthMethodTotpSetup"; +import { AuthMethodVideoSetup as VideoSetup } from "./AuthMethodVideoSetup"; -import { AuthMethodEmailSetup as EmailScreen } from "./AuthMethodEmailSetup"; -import { AuthMethodIbanSetup as IbanScreen } from "./AuthMethodIbanSetup"; -import { AuthMethodPostSetup as PostalScreen } from "./AuthMethodPostSetup"; -import { AuthMethodQuestionSetup as QuestionScreen } from "./AuthMethodQuestionSetup"; -import { AuthMethodSmsSetup as SmsScreen } from "./AuthMethodSmsSetup"; -import { AuthMethodTotpSetup as TotpScreen } from "./AuthMethodTotpSetup"; -import { AuthMethodVideoSetup as VideScreen } from "./AuthMethodVideoSetup"; -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 videoIcon from '../../../assets/icons/auth_method/video.svg'; +import { AuthMethodIbanSolve as IbanSolve } from "./AuthMethodIbanSolve"; +import { AuthMethodPostSolve as PostalSolve } from "./AuthMethodPostSolve"; +import { AuthMethodQuestionSolve as QuestionSolve } from "./AuthMethodQuestionSolve"; +import { AuthMethodSmsSolve as SmsSolve } from "./AuthMethodSmsSolve"; +import { AuthMethodTotpSolve as TotpSolve } from "./AuthMethodTotpSolve"; +import { AuthMethodVideoSolve as VideoSolve } from "./AuthMethodVideoSolve"; + +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; - screen: (props: AuthMethodSetupProps) => VNode; + setup: (props: AuthMethodSetupProps) => VNode; + solve: (props: AuthMethodSolveProps) => VNode; skip?: boolean; } -export type KnownAuthMethods = "sms" | "email" | "post" | "question" | "video" | "totp" | "iban"; +// export type KnownAuthMethods = "sms" | "email" | "post" | "question" | "video" | "totp" | "iban"; + +const ALL_METHODS = [ + "sms", + "email", + "post", + "question", + "video", + "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; @@ -29,41 +64,44 @@ export const authMethods: KnowMethodConfig = { question: { icon: <img src={questionIcon} />, label: "Question", - screen: QuestionScreen + setup: QuestionSetup, + solve: QuestionSolve, }, sms: { icon: <img src={smsIcon} />, label: "SMS", - screen: SmsScreen + setup: SmsSetup, + solve: SmsSolve, }, email: { icon: <i class="mdi mdi-email" />, label: "Email", - screen: EmailScreen - + setup: EmailSetup, + solve: EmailSolve, }, iban: { icon: <i class="mdi mdi-bank" />, label: "IBAN", - screen: IbanScreen - + setup: IbanSetup, + solve: IbanSolve, }, post: { icon: <img src={postalIcon} />, label: "Physical mail", - screen: PostalScreen - + setup: PostalSetup, + solve: PostalSolve, }, totp: { icon: <i class="mdi mdi-devices" />, label: "TOTP", - screen: TotpScreen - + setup: TotpSetup, + solve: TotpSolve, }, video: { icon: <img src={videoIcon} />, label: "Video", - screen: VideScreen, - skip: true, - } -}
\ No newline at end of file + setup: VideoSetup, + solve: VideoSolve, + skip: true, + }, +}; diff --git a/packages/anastasis-webui/src/pages/home/authMethod/totp.ts b/packages/anastasis-webui/src/pages/home/authMethod/totp.ts index 0bc3feaf8..c2288671c 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/totp.ts +++ b/packages/anastasis-webui/src/pages/home/authMethod/totp.ts @@ -1,54 +1,61 @@ /* eslint-disable @typescript-eslint/camelcase */ -import jssha from 'jssha' +import jssha from "jssha"; -const SEARCH_RANGE = 16 -const timeStep = 30 +const SEARCH_RANGE = 16; +const timeStep = 30; -export function computeTOTPandCheck(secretKey: Uint8Array, digits: number, code: number): boolean { - const now = new Date().getTime() +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' } }); + const hmacSha = new jssha("SHA-1", "HEX", { + hmacKey: { value: secretKey, format: "UINT8ARRAY" }, + }); hmacSha.update(movingFactor); - const hmac_text = hmacSha.getHMAC('UINT8ARRAY'); + const hmac_text = hmacSha.getHMAC("UINT8ARRAY"); - const offset = (hmac_text[hmac_text.length - 1] & 0xf) + 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) + 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 + if (otp == code) return true; } - return false + return false; } -const encTable__ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".split('') +const encTable__ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".split(""); export function base32enc(buffer: Uint8Array): string { - let rpos = 0 - let bits = 0 - let vbit = 0 + let rpos = 0; + let bits = 0; + let vbit = 0; - let result = "" - while ((rpos < buffer.length) || (vbit > 0)) { - if ((rpos < buffer.length) && (vbit < 5)) { + 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); + bits <<= 5 - vbit; vbit = 5; } result += encTable__[(bits >> (vbit - 5)) & 31]; vbit -= 5; } - return result + return result; } // const array = new Uint8Array(256) diff --git a/packages/anastasis-webui/src/pages/home/index.tsx b/packages/anastasis-webui/src/pages/home/index.tsx index 07bc7c604..d83442e62 100644 --- a/packages/anastasis-webui/src/pages/home/index.tsx +++ b/packages/anastasis-webui/src/pages/home/index.tsx @@ -1,25 +1,22 @@ +import { BackupStates, RecoveryStates } from "anastasis-core"; import { - BackupStates, - RecoveryStates, - ReducerStateBackup, - ReducerStateRecovery -} from "anastasis-core"; -import { - ComponentChildren, Fragment, + ComponentChildren, + Fragment, FunctionalComponent, h, - VNode + VNode, } from "preact"; -import { - useErrorBoundary -} from "preact/hooks"; +import { useErrorBoundary } from "preact/hooks"; import { AsyncButton } from "../../components/AsyncButton"; import { Menu } from "../../components/menu"; import { Notifications } from "../../components/Notifications"; -import { AnastasisProvider, useAnastasisContext } from "../../context/anastasis"; +import { + AnastasisProvider, + useAnastasisContext, +} from "../../context/anastasis"; import { AnastasisReducerApi, - useAnastasisReducer + useAnastasisReducer, } from "../../hooks/use-anastasis-reducer"; import { AttributeEntryScreen } from "./AttributeEntryScreen"; import { AuthenticationEditorScreen } from "./AuthenticationEditorScreen"; @@ -51,7 +48,11 @@ export function withProcessLabel( } interface AnastasisClientFrameProps { - onNext?(): void; + onNext?(): Promise<void>; + /** + * Override for the "back" functionality. + */ + onBack?(): Promise<void>; title: string; children: ComponentChildren; /** @@ -118,9 +119,27 @@ export function AnastasisClientFrame(props: AnastasisClientFrameProps): VNode { <section class="section is-main-section"> {props.children} {!props.hideNav ? ( - <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> - <button class="button" onClick={() => reducer.back()}>Back</button> - <AsyncButton class="button is-info" data-tooltip={props.hideNext} onClick={next} disabled={props.hideNext !== undefined}>Next</AsyncButton> + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button + class="button" + onClick={() => (props.onBack ?? reducer.back)()} + > + Back + </button> + <AsyncButton + class="button is-info" + data-tooltip={props.hideNext} + onClick={next} + disabled={props.hideNext !== undefined} + > + Next + </AsyncButton> </div> ) : null} </section> @@ -141,7 +160,7 @@ const AnastasisClient: FunctionalComponent = () => { }; function AnastasisClientImpl(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); if (!reducer) { return <p>Fatal: Reducer must be in context.</p>; } @@ -157,27 +176,19 @@ function AnastasisClientImpl(): VNode { state.backup_state === BackupStates.CountrySelecting || state.recovery_state === RecoveryStates.CountrySelecting ) { - return ( - <ContinentSelectionScreen /> - ); + return <ContinentSelectionScreen />; } if ( state.backup_state === BackupStates.UserAttributesCollecting || state.recovery_state === RecoveryStates.UserAttributesCollecting ) { - return ( - <AttributeEntryScreen /> - ); + return <AttributeEntryScreen />; } if (state.backup_state === BackupStates.AuthenticationsEditing) { - return ( - <AuthenticationEditorScreen /> - ); + return <AuthenticationEditorScreen />; } if (state.backup_state === BackupStates.PoliciesReviewing) { - return ( - <ReviewPoliciesScreen /> - ); + return <ReviewPoliciesScreen />; } if (state.backup_state === BackupStates.SecretEditing) { return <SecretEditorScreen />; @@ -196,15 +207,11 @@ function AnastasisClientImpl(): VNode { } if (state.recovery_state === RecoveryStates.SecretSelecting) { - return ( - <SecretSelectionScreen /> - ); + return <SecretSelectionScreen />; } if (state.recovery_state === RecoveryStates.ChallengeSelecting) { - return ( - <ChallengeOverviewScreen /> - ); + return <ChallengeOverviewScreen />; } if (state.recovery_state === RecoveryStates.ChallengeSolving) { @@ -212,9 +219,7 @@ function AnastasisClientImpl(): VNode { } if (state.recovery_state === RecoveryStates.RecoveryFinished) { - return ( - <RecoveryFinishedScreen /> - ); + return <RecoveryFinishedScreen />; } if (state.recovery_state === RecoveryStates.ChallengePaying) { return <ChallengePayingScreen />; @@ -224,7 +229,9 @@ function AnastasisClientImpl(): VNode { <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> ); @@ -236,11 +243,17 @@ function AnastasisClientImpl(): VNode { function ErrorBanner(): VNode | null { const reducer = useAnastasisContext(); if (!reducer || !reducer.currentError) return null; - return (<Notifications removeNotification={reducer.dismissError} notifications={[{ - type: "ERROR", - message: `Error code: ${reducer.currentError.code}`, - description: reducer.currentError.hint - }]} /> + return ( + <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/notfound/index.tsx b/packages/anastasis-webui/src/pages/notfound/index.tsx index 4e74d1d9f..bb22429b0 100644 --- a/packages/anastasis-webui/src/pages/notfound/index.tsx +++ b/packages/anastasis-webui/src/pages/notfound/index.tsx @@ -1,16 +1,16 @@ -import { FunctionalComponent, h } from 'preact'; -import { Link } from 'preact-router/match'; +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> - ); + 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/profile/index.tsx b/packages/anastasis-webui/src/pages/profile/index.tsx index 859a83ed4..bcd26370e 100644 --- a/packages/anastasis-webui/src/pages/profile/index.tsx +++ b/packages/anastasis-webui/src/pages/profile/index.tsx @@ -1,43 +1,42 @@ -import { FunctionalComponent, h } from 'preact'; -import { useEffect, useState } from 'preact/hooks'; +import { FunctionalComponent, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; interface Props { - user: string; + 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); + 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> + 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> + <div>Current time: {new Date(time).toLocaleString()}</div> - <p> - <button onClick={increment}>Click Me</button> Clicked {count}{' '} - times. - </p> - </div> - ); + <p> + <button onClick={increment}>Click Me</button> Clicked {count} times. + </p> + </div> + ); }; export default Profile; diff --git a/packages/anastasis-webui/src/scss/DurationPicker.scss b/packages/anastasis-webui/src/scss/DurationPicker.scss index a35575324..aa75b9916 100644 --- a/packages/anastasis-webui/src/scss/DurationPicker.scss +++ b/packages/anastasis-webui/src/scss/DurationPicker.scss @@ -1,4 +1,3 @@ - .rdp-picker { display: flex; height: 175px; diff --git a/packages/anastasis-webui/src/scss/_aside.scss b/packages/anastasis-webui/src/scss/_aside.scss index c9332b252..11809990b 100644 --- a/packages/anastasis-webui/src/scss/_aside.scss +++ b/packages/anastasis-webui/src/scss/_aside.scss @@ -19,37 +19,35 @@ * @author Sebastian Javier Marchano (sebasjm) */ -@include desktop { - html { - &.has-aside-left { - &.has-aside-expanded { - nav.navbar, - body { - padding-left: $aside-width; - } - } - aside.is-placed-left { - display: block; +html { + &.has-aside-left { + &.has-aside-expanded { + nav.navbar, + body { + padding-left: $aside-width; } } + aside.is-placed-left { + display: block; + } } +} - aside.aside.is-expanded { - width: $aside-width; +aside.aside.is-expanded { + width: $aside-width; - .menu-list { - @include icon-with-update-mark($aside-icon-width); + .menu-list { + @include icon-with-update-mark($aside-icon-width); - span.menu-item-label { - display: inline-block; - } + span.menu-item-label { + display: inline-block; + } - li.is-active { - ul { - display: block; - } - background-color: $body-background-color; + li.is-active { + ul { + display: block; } + background-color: $body-background-color; } } } @@ -128,59 +126,3 @@ aside.aside { margin-bottom: $default-padding * 0.5; } } - -@include touch { - nav.navbar { - @include transition(margin-left); - } - aside.aside { - @include transition(left); - } - html.has-aside-mobile-transition { - body { - overflow-x: hidden; - } - body, - nav.navbar { - width: 100vw; - } - aside.aside { - width: $aside-mobile-width; - display: block; - left: $aside-mobile-width * -1; - - .image { - img { - max-width: $aside-mobile-width * 0.33; - } - } - - .menu-list { - li.is-active { - ul { - display: block; - } - background-color: $body-background-color; - } - li { - @include icon-with-update-mark($aside-icon-width); - margin-top: 8px; - margin-bottom: 8px; - } - a { - span.menu-item-label { - display: inline-block; - } - } - } - } - } - div.has-aside-mobile-expanded { - nav.navbar { - margin-left: $aside-mobile-width; - } - aside.aside { - left: 0; - } - } -} diff --git a/packages/anastasis-webui/src/scss/_card.scss b/packages/anastasis-webui/src/scss/_card.scss index b2eec27a1..3f71aeb6a 100644 --- a/packages/anastasis-webui/src/scss/_card.scss +++ b/packages/anastasis-webui/src/scss/_card.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ @@ -39,7 +39,7 @@ &.is-card-widget { .card-content { - padding: $default-padding * .5; + padding: $default-padding * 0.5; } } diff --git a/packages/anastasis-webui/src/scss/_custom-calendar.scss b/packages/anastasis-webui/src/scss/_custom-calendar.scss index bff68cf79..e0334b62d 100644 --- a/packages/anastasis-webui/src/scss/_custom-calendar.scss +++ b/packages/anastasis-webui/src/scss/_custom-calendar.scss @@ -16,31 +16,30 @@ :root { --primary-color: #3298dc; - - --primary-text-color-dark: rgba(0,0,0,.87); - --secondary-text-color-dark: rgba(0,0,0,.57); - --disabled-text-color-dark: rgba(0,0,0,.13); - - --primary-text-color-light: rgba(255,255,255,.87); - --secondary-text-color-light: rgba(255,255,255,.57); - --disabled-text-color-light: rgba(255,255,255,.13); - - --font-stack: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif; - + + --primary-text-color-dark: rgba(0, 0, 0, 0.87); + --secondary-text-color-dark: rgba(0, 0, 0, 0.57); + --disabled-text-color-dark: rgba(0, 0, 0, 0.13); + + --primary-text-color-light: rgba(255, 255, 255, 0.87); + --secondary-text-color-light: rgba(255, 255, 255, 0.57); + --disabled-text-color-light: rgba(255, 255, 255, 0.13); + + --font-stack: "Roboto", "Helvetica Neue", Helvetica, Arial, sans-serif; + --primary-card-color: #fff; --primary-background-color: #f2f2f2; - + --box-shadow-lvl-1: 0 1px 3px rgba(0, 0, 0, 0.12), - 0 1px 2px rgba(0, 0, 0, 0.24); + 0 1px 2px rgba(0, 0, 0, 0.24); --box-shadow-lvl-2: 0 3px 6px rgba(0, 0, 0, 0.16), - 0 3px 6px rgba(0, 0, 0, 0.23); + 0 3px 6px rgba(0, 0, 0, 0.23); --box-shadow-lvl-3: 0 10px 20px rgba(0, 0, 0, 0.19), - 0 6px 6px rgba(0, 0, 0, 0.23); + 0 6px 6px rgba(0, 0, 0, 0.23); --box-shadow-lvl-4: 0 14px 28px rgba(0, 0, 0, 0.25), - 0 10px 10px rgba(0, 0, 0, 0.22); + 0 10px 10px rgba(0, 0, 0, 0.22); } - .home .datePicker div { margin-top: 0px; margin-bottom: 0px; @@ -56,7 +55,7 @@ width: 90vw; max-width: 448px; transform-origin: top left; - transition: transform .22s ease-in-out, opacity .22s ease-in-out; + transition: transform 0.22s ease-in-out, opacity 0.22s ease-in-out; top: 50%; left: 50%; opacity: 0; @@ -67,7 +66,7 @@ opacity: 1; transform: scale(1) translate(-50%, -50%); } - + .datePicker--titles { border-top-left-radius: 3px; border-top-right-radius: 3px; @@ -75,7 +74,8 @@ height: 100px; background: var(--primary-color); - h2, h3 { + h2, + h3 { cursor: pointer; color: #fff; line-height: 1; @@ -85,7 +85,7 @@ } h3 { - color: rgba(255,255,255,.57); + color: rgba(255, 255, 255, 0.57); font-size: 18px; padding-bottom: 2px; } @@ -114,13 +114,13 @@ font-size: 26px; user-select: none; border-radius: 50%; - + &:hover { background: var(--disabled-text-color-dark); } } } - + .datePicker--scroll { overflow-y: auto; max-height: calc(90vh - 56px - 100px); @@ -133,9 +133,11 @@ width: 100%; display: grid; text-align: center; - + // there's probably a better way to do this, but wanted to try out CSS grid - grid-template-columns: calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7); + grid-template-columns: calc(100% / 7) calc(100% / 7) calc(100% / 7) calc( + 100% / 7 + ) calc(100% / 7) calc(100% / 7) calc(100% / 7); span { color: var(--secondary-text-color-dark); @@ -149,14 +151,16 @@ width: 100%; display: grid; text-align: center; - grid-template-columns: calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7); + grid-template-columns: calc(100% / 7) calc(100% / 7) calc(100% / 7) calc( + 100% / 7 + ) calc(100% / 7) calc(100% / 7) calc(100% / 7); span { color: var(--primary-text-color-dark); line-height: 42px; font-size: 14px; display: inline-grid; - transition: color .22s; + transition: color 0.22s; height: 42px; position: relative; cursor: pointer; @@ -164,7 +168,7 @@ border-radius: 50%; &::before { - content: ''; + content: ""; position: absolute; z-index: -1; height: 42px; @@ -172,12 +176,12 @@ left: calc(50% - 21px); background: var(--primary-color); border-radius: 50%; - transition: transform .22s, opacity .22s; + transition: transform 0.22s, opacity 0.22s; transform: scale(0); opacity: 0; } - - &[disabled=true] { + + &[disabled="true"] { cursor: unset; } @@ -186,7 +190,7 @@ } &.datePicker--selected { - color: rgba(255,255,255,.87); + color: rgba(255, 255, 255, 0.87); &:before { transform: scale(1); @@ -196,21 +200,21 @@ } } } - + .datePicker--selectYear { padding: 0 20px; display: block; width: 100%; text-align: center; max-height: 362px; - + span { display: block; width: 100%; font-size: 24px; margin: 20px auto; cursor: pointer; - + &.selected { font-size: 42px; color: var(--primary-color); @@ -236,9 +240,10 @@ appearance: none; padding: 0 16px; border-radius: 3px; - transition: background-color .13s; + transition: background-color 0.13s; - &:hover, &:focus { + &:hover, + &:focus { outline: none; background-color: var(--disabled-text-color-dark); } @@ -253,6 +258,6 @@ left: 0; bottom: 0; right: 0; - background: rgba(0,0,0,.52); - animation: fadeIn .22s forwards; + background: rgba(0, 0, 0, 0.52); + animation: fadeIn 0.22s forwards; } diff --git a/packages/anastasis-webui/src/scss/_footer.scss b/packages/anastasis-webui/src/scss/_footer.scss index 027a5ca8b..112522ed8 100644 --- a/packages/anastasis-webui/src/scss/_footer.scss +++ b/packages/anastasis-webui/src/scss/_footer.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ diff --git a/packages/anastasis-webui/src/scss/_form.scss b/packages/anastasis-webui/src/scss/_form.scss index 71f0d4da4..786044eff 100644 --- a/packages/anastasis-webui/src/scss/_form.scss +++ b/packages/anastasis-webui/src/scss/_form.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ @@ -22,11 +22,12 @@ .field { &.has-check { .field-body { - margin-top: $default-padding * .125; + margin-top: $default-padding * 0.125; } } .control { - .mdi-24px.mdi-set, .mdi-24px.mdi:before { + .mdi-24px.mdi-set, + .mdi-24px.mdi:before { font-size: inherit; } } @@ -37,28 +38,34 @@ } } -.input, .textarea, select { +.input, +.textarea, +select { box-shadow: none; - &:focus, &:active { - box-shadow: none!important; + &:focus, + &:active { + box-shadow: none !important; } } -.switch input[type=checkbox]+.check:before { +.switch input[type="checkbox"] + .check:before { box-shadow: none; } -.switch, .b-checkbox.checkbox { - input[type=checkbox] { - &:focus + .check, &:focus:checked + .check { - box-shadow: none!important; +.switch, +.b-checkbox.checkbox { + input[type="checkbox"] { + &:focus + .check, + &:focus:checked + .check { + box-shadow: none !important; } } } -.b-checkbox.checkbox input[type=checkbox], .b-radio.radio input[type=radio] { - &+.check { +.b-checkbox.checkbox input[type="checkbox"], +.b-radio.radio input[type="radio"] { + & + .check { border: $checkbox-border; } } diff --git a/packages/anastasis-webui/src/scss/_hero-bar.scss b/packages/anastasis-webui/src/scss/_hero-bar.scss index 90b67a2ed..31b7e623e 100644 --- a/packages/anastasis-webui/src/scss/_hero-bar.scss +++ b/packages/anastasis-webui/src/scss/_hero-bar.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ @@ -32,17 +32,17 @@ section.hero.is-hero-bar { } > div > .level { - margin-bottom: $default-padding * .5; + margin-bottom: $default-padding * 0.5; } .subtitle + p { - margin-top: $default-padding * .5; + margin-top: $default-padding * 0.5; } } .button { &.is-hero-button { - background-color: rgba($white, .5); + background-color: rgba($white, 0.5); font-weight: 300; @include transition(background-color); diff --git a/packages/anastasis-webui/src/scss/_main-section.scss b/packages/anastasis-webui/src/scss/_main-section.scss index 1a4fad81d..01edc24bf 100644 --- a/packages/anastasis-webui/src/scss/_main-section.scss +++ b/packages/anastasis-webui/src/scss/_main-section.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ diff --git a/packages/anastasis-webui/src/scss/_mixins.scss b/packages/anastasis-webui/src/scss/_mixins.scss index 0809033ed..b52e590e3 100644 --- a/packages/anastasis-webui/src/scss/_mixins.scss +++ b/packages/anastasis-webui/src/scss/_mixins.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ @@ -23,12 +23,12 @@ transition: $t 250ms ease-in-out 50ms; } -@mixin icon-with-update-mark ($icon-base-width) { +@mixin icon-with-update-mark($icon-base-width) { .icon { width: $icon-base-width; &.has-update-mark:after { - right: ($icon-base-width / 2) - .85; + right: ($icon-base-width / 2) - 0.85; } } } diff --git a/packages/anastasis-webui/src/scss/_modal.scss b/packages/anastasis-webui/src/scss/_modal.scss index 3edbb8d3a..b3a31ebf1 100644 --- a/packages/anastasis-webui/src/scss/_modal.scss +++ b/packages/anastasis-webui/src/scss/_modal.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ diff --git a/packages/anastasis-webui/src/scss/_nav-bar.scss b/packages/anastasis-webui/src/scss/_nav-bar.scss index 09f1e2326..c6dd04263 100644 --- a/packages/anastasis-webui/src/scss/_nav-bar.scss +++ b/packages/anastasis-webui/src/scss/_nav-bar.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ @@ -25,7 +25,7 @@ nav.navbar { .navbar-item { &.has-user-avatar { .is-user-avatar { - margin-right: $default-padding * .5; + margin-right: $default-padding * 0.5; display: inline-flex; width: $navbar-avatar-size; height: $navbar-avatar-size; @@ -98,11 +98,11 @@ nav.navbar { .navbar-item { .icon:first-child { - margin-right: $default-padding * .5; + margin-right: $default-padding * 0.5; } &.has-dropdown { - >.navbar-link { + > .navbar-link { background-color: $white-ter; .icon:last-child { display: none; @@ -111,11 +111,11 @@ nav.navbar { } &.has-user-avatar { - >.navbar-link { + > .navbar-link { display: flex; align-items: center; - padding-top: $default-padding * .5; - padding-bottom: $default-padding * .5; + padding-top: $default-padding * 0.5; + padding-bottom: $default-padding * 0.5; } } } @@ -131,7 +131,7 @@ nav.navbar { &:not(.is-desktop-icon-only) { .icon:first-child { - margin-right: $default-padding * .5; + margin-right: $default-padding * 0.5; } } &.is-desktop-icon-only { diff --git a/packages/anastasis-webui/src/scss/_table.scss b/packages/anastasis-webui/src/scss/_table.scss index 9cf6f4dcd..b68d50e4f 100644 --- a/packages/anastasis-webui/src/scss/_table.scss +++ b/packages/anastasis-webui/src/scss/_table.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ @@ -26,7 +26,8 @@ table.table { } } - td, th { + td, + th { &.checkbox-cell { .b-checkbox.checkbox:not(.button) { margin-right: 0; @@ -83,7 +84,9 @@ table.table { } } - .pagination-previous, .pagination-next, .pagination-link { + .pagination-previous, + .pagination-next, + .pagination-link { border-color: $button-border-color; color: $base-color; @@ -108,24 +111,25 @@ table.table { &.has-mobile-sort-spaced { .b-table { .field.table-mobile-sort { - padding-top: $default-padding * .5; + padding-top: $default-padding * 0.5; } } } } .b-table { .field.table-mobile-sort { - padding: 0 $default-padding * .5; + padding: 0 $default-padding * 0.5; } .table-wrapper.has-mobile-cards { tr { box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1); - margin-bottom: 3px!important; + margin-bottom: 3px !important; } td { &.is-progress-col { - span, progress { + span, + progress { display: flex; width: 45%; align-items: center; @@ -133,11 +137,13 @@ table.table { } } - &.checkbox-cell, &.is-image-cell { - border-bottom: 0!important; + &.checkbox-cell, + &.is-image-cell { + border-bottom: 0 !important; } - &.checkbox-cell, &.is-actions-cell { + &.checkbox-cell, + &.is-actions-cell { &:before { display: none; } @@ -163,7 +169,7 @@ table.table { .image { width: $table-avatar-size-mobile; height: auto; - margin: 0 auto $default-padding * .25; + margin: 0 auto $default-padding * 0.25; } } } diff --git a/packages/anastasis-webui/src/scss/_tiles.scss b/packages/anastasis-webui/src/scss/_tiles.scss index 94fc04e70..e69d995f0 100644 --- a/packages/anastasis-webui/src/scss/_tiles.scss +++ b/packages/anastasis-webui/src/scss/_tiles.scss @@ -14,12 +14,11 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ - .is-tiles-wrapper { margin-bottom: $default-padding; } diff --git a/packages/anastasis-webui/src/scss/_title-bar.scss b/packages/anastasis-webui/src/scss/_title-bar.scss index 736f26cbd..932f8e65d 100644 --- a/packages/anastasis-webui/src/scss/_title-bar.scss +++ b/packages/anastasis-webui/src/scss/_title-bar.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ @@ -26,14 +26,14 @@ section.section.is-title-bar { ul { li { display: inline-block; - padding: 0 $default-padding * .5 0 0; + padding: 0 $default-padding * 0.5 0 0; font-size: $default-padding; color: $title-bar-color; &:after { display: inline-block; - content: '/'; - padding-left: $default-padding * .5; + content: "/"; + padding-left: $default-padding * 0.5; } &:last-child { diff --git a/packages/anastasis-webui/src/scss/main.scss b/packages/anastasis-webui/src/scss/main.scss index b5335073f..9311fbba0 100644 --- a/packages/anastasis-webui/src/scss/main.scss +++ b/packages/anastasis-webui/src/scss/main.scss @@ -190,7 +190,6 @@ div[data-tooltip]::before { border: solid 1px #f2e9bf; } - .home { padding: 1em 1em; min-height: 100%; @@ -218,9 +217,9 @@ div[data-tooltip]::before { } .profile { - padding: 56px 20px; - min-height: 100%; - width: 100%; + padding: 56px 20px; + min-height: 100%; + width: 100%; } .notfound { @@ -232,4 +231,4 @@ h1 { font-size: 1.5em; margin-top: 0.8em; margin-bottom: 0.8em; -}
\ No newline at end of file +} diff --git a/packages/anastasis-webui/src/template.html b/packages/anastasis-webui/src/template.html index 351f1829c..8ae2fe104 100644 --- a/packages/anastasis-webui/src/template.html +++ b/packages/anastasis-webui/src/template.html @@ -1,15 +1,51 @@ +<!-- + 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 +--> <!DOCTYPE html> -<html lang="en" class="has-aside-left has-aside-mobile-transition has-navbar-fixed-top has-aside-expanded"> - <head> - <meta charset="utf-8"> - <title><% preact.title %></title> - <meta name="viewport" content="width=device-width,initial-scale=1"> - <meta name="mobile-web-app-capable" content="yes"> - <meta name="apple-mobile-web-app-capable" content="yes"> - <link rel="apple-touch-icon" href="/assets/icons/apple-touch-icon.png"> - <% preact.headEnd %> - </head> - <body> - <% preact.bodyEnd %> - </body> +<html + lang="en" + class="has-aside-left has-aside-mobile-transition has-navbar-fixed-top has-aside-expanded" +> + <head> + <meta charset="utf-8" /> + <title><%= htmlWebpackPlugin.options.title %></title> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <meta name="mobile-web-app-capable" content="yes" /> + <meta name="apple-mobile-web-app-capable" content="yes" /> + + <link + rel="icon" + href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + /> + <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" /> + + <% if (htmlWebpackPlugin.options.manifest.theme_color) { %> + <meta + name="theme-color" + content="<%= htmlWebpackPlugin.options.manifest.theme_color %>" + /> + <% } %> + </head> + <body> + <script> + <%= compilation.assets[htmlWebpackPlugin.files.chunks["polyfills"].entry.substr(htmlWebpackPlugin.files.publicPath.length)].source() %> + </script> + <script> + <%= compilation.assets[htmlWebpackPlugin.files.chunks["bundle"].entry.substr(htmlWebpackPlugin.files.publicPath.length)].source() %> + </script> + </body> </html> diff --git a/packages/anastasis-webui/src/utils/index.tsx b/packages/anastasis-webui/src/utils/index.tsx index 9c01aa6ba..a8f6c3101 100644 --- a/packages/anastasis-webui/src/utils/index.tsx +++ b/packages/anastasis-webui/src/utils/index.tsx @@ -1,45 +1,67 @@ /* eslint-disable @typescript-eslint/camelcase */ -import { BackupStates, RecoveryStates, ReducerState } from 'anastasis-core'; -import { FunctionalComponent, h, VNode } from 'preact'; -import { AnastasisProvider } from '../context/anastasis'; +import { BackupStates, RecoveryStates, ReducerState } from "anastasis-core"; +import { FunctionalComponent, h, VNode } from "preact"; +import { AnastasisProvider } from "../context/anastasis"; -export function createExample<Props>(Component: FunctionalComponent<Props>, currentReducerState?: ReducerState, props?: Partial<Props>): { (args: Props): VNode } { +export function createExample<Props>( + Component: FunctionalComponent<Props>, + currentReducerState?: ReducerState, + props?: Partial<Props>, +): { (args: Props): VNode } { const r = (args: Props): VNode => { - return <AnastasisProvider value={{ - currentReducerState, - currentError: undefined, - back: async () => { null }, - dismissError: async () => { null }, - reset: () => { null }, - runTransaction: async () => { null }, - startBackup: () => { null }, - startRecover: () => { null }, - transition: async () => { null }, - }}> - <Component {...args} /> - </AnastasisProvider> - } - r.args = props - return r + return ( + <AnastasisProvider + value={{ + currentReducerState, + currentError: undefined, + back: async () => { + null; + }, + dismissError: async () => { + null; + }, + reset: () => { + null; + }, + runTransaction: async () => { + null; + }, + startBackup: () => { + null; + }, + startRecover: () => { + null; + }, + transition: async () => { + null; + }, + }} + > + <Component {...args} /> + </AnastasisProvider> + ); + }; + r.args = props; + return r; } const base = { continents: [ { - name: "Europe" + name: "Europe", }, { - name: "India" + name: "India", }, { - name: "Asia" + name: "Asia", }, { - name: "North America" + name: "North America", }, { - name: "Testcontinent" - } + name: "Testcontinent", + }, ], countries: [ { @@ -47,122 +69,124 @@ const base = { name: "Testland", continent: "Testcontinent", continent_i18n: { - de_DE: "Testkontinent" + de_DE: "Testkontinent", }, name_i18n: { de_DE: "Testlandt", de_CH: "Testlandi", fr_FR: "Testpais", - en_UK: "Testland" + en_UK: "Testland", }, currency: "TESTKUDOS", - call_code: "+00" + call_code: "+00", }, { code: "xy", name: "Demoland", continent: "Testcontinent", continent_i18n: { - de_DE: "Testkontinent" + de_DE: "Testkontinent", }, name_i18n: { de_DE: "Demolandt", de_CH: "Demolandi", fr_FR: "Demopais", - en_UK: "Demoland" + en_UK: "Demoland", }, currency: "KUDOS", - call_code: "+01" - } + call_code: "+01", + }, ], authentication_providers: { "http://localhost:8086/": { http_status: 200, annual_fee: "COL:0", - business_name: "ana", + business_name: "Anastasis Local", currency: "COL", liability_limit: "COL:10", methods: [ { type: "question", - usage_fee: "COL:0" - }, { + usage_fee: "COL:0", + }, + { type: "sms", - usage_fee: "COL:0" - }, { + usage_fee: "COL:0", + }, + { type: "email", - usage_fee: "COL:0" + usage_fee: "COL:0", }, ], salt: "WBMDD76BR1E90YQ5AHBMKPH7GW", storage_limit_in_megabytes: 16, - truth_upload_fee: "COL:0" + truth_upload_fee: "COL:0", }, "https://kudos.demo.anastasis.lu/": { http_status: 200, annual_fee: "COL:0", - business_name: "ana", + business_name: "Anastasis Kudo", currency: "COL", liability_limit: "COL:10", methods: [ { type: "question", - usage_fee: "COL:0" - }, { + usage_fee: "COL:0", + }, + { type: "email", - usage_fee: "COL:0" + usage_fee: "COL:0", }, ], salt: "WBMDD76BR1E90YQ5AHBMKPH7GW", storage_limit_in_megabytes: 16, - truth_upload_fee: "COL:0" + truth_upload_fee: "COL:0", }, "https://anastasis.demo.taler.net/": { http_status: 200, annual_fee: "COL:0", - business_name: "ana", + business_name: "Anastasis Demo", currency: "COL", liability_limit: "COL:10", methods: [ { type: "question", - usage_fee: "COL:0" - }, { + usage_fee: "COL:0", + }, + { type: "sms", - usage_fee: "COL:0" - }, { + usage_fee: "COL:0", + }, + { type: "totp", - usage_fee: "COL:0" + usage_fee: "COL:0", }, ], salt: "WBMDD76BR1E90YQ5AHBMKPH7GW", storage_limit_in_megabytes: 16, - truth_upload_fee: "COL:0" + truth_upload_fee: "COL:0", }, "http://localhost:8087/": { code: 8414, - hint: "request to provider failed" + hint: "request to provider failed", }, "http://localhost:8088/": { code: 8414, - hint: "request to provider failed" + hint: "request to provider failed", }, "http://localhost:8089/": { code: 8414, - hint: "request to provider failed" - } + hint: "request to provider failed", + }, }, - // expiration: { - // d_ms: 1792525051855 // check t_ms - // }, -} as Partial<ReducerState> +} as Partial<ReducerState>; export const reducerStatesExample = { initial: undefined, recoverySelectCountry: { ...base, - recovery_state: RecoveryStates.CountrySelecting + recovery_state: RecoveryStates.CountrySelecting, } as ReducerState, recoverySelectContinent: { ...base, @@ -190,11 +214,11 @@ export const reducerStatesExample = { } as ReducerState, recoveryAttributeEditing: { ...base, - recovery_state: RecoveryStates.UserAttributesCollecting + recovery_state: RecoveryStates.UserAttributesCollecting, } as ReducerState, backupSelectCountry: { ...base, - backup_state: BackupStates.CountrySelecting + backup_state: BackupStates.CountrySelecting, } as ReducerState, backupSelectContinent: { ...base, @@ -218,15 +242,14 @@ export const reducerStatesExample = { } as ReducerState, authEditing: { ...base, - backup_state: BackupStates.AuthenticationsEditing + backup_state: BackupStates.AuthenticationsEditing, } as ReducerState, backupAttributeEditing: { ...base, - backup_state: BackupStates.UserAttributesCollecting + backup_state: BackupStates.UserAttributesCollecting, } as ReducerState, truthsPaying: { ...base, - backup_state: BackupStates.TruthsPaying + backup_state: BackupStates.TruthsPaying, } as ReducerState, - -} +}; |