some fixes and validations

This commit is contained in:
Sebastian 2023-03-13 11:12:46 -03:00
parent 5f681813cf
commit 96d110379e
No known key found for this signature in database
GPG Key ID: 173909D1A5F66069
9 changed files with 154 additions and 50 deletions

View File

@ -24,8 +24,7 @@ import { InputProps, useField } from "./useField.js";
interface Props<T> extends InputProps<T> { interface Props<T> extends InputProps<T> {
readonly?: boolean; readonly?: boolean;
expand?: boolean; expand?: boolean;
values: string[]; values: any[];
convert?: (v: string) => any;
toStr?: (v?: any) => string; toStr?: (v?: any) => string;
fromStr?: (s: string) => any; fromStr?: (s: string) => any;
} }
@ -42,11 +41,11 @@ export function InputSelector<T>({
label, label,
help, help,
values, values,
convert, fromStr = defaultFromString,
toStr = defaultToString, toStr = defaultToString,
}: Props<keyof T>): VNode { }: Props<keyof T>): VNode {
const { error, value, onChange } = useField<T>(name); const { error, value, onChange } = useField<T>(name);
console.log(error);
return ( return (
<div class="field is-horizontal"> <div class="field is-horizontal">
<div class="field-label is-normal"> <div class="field-label is-normal">
@ -68,18 +67,17 @@ export function InputSelector<T>({
disabled={readonly} disabled={readonly}
readonly={readonly} readonly={readonly}
onChange={(e) => { onChange={(e) => {
const v = convert onChange(fromStr(e.currentTarget.value));
? convert(e.currentTarget.value)
: e.currentTarget.value;
onChange(v);
}} }}
> >
{placeholder && <option>{placeholder}</option>} {placeholder && <option>{placeholder}</option>}
{values.map((v, i) => ( {values.map((v, i) => {
return (
<option key={i} value={v} selected={value === v}> <option key={i} value={v} selected={value === v}>
{toStr(v)} {toStr(v)}
</option> </option>
))} );
})}
</select> </select>
{help} {help}
</p> </p>

View File

@ -96,7 +96,6 @@ export function InputWithAddon<T>({
<i class="mdi mdi-alert" /> <i class="mdi mdi-alert" />
</span> </span>
)} )}
{help}
{children} {children}
</p> </p>
{addonAfter && ( {addonAfter && (
@ -106,6 +105,7 @@ export function InputWithAddon<T>({
)} )}
</div> </div>
{error && <p class="help is-danger">{error}</p>} {error && <p class="help is-danger">{error}</p>}
<span class="has-text-grey">{help}</span>
</div> </div>
{side} {side}
</div> </div>

View File

@ -20,6 +20,7 @@
*/ */
import { ComponentChildren, VNode } from "preact"; import { ComponentChildren, VNode } from "preact";
import { useState } from "preact/hooks";
import { useFormContext } from "./FormProvider.js"; import { useFormContext } from "./FormProvider.js";
interface Use<V> { interface Use<V> {
@ -37,10 +38,11 @@ export function useField<T>(name: keyof T): Use<T[typeof name]> {
useFormContext<T>(); useFormContext<T>();
type P = typeof name; type P = typeof name;
type V = T[P]; type V = T[P];
const [isDirty, setDirty] = useState(false);
const updateField = const updateField =
(field: P) => (field: P) =>
(value: V): void => { (value: V): void => {
setDirty(true);
return valueHandler((prev) => { return valueHandler((prev) => {
return setValueDeeper(prev, String(field).split("."), value); return setValueDeeper(prev, String(field).split("."), value);
}); });
@ -50,7 +52,6 @@ export function useField<T>(name: keyof T): Use<T[typeof name]> {
const defaultFromString = (v: string): V => v as any; const defaultFromString = (v: string): V => v as any;
const value = readField(object, String(name)); const value = readField(object, String(name));
const initial = readField(initialObject, String(name)); const initial = readField(initialObject, String(name));
const isDirty = value !== initial;
const hasError = readField(errors, String(name)); const hasError = readField(errors, String(name));
return { return {
error: isDirty ? hasError : undefined, error: isDirty ? hasError : undefined,

View File

@ -144,12 +144,18 @@ export function CreatePage({
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const parsedPrice = !value.pricing?.order_price
? undefined
: Amounts.parse(value.pricing.order_price);
const errors: FormErrors<Entity> = { const errors: FormErrors<Entity> = {
pricing: undefinedIfEmpty({ pricing: undefinedIfEmpty({
summary: !value.pricing?.summary ? i18n.str`required` : undefined, summary: !value.pricing?.summary ? i18n.str`required` : undefined,
order_price: !value.pricing?.order_price order_price: !value.pricing?.order_price
? i18n.str`required` ? i18n.str`required`
: Amounts.isZero(value.pricing.order_price) : !parsedPrice
? i18n.str`not valid`
: Amounts.isZero(parsedPrice)
? i18n.str`must be greater than 0` ? i18n.str`must be greater than 0`
: undefined, : undefined,
}), }),
@ -333,8 +339,8 @@ export function CreatePage({
}, [hasProducts, totalAsString]); }, [hasProducts, totalAsString]);
const discountOrRise = rate( const discountOrRise = rate(
value.pricing?.order_price || `${config.currency}:0`, parsedPrice ?? Amounts.zeroOfCurrency(config.currency),
totalAsString, totalPrice.amount,
); );
const minAgeByProducts = allProducts.reduce( const minAgeByProducts = allProducts.reduce(

View File

@ -19,6 +19,10 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import {
Amounts,
MerchantTemplateContractDetails,
} from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { h, VNode } from "preact"; import { h, VNode } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
@ -35,7 +39,10 @@ import { InputSelector } from "../../../../components/form/InputSelector.js";
import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { randomBase32Key } from "../../../../utils/crypto.js"; import {
isBase32RFC3548Charset,
randomBase32Key,
} from "../../../../utils/crypto.js";
import { undefinedIfEmpty } from "../../../../utils/table.js"; import { undefinedIfEmpty } from "../../../../utils/table.js";
type Entity = MerchantBackend.Template.TemplateAddDetails; type Entity = MerchantBackend.Template.TemplateAddDetails;
@ -45,17 +52,14 @@ interface Props {
onBack?: () => void; onBack?: () => void;
} }
const algorithms = ["0", "1", "2"]; const algorithms = [0, 1, 2];
const algorithmsNames = [ const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"];
"off",
"30s 8d TOTP-SHA1 without amount",
"30s 8d eTOTP-SHA1 with amount",
];
export function CreatePage({ onCreate, onBack }: Props): VNode { export function CreatePage({ onCreate, onBack }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext(); const backend = useBackendContext();
const [showKey, setShowKey] = useState(false);
const [state, setState] = useState<Partial<Entity>>({ const [state, setState] = useState<Partial<Entity>>({
template_contract: { template_contract: {
minimum_age: 0, minimum_age: 0,
@ -65,6 +69,10 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
}, },
}); });
const parsedPrice = !state.template_contract?.amount
? undefined
: Amounts.parse(state.template_contract?.amount);
const errors: FormErrors<Entity> = { const errors: FormErrors<Entity> = {
template_id: !state.template_id ? i18n.str`should not be empty` : undefined, template_id: !state.template_id ? i18n.str`should not be empty` : undefined,
template_description: !state.template_description template_description: !state.template_description
@ -73,6 +81,13 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
template_contract: !state.template_contract template_contract: !state.template_contract
? undefined ? undefined
: undefinedIfEmpty({ : undefinedIfEmpty({
amount: !state.template_contract?.amount
? undefined
: !parsedPrice
? i18n.str`not valid`
: Amounts.isZero(parsedPrice)
? i18n.str`must be greater than 0`
: undefined,
minimum_age: minimum_age:
state.template_contract.minimum_age < 0 state.template_contract.minimum_age < 0
? i18n.str`should be greater that 0` ? i18n.str`should be greater that 0`
@ -84,7 +99,16 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
: state.template_contract.pay_duration.d_us < 1000 * 1000 //less than one second : state.template_contract.pay_duration.d_us < 1000 * 1000 //less than one second
? i18n.str`to short` ? i18n.str`to short`
: undefined, : undefined,
}), } as Partial<MerchantTemplateContractDetails>),
pos_key: !state.pos_key
? !state.pos_algorithm
? undefined
: i18n.str`required`
: !isBase32RFC3548Charset(state.pos_key)
? i18n.str`just letters and numbers from 2 to 7`
: state.pos_key.length !== 32
? i18n.str`size of the key should be 32`
: undefined,
}; };
const hasErrors = Object.keys(errors).some( const hasErrors = Object.keys(errors).some(
@ -144,21 +168,32 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
/> />
<InputSelector<Entity> <InputSelector<Entity>
name="pos_algorithm" name="pos_algorithm"
label={i18n.str`Veritifaction algorithm`} label={i18n.str`Verification algorithm`}
tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`} tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`}
values={algorithms} values={algorithms}
toStr={(v) => algorithmsNames[v]} toStr={(v) => algorithmsNames[v]}
convert={(v) => Number(v)} fromStr={(v) => Number(v)}
/> />
{state.pos_algorithm && state.pos_algorithm > 0 ? ( {state.pos_algorithm && state.pos_algorithm > 0 ? (
<Input<Entity> <InputWithAddon<Entity>
name="pos_key" name="pos_key"
label={i18n.str`Point-of-sale key`} label={i18n.str`Point-of-sale key`}
help="" help="Be sure to be very hard to guess or use the random generator"
tooltip={i18n.str`Useful to validate the purchase`} tooltip={i18n.str`Useful to validate the purchase`}
fromStr={(v) => v.toUpperCase()}
addonAfter={
<span class="icon">
{showKey ? (
<i class="mdi mdi-eye" />
) : (
<i class="mdi mdi-eye-off" />
)}
</span>
}
side={ side={
<span data-tooltip={i18n.str`generate random secret key`}> <span style={{ display: "flex" }}>
<button <button
data-tooltip={i18n.str`generate random secret key`}
class="button is-info mr-3" class="button is-info mr-3"
onClick={(e) => { onClick={(e) => {
const pos_key = randomBase32Key(); const pos_key = randomBase32Key();
@ -167,6 +202,23 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
> >
<i18n.Translate>random</i18n.Translate> <i18n.Translate>random</i18n.Translate>
</button> </button>
<button
data-tooltip={
showKey
? i18n.str`show secret key`
: i18n.str`hide secret key`
}
class="button is-info mr-3"
onClick={(e) => {
setShowKey(!showKey);
}}
>
{showKey ? (
<i18n.Translate>hide</i18n.Translate>
) : (
<i18n.Translate>show</i18n.Translate>
)}
</button>
</span> </span>
} }
/> />

View File

@ -127,6 +127,15 @@ export function QrPage({ template, id: templateId, onBack }: Props): VNode {
<div class="columns"> <div class="columns">
<div class="column" /> <div class="column" />
<div class="column is-four-fifths"> <div class="column is-four-fifths">
<p class="is-size-5 mt-5 mb-5">
<i18n.Translate>
Here you can specify a default value for fields that are not
fixed. Default values can be edited by the customer before the
payment.
</i18n.Translate>
</p>
<p></p>
<FormProvider <FormProvider
object={state} object={state}
valueHandler={setState} valueHandler={setState}
@ -134,7 +143,11 @@ export function QrPage({ template, id: templateId, onBack }: Props): VNode {
> >
<InputCurrency<Entity> <InputCurrency<Entity>
name="amount" name="amount"
label={i18n.str`Amount`} label={
fixedAmount
? i18n.str`Fixed amount`
: i18n.str`Default amount`
}
readonly={fixedAmount} readonly={fixedAmount}
tooltip={i18n.str`Amount of the order`} tooltip={i18n.str`Amount of the order`}
/> />
@ -142,7 +155,11 @@ export function QrPage({ template, id: templateId, onBack }: Props): VNode {
name="summary" name="summary"
inputType="multiline" inputType="multiline"
readonly={fixedSummary} readonly={fixedSummary}
label={i18n.str`Order summary`} label={
fixedSummary
? i18n.str`Fixed summary`
: i18n.str`Default summary`
}
tooltip={i18n.str`Title of the order to be shown to the customer`} tooltip={i18n.str`Title of the order to be shown to the customer`}
/> />
</FormProvider> </FormProvider>

View File

@ -19,6 +19,10 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import {
Amounts,
MerchantTemplateContractDetails,
} from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser"; import { useTranslationContext } from "@gnu-taler/web-util/lib/index.browser";
import { h, VNode } from "preact"; import { h, VNode } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
@ -35,7 +39,10 @@ import { InputSelector } from "../../../../components/form/InputSelector.js";
import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { MerchantBackend, WithId } from "../../../../declaration.js"; import { MerchantBackend, WithId } from "../../../../declaration.js";
import { randomBase32Key } from "../../../../utils/crypto.js"; import {
isBase32RFC3548Charset,
randomBase32Key,
} from "../../../../utils/crypto.js";
import { undefinedIfEmpty } from "../../../../utils/table.js"; import { undefinedIfEmpty } from "../../../../utils/table.js";
type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId; type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId;
@ -46,12 +53,8 @@ interface Props {
template: Entity; template: Entity;
} }
const algorithms = ["0", "1", "2"]; const algorithms = [0, 1, 2];
const algorithmsNames = [ const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"];
"off",
"30s 8d TOTP-SHA1 without amount",
"30s 8d eTOTP-SHA1 with amount",
];
export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
@ -60,6 +63,10 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [state, setState] = useState<Partial<Entity>>(template); const [state, setState] = useState<Partial<Entity>>(template);
const parsedPrice = !state.template_contract?.amount
? undefined
: Amounts.parse(state.template_contract?.amount);
const errors: FormErrors<Entity> = { const errors: FormErrors<Entity> = {
template_description: !state.template_description template_description: !state.template_description
? i18n.str`should not be empty` ? i18n.str`should not be empty`
@ -67,6 +74,13 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
template_contract: !state.template_contract template_contract: !state.template_contract
? undefined ? undefined
: undefinedIfEmpty({ : undefinedIfEmpty({
amount: !state.template_contract?.amount
? undefined
: !parsedPrice
? i18n.str`not valid`
: Amounts.isZero(parsedPrice)
? i18n.str`must be greater than 0`
: undefined,
minimum_age: minimum_age:
state.template_contract.minimum_age < 0 state.template_contract.minimum_age < 0
? i18n.str`should be greater that 0` ? i18n.str`should be greater that 0`
@ -78,7 +92,16 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
: state.template_contract.pay_duration.d_us < 1000 * 1000 // less than one second : state.template_contract.pay_duration.d_us < 1000 * 1000 // less than one second
? i18n.str`to short` ? i18n.str`to short`
: undefined, : undefined,
}), } as Partial<MerchantTemplateContractDetails>),
pos_key: !state.pos_key
? !state.pos_algorithm
? undefined
: i18n.str`required`
: !isBase32RFC3548Charset(state.pos_key)
? i18n.str`just letters and numbers from 2 to 7`
: state.pos_key.length !== 32
? i18n.str`size of the key should be 32`
: undefined,
}; };
const hasErrors = Object.keys(errors).some( const hasErrors = Object.keys(errors).some(
@ -155,20 +178,21 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
/> />
<InputSelector<Entity> <InputSelector<Entity>
name="pos_algorithm" name="pos_algorithm"
label={i18n.str`Veritifaction algorithm`} label={i18n.str`Verification algorithm`}
tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`} tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`}
values={algorithms} values={algorithms}
toStr={(v) => algorithmsNames[v]} toStr={(v) => algorithmsNames[v]}
convert={(v) => Number(v)} fromStr={(v) => Number(v)}
/> />
{state.pos_algorithm && state.pos_algorithm > 0 ? ( {state.pos_algorithm && state.pos_algorithm > 0 ? (
<InputWithAddon<Entity> <InputWithAddon<Entity>
name="pos_key" name="pos_key"
label={i18n.str`Point-of-sale key`} label={i18n.str`Point-of-sale key`}
inputType={showKey ? "text" : "password"} inputType={showKey ? "text" : "password"}
help="" help="Be sure to be very hard to guess or use the random generator"
expand expand
tooltip={i18n.str`Useful to validate the purchase`} tooltip={i18n.str`Useful to validate the purchase`}
fromStr={(v) => v.toUpperCase()}
addonAfter={ addonAfter={
<span class="icon"> <span class="icon">
{showKey ? ( {showKey ? (
@ -179,7 +203,7 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
</span> </span>
} }
side={ side={
<span> <span style={{ display: "flex" }}>
<button <button
data-tooltip={i18n.str`generate random secret key`} data-tooltip={i18n.str`generate random secret key`}
class="button is-info mr-3" class="button is-info mr-3"

View File

@ -59,14 +59,12 @@ export function mergeRefunds(
return prev; return prev;
} }
export const rate = (one: string, two: string): number => { export function rate(a: AmountJson, b: AmountJson): number {
const a = Amounts.parseOrThrow(one);
const b = Amounts.parseOrThrow(two);
const af = toFloat(a); const af = toFloat(a);
const bf = toFloat(b); const bf = toFloat(b);
if (bf === 0) return 0; if (bf === 0) return 0;
return af / bf; return af / bf;
}; }
function toFloat(amount: AmountJson): number { function toFloat(amount: AmountJson): number {
return amount.value + amount.fraction / amountFractionalBase; return amount.value + amount.fraction / amountFractionalBase;

View File

@ -46,6 +46,14 @@ function encodeBase32(data: ArrayBuffer) {
return sb; return sb;
} }
export function isBase32RFC3548Charset(s: string): boolean {
for (let idx = 0; idx < s.length; idx++) {
const c = s.charAt(idx);
if (encTable.indexOf(c) === -1) return false;
}
return true;
}
export function randomBase32Key(): string { export function randomBase32Key(): string {
var buf = new Uint8Array(20); var buf = new Uint8Array(20);
window.crypto.getRandomValues(buf); window.crypto.getRandomValues(buf);