diff options
Diffstat (limited to 'packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx')
-rw-r--r-- | packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx | 287 |
1 files changed, 246 insertions, 41 deletions
diff --git a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx index 2f804f940..1f8cea7aa 100644 --- a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx @@ -1,65 +1,270 @@ -/* eslint-disable @typescript-eslint/camelcase */ +/* + This file is part of GNU Anastasis + (C) 2021-2022 Anastasis SARL + + GNU Anastasis is free software; you can redistribute it and/or modify it under the + terms of the GNU Affero General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Anastasis is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with + GNU Anastasis; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { UserAttributeSpec, validators } from "@gnu-taler/anastasis-core"; +import { isAfter, parse } from "date-fns"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { ReducerStateRecovery, ReducerStateBackup, UserAttributeSpec } from "anastasis-core/lib"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisReducerApi } from "../../hooks/use-anastasis-reducer"; -import { AnastasisClientFrame, withProcessLabel, LabeledInput } from "./index"; +import { DateInput } from "../../components/fields/DateInput.js"; +import { PhoneNumberInput } from "../../components/fields/NumberInput.js"; +import { TextInput } from "../../components/fields/TextInput.js"; +import { useAnastasisContext } from "../../context/anastasis.js"; +import { ConfirmModal } from "./ConfirmModal.js"; +import { AnastasisClientFrame, withProcessLabel } from "./index.js"; export function AttributeEntryScreen(): VNode { - const reducer = useAnastasisContext() - const state = reducer?.currentReducerState - const currentIdentityAttributes = state && "identity_attributes" in state ? (state.identity_attributes || {}) : {} - const [attrs, setAttrs] = useState<Record<string, string>>(currentIdentityAttributes); + const reducer = useAnastasisContext(); + const state = reducer?.currentReducerState; + const currentIdentityAttributes = + state && "identity_attributes" in state + ? state.identity_attributes || {} + : {}; + const [attrs, setAttrs] = useState<Record<string, string>>( + currentIdentityAttributes, + ); + const isBackup = state?.reducer_type === "backup"; + const [askUserIfSure, setAskUserIfSure] = useState(false); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || !("required_attributes" in reducer.currentReducerState)) { - return <div>invalid state</div> + if ( + !reducer.currentReducerState || + !("required_attributes" in reducer.currentReducerState) + ) { + return <div>invalid state</div>; + } + const reqAttr = reducer.currentReducerState.required_attributes || []; + let hasErrors = false; + + const fieldList: VNode[] = reqAttr.map((spec, i: number) => { + const value = attrs[spec.name]; + const error = checkIfValid(value, spec); + + function addAutocomplete(newValue: string): string { + const ac = spec.autocomplete; + if (!ac || ac.length <= newValue.length || ac[newValue.length] === "?") + return newValue; + + if (!value || newValue.length < value.length) { + return newValue.slice(0, -1); + } + + return newValue + ac[newValue.length]; + } + + hasErrors = hasErrors || error !== undefined; + return ( + <AttributeEntryField + key={i} + isFirst={i == 0} + setValue={(v: string) => + setAttrs({ ...attrs, [spec.name]: addAutocomplete(v) }) + } + spec={spec} + errorMessage={error} + onConfirm={() => { + if (!hasErrors) { + setAskUserIfSure(true); + } + }} + value={value} + /> + ); + }); + + const doConfirm = async () => { + await reducer.transition("enter_user_attributes", { + identity_attributes: { + application_id: "anastasis-standalone", + ...attrs, + }, + }); + }; + + function saveAsPDF(): void { + const printWindow = window.open("", "", "height=400,width=800"); + const divContents = document.getElementById("printThis"); + + if (!printWindow || !divContents) return; + printWindow.document.write( + `<html><head><link rel="stylesheet" href="index.css" /><title>Anastasis Recovery Document</title><style>`, + ); + printWindow.document.write("</style></head><body> </body></html>"); + printWindow.document.close(); + printWindow.document.body.appendChild(divContents.cloneNode(true)); + printWindow.addEventListener("load", () => { + printWindow.print(); + printWindow.close(); + }); } - + return ( <AnastasisClientFrame - title={withProcessLabel(reducer, "Select Country")} - onNext={() => reducer.transition("enter_user_attributes", { - identity_attributes: attrs, - })} + title={withProcessLabel(reducer, "Who are you?")} + hideNext={hasErrors ? "Complete the form." : undefined} + onNext={async () => (isBackup ? setAskUserIfSure(true) : doConfirm())} > - {reducer.currentReducerState.required_attributes?.map((x, i: number) => { - return ( - <AttributeEntryField - key={i} - isFirst={i == 0} - setValue={(v: string) => setAttrs({ ...attrs, [x.name]: v })} - spec={x} - value={attrs[x.name]} /> - ); - })} + {askUserIfSure ? ( + <ConfirmModal + active + onCancel={() => setAskUserIfSure(false)} + description="The values in the form must be correct" + label="I am sure" + cancelLabel="Wait, I want to check" + onConfirm={() => doConfirm().then(() => setAskUserIfSure(false))} + > + You personal information is used to define the location where your + secret will be safely stored. If you forget what you have entered or + if there is a misspell you will be unable to recover your secret. + <p> + {/* TODO: make this actually work reliably cross-browser lol (opens about:blank for me) */} + <a onClick={saveAsPDF}>Save the personal information as PDF</a> + </p> + </ConfirmModal> + ) : undefined} + + <div class="columns" style={{ maxWidth: "unset" }}> + <div class="column" id="printThis"> + {fieldList} + </div> + <div class="column"> + <p>This personal information will help to locate your secret.</p> + <h1 class="title">This stays private</h1> + <p>The information you have entered here:</p> + <ul> + <li> + <span class="icon is-right"> + <i class="mdi mdi-circle-small" /> + </span> + Will be hashed, and therefore unreadable + </li> + <li> + <span class="icon is-right"> + <i class="mdi mdi-circle-small" /> + </span> + The non-hashed version is not shared + </li> + </ul> + </div> + </div> </AnastasisClientFrame> ); } -interface AttributeEntryProps { - reducer: AnastasisReducerApi; - reducerState: ReducerStateRecovery | ReducerStateBackup; -} - -export interface AttributeEntryFieldProps { +interface AttributeEntryFieldProps { isFirst: boolean; value: string; setValue: (newValue: string) => void; spec: UserAttributeSpec; + errorMessage: string | undefined; + onConfirm: () => void; } - -export function AttributeEntryField(props: AttributeEntryFieldProps): VNode { +const possibleBirthdayYear: Array<number> = []; +for (let i = 0; i < 100; i++) { + possibleBirthdayYear.push(2020 - i); +} +function AttributeEntryField(props: AttributeEntryFieldProps): VNode { return ( - <div> - <LabeledInput - grabFocus={props.isFirst} - label={props.spec.label} - bind={[props.value, props.setValue]} - /> + <div style={{ marginTop: 16 }}> + {props.spec.type === "date" && ( + <DateInput + grabFocus={props.isFirst} + label={props.spec.label} + years={possibleBirthdayYear} + onConfirm={props.onConfirm} + error={props.errorMessage} + bind={[props.value, props.setValue]} + /> + )} + {props.spec.type === "number" && ( + <PhoneNumberInput + grabFocus={props.isFirst} + label={props.spec.label} + onConfirm={props.onConfirm} + error={props.errorMessage} + bind={[props.value, props.setValue]} + /> + )} + {props.spec.type === "string" && ( + <TextInput + grabFocus={props.isFirst} + label={props.spec.label} + onConfirm={props.onConfirm} + error={props.errorMessage} + bind={[props.value, props.setValue]} + /> + )} + {props.spec.type === "string" && ( + <div> + This field is case-sensitive. You must enter exactly the same value + during recovery. + </div> + )} + {props.spec.name === "full_name" && ( + <div> + If possible, use "LASTNAME, Firstname(s)" without + abbreviations. + </div> + )} + <div class="block"> + This stays private + <span class="icon is-right"> + <i class="mdi mdi-eye-off" /> + </span> + </div> </div> ); } +const YEAR_REGEX = /^[0-9]+-[0-9]+-[0-9]+$/; + +function checkIfValid( + value: string, + spec: UserAttributeSpec, +): string | undefined { + const pattern = spec["validation-regex"]; + if (pattern) { + const re = new RegExp(pattern); + if (!re.test(value)) return "The value is invalid"; + } + const logic = spec["validation-logic"]; + if (logic) { + const func = (validators as any)[logic]; + if (func && typeof func === "function" && !func(value)) + return "Please check the value"; + } + const optional = spec.optional; + if (!optional && !value) { + return "This value is required"; + } + if ("date" === spec.type) { + if (!YEAR_REGEX.test(value)) { + return "The date doesn't follow the format"; + } + + try { + const v = parse(value, "yyyy-MM-dd", new Date()); + if (Number.isNaN(v.getTime())) { + return "Some numeric values seems out of range for a date"; + } + if ("birthdate" === spec.name && isAfter(v, new Date())) { + return "A birthdate cannot be in the future"; + } + } catch (e) { + return "Could not parse the date"; + } + } + return undefined; +} |