diff options
Diffstat (limited to 'packages/merchant-backoffice-ui/src/paths')
63 files changed, 3196 insertions, 739 deletions
diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx index 14e2fcb46..a8108251d 100644 --- a/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx @@ -19,7 +19,6 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { Amounts } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -29,9 +28,8 @@ import { FormProvider, } from "../../../components/form/FormProvider.js"; import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js"; -import { SetTokenNewInstanceModal } from "../../../components/modal/index.js"; import { MerchantBackend } from "../../../declaration.js"; -import { INSTANCE_ID_REGEX, PAYTO_REGEX } from "../../../utils/constants.js"; +import { INSTANCE_ID_REGEX } from "../../../utils/constants.js"; import { undefinedIfEmpty } from "../../../utils/table.js"; export type Entity = MerchantBackend.Instances.InstanceConfigurationMessage & { @@ -47,19 +45,19 @@ interface Props { function with_defaults(id?: string): Partial<Entity> { return { id, - accounts: [], + // accounts: [], user_type: "business", + use_stefan: false, default_pay_delay: { d_us: 2 * 1000 * 60 * 60 * 1000 }, // two hours - default_wire_fee_amortization: 1, default_wire_transfer_delay: { d_us: 1000 * 2 * 60 * 60 * 24 * 1000 }, // two days }; } export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { const [value, valueHandler] = useState(with_defaults(forceId)); - const [isTokenSet, updateIsTokenSet] = useState<boolean>(false); - const [isTokenDialogActive, updateIsTokenDialogActive] = - useState<boolean>(false); + // const [isTokenSet, updateIsTokenSet] = useState<boolean>(false); + // const [isTokenDialogActive, updateIsTokenDialogActive] = + // useState<boolean>(false); const { i18n } = useTranslationContext(); @@ -67,42 +65,24 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { id: !value.id ? i18n.str`required` : !INSTANCE_ID_REGEX.test(value.id) - ? i18n.str`is not valid` - : undefined, + ? i18n.str`is not valid` + : undefined, name: !value.name ? i18n.str`required` : undefined, user_type: !value.user_type ? i18n.str`required` : value.user_type !== "business" && value.user_type !== "individual" - ? i18n.str`should be business or individual` - : undefined, - accounts: - !value.accounts || !value.accounts.length - ? i18n.str`required` - : undefinedIfEmpty( - value.accounts.map((p) => { - return !PAYTO_REGEX.test(p.payto_uri) - ? i18n.str`is not valid` - : undefined; - }), - ), - default_max_deposit_fee: !value.default_max_deposit_fee - ? i18n.str`required` - : !Amounts.parse(value.default_max_deposit_fee) - ? i18n.str`invalid format` - : undefined, - default_max_wire_fee: !value.default_max_wire_fee - ? i18n.str`required` - : !Amounts.parse(value.default_max_wire_fee) - ? i18n.str`invalid format` - : undefined, - default_wire_fee_amortization: - value.default_wire_fee_amortization === undefined - ? i18n.str`required` - : isNaN(value.default_wire_fee_amortization) - ? i18n.str`is not a number` - : value.default_wire_fee_amortization < 1 - ? i18n.str`must be 1 or greater` + ? i18n.str`should be business or individual` : undefined, + // accounts: + // !value.accounts || !value.accounts.length + // ? i18n.str`required` + // : undefinedIfEmpty( + // value.accounts.map((p) => { + // return !PAYTO_REGEX.test(p.payto_uri) + // ? i18n.str`is not valid` + // : undefined; + // }), + // ), default_pay_delay: !value.default_pay_delay ? i18n.str`required` : undefined, @@ -129,12 +109,12 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { const submit = (): Promise<void> => { // use conversion instead of this - const newToken = value.auth_token; - value.auth_token = undefined; - value.auth = - newToken === null || newToken === undefined - ? { method: "external" } - : { method: "token", token: `secret-token:${newToken}` }; + // const newToken = value.auth_token; + // value.auth_token = undefined; + value.auth = { method: "external" } + // newToken === null || newToken === undefined + // ? { method: "external" } + // : { method: "token", token: `secret-token:${newToken}` }; if (!value.address) value.address = {}; if (!value.jurisdiction) value.jurisdiction = {}; // remove above use conversion @@ -142,16 +122,16 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { return onCreate(value as Entity); }; - function updateToken(token: string | null) { - valueHandler((old) => ({ - ...old, - auth_token: token === null ? undefined : token, - })); - } + // function updateToken(token: string | null) { + // valueHandler((old) => ({ + // ...old, + // auth_token: token === null ? undefined : token, + // })); + // } return ( <div> - <div class="columns"> + {/* <div class="columns"> <div class="column" /> <div class="column is-four-fifths"> {isTokenDialogActive && ( @@ -174,9 +154,9 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { )} </div> <div class="column" /> - </div> + </div> */} - <section class="hero is-hero-bar"> + {/* <section class="hero is-hero-bar"> <div class="hero-body"> <div class="level"> <div class="level-item has-text-centered"> @@ -186,8 +166,8 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { !isTokenSet ? "button is-danger has-tooltip-bottom" : !value.auth_token - ? "button has-tooltip-bottom" - : "button is-info has-tooltip-bottom" + ? "button has-tooltip-bottom" + : "button is-info has-tooltip-bottom" } data-tooltip={i18n.str`change authorization configuration`} onClick={() => updateIsTokenDialogActive(true)} @@ -228,7 +208,7 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { </div> </div> </div> - </section> + </section> */} <section class="section is-main-section"> <div class="columns"> @@ -250,7 +230,7 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { )} <AsyncButton onClick={submit} - disabled={!isTokenSet || hasErrors} + disabled={hasErrors} data-tooltip={ hasErrors ? i18n.str`Need to complete marked fields and choose authorization method` diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx new file mode 100644 index 000000000..3336c53a4 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/Create.stories.tsx @@ -0,0 +1,28 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { h, VNode, FunctionalComponent } from "preact"; +import { CreatePage as TestedComponent } from "./CreatePage.js"; + +export default { + title: "Pages/Accounts/Create", + component: TestedComponent, +}; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx new file mode 100644 index 000000000..3ac510f63 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/CreatePage.tsx @@ -0,0 +1,175 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; +import { + FormErrors, + FormProvider, +} from "../../../../components/form/FormProvider.js"; +import { Input } from "../../../../components/form/Input.js"; +import { useBackendContext } from "../../../../context/backend.js"; +import { MerchantBackend } from "../../../../declaration.js"; +import { InputPaytoForm } from "../../../../components/form/InputPaytoForm.js"; +import { parsePayUri, stringifyPaytoUri } from "@gnu-taler/taler-util"; +import { undefinedIfEmpty } from "../../../../utils/table.js"; +import { InputSelector } from "../../../../components/form/InputSelector.js"; + +type Entity = MerchantBackend.BankAccounts.AccountAddDetails & { repeatPassword: string }; + +interface Props { + onCreate: (d: Entity) => Promise<void>; + onBack?: () => void; +} + +const accountAuthType = ["none", "basic"]; + +function isValidURL(s: string): boolean { + try { + const u = new URL(s) + return true; + } catch (e) { + return false; + } +} + +export function CreatePage({ onCreate, onBack }: Props): VNode { + const { i18n } = useTranslationContext(); + + const [state, setState] = useState<Partial<Entity>>({}); + const errors: FormErrors<Entity> = { + payto_uri: !state.payto_uri ? i18n.str`required` : undefined, + + credit_facade_credentials: !state.credit_facade_credentials + ? undefined + : undefinedIfEmpty({ + username: + state.credit_facade_credentials.type === "basic" && !state.credit_facade_credentials.username + ? i18n.str`required` + : undefined, + password: + state.credit_facade_credentials.type === "basic" && !state.credit_facade_credentials.password + ? i18n.str`required` + : undefined, + }), + credit_facade_url: !state.credit_facade_url + ? undefined + : !isValidURL(state.credit_facade_url) ? i18n.str`not valid url` + : undefined, + repeatPassword: + !state.credit_facade_credentials + ? undefined + : state.credit_facade_credentials.type === "basic" && (!state.credit_facade_credentials.password || state.credit_facade_credentials.password !== state.repeatPassword) + ? i18n.str`is not the same` + : undefined, + }; + + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const submitForm = () => { + if (hasErrors) return Promise.reject(); + delete state.repeatPassword + return onCreate(state as any); + }; + + return ( + <div> + <section class="section is-main-section"> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <FormProvider + object={state} + valueHandler={setState} + errors={errors} + > + <InputPaytoForm<Entity> + name="payto_uri" + label={i18n.str`Account`} + /> + <Input<Entity> + name="credit_facade_url" + label={i18n.str`Account info URL`} + help="https://bank.com" + expand + tooltip={i18n.str`From where the merchant can download information about incoming wire transfers to this account`} + /> + <InputSelector + name="credit_facade_credentials.type" + label={i18n.str`Auth type`} + tooltip={i18n.str`Choose the authentication type for the account info URL`} + values={accountAuthType} + toStr={(str) => { + if (str === "none") return "Without authentication"; + return "Username and password"; + }} + /> + {state.credit_facade_credentials?.type === "basic" ? ( + <Fragment> + <Input + name="credit_facade_credentials.username" + label={i18n.str`Username`} + tooltip={i18n.str`Username to access the account information.`} + /> + <Input + name="credit_facade_credentials.password" + inputType="password" + label={i18n.str`Password`} + tooltip={i18n.str`Password to access the account information.`} + /> + <Input + name="repeatPassword" + inputType="password" + label={i18n.str`Repeat password`} + /> + </Fragment> + ) : undefined} + </FormProvider> + + <div class="buttons is-right mt-5"> + {onBack && ( + <button class="button" onClick={onBack}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + )} + <AsyncButton + disabled={hasErrors} + data-tooltip={ + hasErrors + ? i18n.str`Need to complete marked fields` + : "confirm operation" + } + onClick={submitForm} + > + <i18n.Translate>Confirm</i18n.Translate> + </AsyncButton> + </div> + </div> + <div class="column" /> + </div> + </section> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx new file mode 100644 index 000000000..7d33d25ce --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx @@ -0,0 +1,65 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { MerchantBackend } from "../../../../declaration.js"; +import { useWebhookAPI } from "../../../../hooks/webhooks.js"; +import { Notification } from "../../../../utils/types.js"; +import { CreatePage } from "./CreatePage.js"; +import { useOtpDeviceAPI } from "../../../../hooks/otp.js"; +import { useBankAccountAPI } from "../../../../hooks/bank.js"; + +export type Entity = MerchantBackend.BankAccounts.AccountAddDetails; +interface Props { + onBack?: () => void; + onConfirm: () => void; +} + +export default function CreateValidator({ onConfirm, onBack }: Props): VNode { + const { createBankAccount } = useBankAccountAPI(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { i18n } = useTranslationContext(); + + return ( + <> + <NotificationCard notification={notif} /> + <CreatePage + onBack={onBack} + onCreate={(request: Entity) => { + return createBankAccount(request) + .then((d) => { + onConfirm() + }) + .catch((error) => { + setNotif({ + message: i18n.str`could not create device`, + type: "ERROR", + description: error.message, + }); + }); + }} + /> + </> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx new file mode 100644 index 000000000..6b4b63735 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/List.stories.tsx @@ -0,0 +1,28 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { FunctionalComponent, h } from "preact"; +import { ListPage as TestedComponent } from "./ListPage.js"; + +export default { + title: "Pages/Accounts/List", + component: TestedComponent, +}; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx new file mode 100644 index 000000000..24da755b9 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/ListPage.tsx @@ -0,0 +1,64 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { h, VNode } from "preact"; +import { MerchantBackend } from "../../../../declaration.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { CardTable } from "./Table.js"; + +export interface Props { + devices: MerchantBackend.BankAccounts.BankAccountEntry[]; + onLoadMoreBefore?: () => void; + onLoadMoreAfter?: () => void; + onCreate: () => void; + onDelete: (e: MerchantBackend.BankAccounts.BankAccountEntry) => void; + onSelect: (e: MerchantBackend.BankAccounts.BankAccountEntry) => void; +} + +export function ListPage({ + devices, + onCreate, + onDelete, + onSelect, + onLoadMoreBefore, + onLoadMoreAfter, +}: Props): VNode { + const form = { payto_uri: "" }; + + const { i18n } = useTranslationContext(); + return ( + <section class="section is-main-section"> + <CardTable + accounts={devices.map((o) => ({ + ...o, + id: String(o.h_wire), + }))} + onCreate={onCreate} + onDelete={onDelete} + onSelect={onSelect} + onLoadMoreBefore={onLoadMoreBefore} + hasMoreBefore={!onLoadMoreBefore} + onLoadMoreAfter={onLoadMoreAfter} + hasMoreAfter={!onLoadMoreAfter} + /> + </section> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx new file mode 100644 index 000000000..7d6db0782 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/Table.tsx @@ -0,0 +1,385 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { StateUpdater, useState } from "preact/hooks"; +import { MerchantBackend } from "../../../../declaration.js"; +import { parsePaytoUri, PaytoType, PaytoUri, PaytoUriBitcoin, PaytoUriIBAN, PaytoUriTalerBank, PaytoUriUnknown } from "@gnu-taler/taler-util"; + +type Entity = MerchantBackend.BankAccounts.BankAccountEntry; + +interface Props { + accounts: Entity[]; + onDelete: (e: Entity) => void; + onSelect: (e: Entity) => void; + onCreate: () => void; + onLoadMoreBefore?: () => void; + hasMoreBefore?: boolean; + hasMoreAfter?: boolean; + onLoadMoreAfter?: () => void; +} + +export function CardTable({ + accounts, + onCreate, + onDelete, + onSelect, + onLoadMoreAfter, + onLoadMoreBefore, + hasMoreAfter, + hasMoreBefore, +}: Props): VNode { + const [rowSelection, rowSelectionHandler] = useState<string[]>([]); + + const { i18n } = useTranslationContext(); + + return ( + <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"> + <span class="icon"> + <i class="mdi mdi-newspaper" /> + </span> + <i18n.Translate>Bank accounts</i18n.Translate> + </p> + <div class="card-header-icon" aria-label="more options"> + <span + class="has-tooltip-left" + data-tooltip={i18n.str`add new accounts`} + > + <button class="button is-info" type="button" onClick={onCreate}> + <span class="icon is-small"> + <i class="mdi mdi-plus mdi-36px" /> + </span> + </button> + </span> + </div> + </header> + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + {accounts.length > 0 ? ( + <Table + accounts={accounts} + onDelete={onDelete} + onSelect={onSelect} + rowSelection={rowSelection} + rowSelectionHandler={rowSelectionHandler} + onLoadMoreAfter={onLoadMoreAfter} + onLoadMoreBefore={onLoadMoreBefore} + hasMoreAfter={hasMoreAfter} + hasMoreBefore={hasMoreBefore} + /> + ) : ( + <EmptyTable /> + )} + </div> + </div> + </div> + </div> + ); +} +interface TableProps { + rowSelection: string[]; + accounts: Entity[]; + onDelete: (e: Entity) => void; + onSelect: (e: Entity) => void; + rowSelectionHandler: StateUpdater<string[]>; + onLoadMoreBefore?: () => void; + hasMoreBefore?: boolean; + hasMoreAfter?: boolean; + onLoadMoreAfter?: () => void; +} + +function toggleSelected<T>(id: T): (prev: T[]) => T[] { + return (prev: T[]): T[] => + prev.indexOf(id) == -1 ? [...prev, id] : prev.filter((e) => e != id); +} + +function Table({ + accounts, + onLoadMoreAfter, + onDelete, + onSelect, + onLoadMoreBefore, + hasMoreAfter, + hasMoreBefore, +}: TableProps): VNode { + const { i18n } = useTranslationContext(); + const emptyList: Record<PaytoType | "unknown", { parsed: PaytoUri, acc: Entity }[]> = { "bitcoin": [], "x-taler-bank": [], "iban": [], "unknown": [], } + const accountsByType = accounts.reduce((prev, acc) => { + const parsed = parsePaytoUri(acc.payto_uri) + if (!parsed) return prev //skip + if (parsed.targetType !== "bitcoin" && parsed.targetType !== "x-taler-bank" && parsed.targetType !== "iban") { + prev["unknown"].push({ parsed, acc }) + } else { + prev[parsed.targetType].push({ parsed, acc }) + } + return prev + }, emptyList) + + const bitcoinAccounts = accountsByType["bitcoin"] + const talerbankAccounts = accountsByType["x-taler-bank"] + const ibanAccounts = accountsByType["iban"] + const unkownAccounts = accountsByType["unknown"] + + + return ( + <Fragment> + + {bitcoinAccounts.length > 0 && <div class="table-container"> + <p class="card-header-title"><i18n.Translate>Bitcoin type accounts</i18n.Translate></p> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>Address</i18n.Translate> + </th> + <th> + <i18n.Translate>Sewgit 1</i18n.Translate> + </th> + <th> + <i18n.Translate>Sewgit 2</i18n.Translate> + </th> + <th /> + </tr> + </thead> + <tbody> + {bitcoinAccounts.map(({ parsed, acc }, idx) => { + const ac = parsed as PaytoUriBitcoin + return ( + <tr key={idx}> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.targetPath} + </td> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.segwitAddrs[0]} + </td> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.segwitAddrs[1]} + </td> + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + <button + class="button is-danger is-small has-tooltip-left" + data-tooltip={i18n.str`delete selected accounts from the database`} + onClick={() => onDelete(acc)} + > + Delete + </button> + </div> + </td> + </tr> + ); + })} + </tbody> + </table> + </div>} + + + + {talerbankAccounts.length > 0 && <div class="table-container"> + <p class="card-header-title"><i18n.Translate>Taler type accounts</i18n.Translate></p> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>Host</i18n.Translate> + </th> + <th> + <i18n.Translate>Account name</i18n.Translate> + </th> + <th /> + </tr> + </thead> + <tbody> + {talerbankAccounts.map(({ parsed, acc }, idx) => { + const ac = parsed as PaytoUriTalerBank + return ( + <tr key={idx}> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.host} + </td> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.account} + </td> + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + <button + class="button is-danger is-small has-tooltip-left" + data-tooltip={i18n.str`delete selected accounts from the database`} + onClick={() => onDelete(acc)} + > + Delete + </button> + </div> + </td> + </tr> + ); + })} + </tbody> + </table> + </div>} + + {ibanAccounts.length > 0 && <div class="table-container"> + <p class="card-header-title"><i18n.Translate>IBAN type accounts</i18n.Translate></p> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>Account name</i18n.Translate> + </th> + <th> + <i18n.Translate>IBAN</i18n.Translate> + </th> + <th> + <i18n.Translate>BIC</i18n.Translate> + </th> + <th /> + </tr> + </thead> + <tbody> + {ibanAccounts.map(({ parsed, acc }, idx) => { + const ac = parsed as PaytoUriIBAN + return ( + <tr key={idx}> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.params["receiver-name"]} + </td> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.iban} + </td> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.bic ?? ""} + </td> + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + <button + class="button is-danger is-small has-tooltip-left" + data-tooltip={i18n.str`delete selected accounts from the database`} + onClick={() => onDelete(acc)} + > + Delete + </button> + </div> + </td> + </tr> + ); + })} + </tbody> + </table> + </div>} + + {unkownAccounts.length > 0 && <div class="table-container"> + <p class="card-header-title"><i18n.Translate>Other type accounts</i18n.Translate></p> + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>Type</i18n.Translate> + </th> + <th> + <i18n.Translate>Path</i18n.Translate> + </th> + <th /> + </tr> + </thead> + <tbody> + {unkownAccounts.map(({ parsed, acc }, idx) => { + const ac = parsed as PaytoUriUnknown + return ( + <tr key={idx}> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.targetType} + </td> + <td + onClick={(): void => onSelect(acc)} + style={{ cursor: "pointer" }} + > + {ac.targetPath} + </td> + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + <button + class="button is-danger is-small has-tooltip-left" + data-tooltip={i18n.str`delete selected accounts from the database`} + onClick={() => onDelete(acc)} + > + Delete + </button> + </div> + </td> + </tr> + ); + })} + </tbody> + </table> + </div>} + </Fragment> + + ); +} + +function EmptyTable(): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="content has-text-grey has-text-centered"> + <p> + <span class="icon is-large"> + <i class="mdi mdi-emoticon-sad mdi-48px" /> + </span> + </p> + <p> + <i18n.Translate> + There is no accounts yet, add more pressing the + sign + </i18n.Translate> + </p> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx new file mode 100644 index 000000000..9788ce0ec --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/list/index.tsx @@ -0,0 +1,107 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { HttpStatusCode } from "@gnu-taler/taler-util"; +import { + ErrorType, + HttpError, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { Loading } from "../../../../components/exception/loading.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { MerchantBackend } from "../../../../declaration.js"; +import { useInstanceOtpDevices, useOtpDeviceAPI } from "../../../../hooks/otp.js"; +import { Notification } from "../../../../utils/types.js"; +import { ListPage } from "./ListPage.js"; +import { useBankAccountAPI, useInstanceBankAccounts } from "../../../../hooks/bank.js"; + +interface Props { + onUnauthorized: () => VNode; + onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; + onNotFound: () => VNode; + onCreate: () => void; + onSelect: (id: string) => void; +} + +export default function ListValidators({ + onUnauthorized, + onLoadError, + onCreate, + onSelect, + onNotFound, +}: Props): VNode { + const [position, setPosition] = useState<string | undefined>(undefined); + const { i18n } = useTranslationContext(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { deleteBankAccount } = useBankAccountAPI(); + const result = useInstanceBankAccounts({ position }, (id) => setPosition(id)); + + if (result.loading) return <Loading />; + if (!result.ok) { + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.Unauthorized + ) + return onUnauthorized(); + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.NotFound + ) + return onNotFound(); + return onLoadError(result); + } + + return ( + <Fragment> + <NotificationCard notification={notif} /> + + <ListPage + devices={result.data.accounts} + onLoadMoreBefore={ + result.isReachingStart ? result.loadMorePrev : undefined + } + onLoadMoreAfter={result.isReachingEnd ? result.loadMore : undefined} + onCreate={onCreate} + onSelect={(e) => { + onSelect(e.h_wire); + }} + onDelete={(e: MerchantBackend.BankAccounts.BankAccountEntry) => + deleteBankAccount(e.h_wire) + .then(() => + setNotif({ + message: i18n.str`bank account delete successfully`, + type: "SUCCESS", + }), + ) + .catch((error) => + setNotif({ + message: i18n.str`could not delete the bank account`, + type: "ERROR", + description: error.message, + }), + ) + } + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx new file mode 100644 index 000000000..fcb77b820 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/Update.stories.tsx @@ -0,0 +1,32 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { h, VNode, FunctionalComponent } from "preact"; +import { UpdatePage as TestedComponent } from "./UpdatePage.js"; + +export default { + title: "Pages/Validators/Update", + component: TestedComponent, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx new file mode 100644 index 000000000..802f593cf --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/UpdatePage.tsx @@ -0,0 +1,114 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; +import { + FormErrors, + FormProvider, +} from "../../../../components/form/FormProvider.js"; +import { Input } from "../../../../components/form/Input.js"; +import { MerchantBackend, WithId } from "../../../../declaration.js"; + +type Entity = MerchantBackend.BankAccounts.AccountPatchDetails & WithId; + +interface Props { + onUpdate: (d: Entity) => Promise<void>; + onBack?: () => void; + account: Entity; +} +export function UpdatePage({ account, onUpdate, onBack }: Props): VNode { + const { i18n } = useTranslationContext(); + + const [state, setState] = useState<Partial<Entity>>(account); + + const errors: FormErrors<Entity> = { + }; + + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const submitForm = () => { + if (hasErrors) return Promise.reject(); + return onUpdate(state as any); + }; + + return ( + <div> + <section class="section"> + <section class="hero is-hero-bar"> + <div class="hero-body"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <span class="is-size-4"> + Account: <b>{account.id}</b> + </span> + </div> + </div> + </div> + </div> + </section> + <hr /> + + <section class="section is-main-section"> + <div class="columns"> + <div class="column is-four-fifths"> + <FormProvider + object={state} + valueHandler={setState} + errors={errors} + > + <Input<Entity> + name="credit_facade_url" + label={i18n.str`Description`} + tooltip={i18n.str`dddd`} + /> + </FormProvider> + + <div class="buttons is-right mt-5"> + {onBack && ( + <button class="button" onClick={onBack}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + )} + <AsyncButton + disabled={hasErrors} + data-tooltip={ + hasErrors + ? i18n.str`Need to complete marked fields` + : "confirm operation" + } + onClick={submitForm} + > + <i18n.Translate>Confirm</i18n.Translate> + </AsyncButton> + </div> + </div> + </div> + </section> + </section> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx new file mode 100644 index 000000000..44dee7651 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx @@ -0,0 +1,96 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { HttpStatusCode } from "@gnu-taler/taler-util"; +import { + ErrorType, + HttpError, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { Loading } from "../../../../components/exception/loading.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { MerchantBackend, WithId } from "../../../../declaration.js"; +import { useBankAccountAPI, useBankAccountDetails } from "../../../../hooks/bank.js"; +import { Notification } from "../../../../utils/types.js"; +import { UpdatePage } from "./UpdatePage.js"; + +export type Entity = MerchantBackend.BankAccounts.AccountPatchDetails & WithId; + +interface Props { + onBack?: () => void; + onConfirm: () => void; + onUnauthorized: () => VNode; + onNotFound: () => VNode; + onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode; + bid: string; +} +export default function UpdateValidator({ + bid, + onConfirm, + onBack, + onUnauthorized, + onNotFound, + onLoadError, +}: Props): VNode { + const { updateBankAccount } = useBankAccountAPI(); + const result = useBankAccountDetails(bid); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + + const { i18n } = useTranslationContext(); + + if (result.loading) return <Loading />; + if (!result.ok) { + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.Unauthorized + ) + return onUnauthorized(); + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.NotFound + ) + return onNotFound(); + return onLoadError(result); + } + + return ( + <Fragment> + <NotificationCard notification={notif} /> + <UpdatePage + account={{ ...result.data, id: bid }} + onBack={onBack} + onUpdate={(data) => { + return updateBankAccount(bid, data) + .then(onConfirm) + .catch((error) => { + setNotif({ + message: i18n.str`could not update account`, + type: "ERROR", + description: error.message, + }); + }); + }} + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/details/DetailPage.tsx index e5937ab7b..21dadb1e3 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/details/DetailPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/details/DetailPage.tsx @@ -36,14 +36,13 @@ interface Props { function convert( from: MerchantBackend.Instances.QueryInstancesResponse, ): Entity { - const { accounts: allAccounts, ...rest } = from; - const accounts = allAccounts.filter((a) => a.active); const defaults = { default_wire_fee_amortization: 1, + use_stefan: true, default_pay_delay: { d_us: 1000 * 60 * 60 * 1000 }, //one hour default_wire_transfer_delay: { d_us: 1000 * 60 * 60 * 2 * 1000 }, //two hours }; - return { ...defaults, ...rest, accounts }; + return { ...defaults, ...from }; } export function DetailPage({ selected }: Props): VNode { @@ -74,11 +73,6 @@ export function DetailPage({ selected }: Props): VNode { <div class="column is-6"> <FormProvider<Entity> object={value} valueHandler={valueHandler}> <Input<Entity> name="name" readonly label={i18n.str`Name`} /> - <Input<Entity> - name="accounts" - readonly - label={i18n.str`Account address`} - /> </FormProvider> </div> <div class="column" /> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx index 3a6e0fbfe..367fabce2 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/details/stories.tsx @@ -51,17 +51,15 @@ function createExample<Props>( export const Example = createExample(TestedComponent, { selected: { - accounts: [], name: "name", auth: { method: "external" }, address: {}, + user_type: "business", jurisdiction: {}, - default_max_deposit_fee: "TESTKUDOS:2", - default_max_wire_fee: "TESTKUDOS:1", + use_stefan: true, default_pay_delay: { d_us: 1000 * 1000, //one second }, - default_wire_fee_amortization: 1, default_wire_transfer_delay: { d_us: 1000 * 1000, //one second }, diff --git a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx index 6f50ac830..d33f64ada 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.stories.tsx @@ -54,5 +54,5 @@ export const Example = tests.createExample(TestedComponent, { payto_uri: "payto://iban/de123123123", }, ], - } as MerchantBackend.Instances.AccountKycRedirects, + } as MerchantBackend.KYC.AccountKycRedirects, }); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx index 67005d3cc..338081886 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/ListPage.tsx @@ -24,7 +24,7 @@ import { h, VNode } from "preact"; import { MerchantBackend } from "../../../../declaration.js"; export interface Props { - status: MerchantBackend.Instances.AccountKycRedirects; + status: MerchantBackend.KYC.AccountKycRedirects; } export function ListPage({ status }: Props): VNode { @@ -85,11 +85,11 @@ export function ListPage({ status }: Props): VNode { ); } interface PendingTableProps { - entries: MerchantBackend.Instances.MerchantAccountKycRedirect[]; + entries: MerchantBackend.KYC.MerchantAccountKycRedirect[]; } interface TimedOutTableProps { - entries: MerchantBackend.Instances.ExchangeKycTimeout[]; + entries: MerchantBackend.KYC.ExchangeKycTimeout[]; } function PendingTable({ entries }: PendingTableProps): VNode { diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx index fcf611c3c..bd9f65718 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/Create.stories.tsx @@ -42,12 +42,13 @@ function createExample<Props>( export const Example = createExample(TestedComponent, { instanceConfig: { - default_max_deposit_fee: "", - default_max_wire_fee: "", default_pay_delay: { d_us: 1000 * 1000 * 60 * 60, //one hour }, - default_wire_fee_amortization: 1, + default_wire_transfer_delay: { + d_us: 1000 * 1000 * 60 * 60, //one hour + }, + use_stefan: true, }, instanceInventory: [ { diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx index fa9347c6e..ea2cf849a 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/CreatePage.tsx @@ -44,6 +44,7 @@ import { OrderCreateSchema as schema } from "../../../../schemas/index.js"; import { rate } from "../../../../utils/amount.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; import { useSettings } from "../../../../hooks/useSettings.js"; +import { InputToggle } from "../../../../components/form/InputToggle.js"; interface Props { onCreate: (d: MerchantBackend.Orders.PostOrderRequest) => void; @@ -52,34 +53,38 @@ interface Props { instanceInventory: (MerchantBackend.Products.ProductDetail & WithId)[]; } interface InstanceConfig { - default_max_wire_fee: string; - default_max_deposit_fee: string; - default_wire_fee_amortization: number; + use_stefan: boolean; default_pay_delay: Duration; + default_wire_transfer_delay: Duration; } -function with_defaults(config: InstanceConfig): Partial<Entity> { +function with_defaults(config: InstanceConfig, currency: string): Partial<Entity> { const defaultPayDeadline = !config.default_pay_delay || config.default_pay_delay.d_us === "forever" ? undefined : add(new Date(), { seconds: config.default_pay_delay.d_us / (1000 * 1000), }); + const defaultWireDeadline = + !config.default_wire_transfer_delay || config.default_wire_transfer_delay.d_us === "forever" + ? undefined + : add(new Date(), { + seconds: config.default_wire_transfer_delay.d_us / (1000 * 1000), + }); return { inventoryProducts: {}, products: [], pricing: {}, payments: { - max_wire_fee: config.default_max_wire_fee, - max_fee: config.default_max_deposit_fee, - wire_fee_amortization: config.default_wire_fee_amortization, + max_fee: undefined, pay_deadline: defaultPayDeadline, refund_deadline: defaultPayDeadline, createToken: true, + wire_transfer_deadline: defaultWireDeadline, }, shipping: {}, - extra: "", + extra: {}, }; } @@ -107,8 +112,6 @@ interface Payments { wire_transfer_deadline?: Date; auto_refund_deadline?: Date; max_fee?: string; - max_wire_fee?: string; - wire_fee_amortization?: number; createToken: boolean; minimum_age?: number; } @@ -118,7 +121,7 @@ interface Entity { pricing: Partial<Pricing>; payments: Partial<Payments>; shipping: Partial<Shipping>; - extra: string; + extra: Record<string, string>; } const stringIsValidJSON = (value: string) => { @@ -136,8 +139,9 @@ export function CreatePage({ instanceConfig, instanceInventory, }: Props): VNode { - const [value, valueHandler] = useState(with_defaults(instanceConfig)); const config = useConfigContext(); + const instance_default = with_defaults(instanceConfig, config.currency) + const [value, valueHandler] = useState(instance_default); const zero = Amounts.zeroOfCurrency(config.currency); const [settings] = useSettings() const inventoryList = Object.values(value.inventoryProducts || {}); @@ -160,10 +164,10 @@ export function CreatePage({ ? i18n.str`must be greater than 0` : undefined, }), - extra: - value.extra && !stringIsValidJSON(value.extra) - ? i18n.str`not a valid json` - : undefined, + // extra: + // value.extra && !stringIsValidJSON(value.extra) + // ? i18n.str`not a valid json` + // : undefined, payments: undefinedIfEmpty({ refund_deadline: !value.payments?.refund_deadline ? undefined @@ -202,6 +206,7 @@ export function CreatePage({ ) ? i18n.str`auto refund cannot be after refund deadline` : undefined, + }), shipping: undefinedIfEmpty({ delivery_date: !value.shipping?.delivery_date @@ -225,7 +230,7 @@ export function CreatePage({ amount: order.pricing.order_price, summary: order.pricing.summary, products: productList, - extra: value.extra, + extra: JSON.stringify(value.extra), pay_deadline: value.payments.pay_deadline ? { t_s: Math.floor(value.payments.pay_deadline.getTime() / 1000), @@ -250,9 +255,7 @@ export function CreatePage({ ), } : undefined, - wire_fee_amortization: value.payments.wire_fee_amortization as number, max_fee: value.payments.max_fee as string, - max_wire_fee: value.payments.max_wire_fee as string, delivery_date: value.shipping.delivery_date ? { t_s: value.shipping.delivery_date.getTime() / 1000 } @@ -326,6 +329,8 @@ export function CreatePage({ const totalAsString = Amounts.stringify(totalPrice.amount); const allProducts = productList.concat(inventoryList.map(asProduct)); + const [newField, setNewField] = useState("") + useEffect(() => { valueHandler((v) => { return { @@ -486,16 +491,61 @@ export function CreatePage({ name="payments.pay_deadline" label={i18n.str`Payment deadline`} tooltip={i18n.str`Deadline for the customer to pay for the offer before it expires. Inventory products will be reserved until this deadline.`} + side={ + <span> + <button class="button" onClick={() => { + valueHandler({ + ...value, + payments: { + ...(value.payments ?? {}), + pay_deadline: instance_default.payments?.pay_deadline + } + }) + }}> + <i18n.Translate>default</i18n.Translate> + </button> + </span> + } /> <InputDate name="payments.refund_deadline" label={i18n.str`Refund deadline`} tooltip={i18n.str`Time until which the order can be refunded by the merchant.`} + side={ + <span> + <button class="button" onClick={() => { + valueHandler({ + ...value, + payments: { + ...(value.payments ?? {}), + refund_deadline: instance_default.payments?.refund_deadline + } + }) + }}> + <i18n.Translate>default</i18n.Translate> + </button> + </span> + } /> <InputDate name="payments.wire_transfer_deadline" label={i18n.str`Wire transfer deadline`} tooltip={i18n.str`Deadline for the exchange to make the wire transfer.`} + side={ + <span> + <button class="button" onClick={() => { + valueHandler({ + ...value, + payments: { + ...(value.payments ?? {}), + wire_transfer_deadline: instance_default.payments?.wire_transfer_deadline + } + }) + }}> + <i18n.Translate>default</i18n.Translate> + </button> + </span> + } /> <InputDate name="payments.auto_refund_deadline" @@ -505,23 +555,13 @@ export function CreatePage({ <InputCurrency name="payments.max_fee" - label={i18n.str`Maximum deposit fee`} - tooltip={i18n.str`Maximum deposit fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.`} + label={i18n.str`Maximum fee`} + tooltip={i18n.str`Maximum fees the merchant is willing to cover for this order. Higher deposit fees must be covered in full by the consumer.`} /> - <InputCurrency - name="payments.max_wire_fee" - label={i18n.str`Maximum wire fee`} - tooltip={i18n.str`Maximum aggregate wire fees the merchant is willing to cover for this order. Wire fees exceeding this amount are to be covered by the customers.`} - /> - <InputNumber - name="payments.wire_fee_amortization" - label={i18n.str`Wire fee amortization`} - tooltip={i18n.str`Factor by which wire fees exceeding the above threshold are divided to determine the share of excess wire fees to be paid explicitly by the consumer.`} - /> - <InputBoolean + <InputToggle name="payments.createToken" label={i18n.str`Create token`} - tooltip={i18n.str`Uncheck this option if the merchant backend generated an order ID with enough entropy to prevent adversarial claims.`} + tooltip={i18n.str`If the order ID is easy to guess the token will prevent user to steal orders from others.`} /> <InputNumber name="payments.minimum_age" @@ -530,7 +570,7 @@ export function CreatePage({ help={ minAgeByProducts > 0 ? i18n.str`Min age defined by the producs is ${minAgeByProducts}` - : undefined + : i18n.str`No product with age restriction in this order` } /> </InputGroup> @@ -542,12 +582,53 @@ export function CreatePage({ label={i18n.str`Additional information`} tooltip={i18n.str`Custom information to be included in the contract for this order.`} > - <Input - name="extra" - inputType="multiline" - label={`Value`} - tooltip={i18n.str`You must enter a value in JavaScript Object Notation (JSON).`} - /> + {Object.keys(value.extra ?? {}).map((key) => { + + return <Input + name={`extra.${key}`} + inputType="multiline" + label={key} + tooltip={i18n.str`You must enter a value in JavaScript Object Notation (JSON).`} + side={ + <button class="button" onClick={(e) => { + if (value.extra && value.extra[key] !== undefined) { + console.log(value.extra) + delete value.extra[key] + } + valueHandler({ + ...value, + }) + }}>remove</button> + } + /> + })} + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + <i18n.Translate>Custom field name</i18n.Translate> + <span class="icon has-tooltip-right" data-tooltip={"new extra field"}> + <i class="mdi mdi-information" /> + </span> + </label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class="control"> + <input class="input " value={newField} onChange={(e) => setNewField(e.currentTarget.value)} /> + </p> + </div> + </div> + <button class="button" onClick={(e) => { + setNewField("") + valueHandler({ + ...value, + extra: { + ...(value.extra ?? {}), + [newField]: "" + } + }) + }}>add</button> + </div> </InputGroup> } </FormProvider> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx index ffefd5302..2474fd042 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/create/index.tsx @@ -38,7 +38,7 @@ export type Entity = { }; interface Props { onBack?: () => void; - onConfirm: () => void; + onConfirm: (id: string) => void; onUnauthorized: () => VNode; onNotFound: () => VNode; onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; @@ -95,7 +95,9 @@ export default function OrderCreate({ onBack={onBack} onCreate={(request: MerchantBackend.Orders.PostOrderRequest) => { createOrder(request) - .then(onConfirm) + .then((r) => { + return onConfirm(r.data.order_id) + }) .catch((error) => { setNotif({ message: "could not create order", diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx index e430ede56..6e73a01a5 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Detail.stories.tsx @@ -50,13 +50,11 @@ const defaultContractTerm = { auditors: [], exchanges: [], max_fee: "TESTKUDOS:1", - max_wire_fee: "TESTKUDOS:1", merchant: {} as any, merchant_base_url: "http://merchant.url/", order_id: "2021.165-03GDFC26Y1NNG", products: [], summary: "text summary", - wire_fee_amortization: 1, wire_transfer_deadline: { t_s: "never", }, diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx index 8965d41c9..e42adc2ff 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx @@ -19,7 +19,7 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { AmountJson, Amounts } from "@gnu-taler/taler-util"; +import { AmountJson, Amounts, stringifyRefundUri } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { format, formatDistance } from "date-fns"; import { Fragment, h, VNode } from "preact"; @@ -38,6 +38,7 @@ import { MerchantBackend } from "../../../../declaration.js"; import { mergeRefunds } from "../../../../utils/amount.js"; import { RefundModal } from "../list/Table.js"; import { Event, Timeline } from "./Timeline.js"; +import { dateFormatForSettings, datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; type Entity = MerchantBackend.Orders.MerchantOrderStatusResponse; type CT = MerchantBackend.ContractTerms; @@ -87,18 +88,6 @@ function ContractTerms({ value }: { value: CT }) { label={i18n.str`Max fee`} tooltip={i18n.str`maximum total deposit fee accepted by the merchant for this contract`} /> - <Input<CT> - readonly - name="max_wire_fee" - label={i18n.str`Max wire fee`} - tooltip={i18n.str`maximum wire fee accepted by the merchant`} - /> - <Input<CT> - readonly - name="wire_fee_amortization" - label={i18n.str`Wire fee amortization`} - tooltip={i18n.str`over how many customer transactions does the merchant expect to amortize wire fees on average`} - /> <InputDate<CT> readonly name="timestamp" @@ -204,6 +193,7 @@ function ClaimedPage({ const [value, valueHandler] = useState<Partial<Claimed>>(order); const { i18n } = useTranslationContext(); + const [settings] = useSettings() return ( <div> @@ -249,7 +239,7 @@ function ClaimedPage({ </b>{" "} {format( new Date(order.contract_terms.timestamp.t_s * 1000), - "yyyy-MM-dd HH:mm:ss", + datetimeFormatForSettings(settings) )} </p> </div> @@ -427,9 +417,10 @@ function PaidPage({ const [value, valueHandler] = useState<Partial<Paid>>(order); const { url } = useBackendContext(); - const refundHost = url.replace(/.*:\/\//, ""); // remove protocol part - const proto = url.startsWith("http://") ? "taler+http" : "taler"; - const refundurl = `${proto}://refund/${refundHost}/${order.contract_terms.order_id}/`; + const refundurl = stringifyRefundUri({ + merchantBaseUrl: url, + orderId: order.contract_terms.order_id + }) const refundable = new Date().getTime() < order.contract_terms.refund_deadline.t_s * 1000; const { i18n } = useTranslationContext(); @@ -618,6 +609,7 @@ function UnpaidPage({ }) { const [value, valueHandler] = useState<Partial<Unpaid>>(order); const { i18n } = useTranslationContext(); + const [settings] = useSettings() return ( <div> <section class="hero is-hero-bar"> @@ -666,7 +658,7 @@ function UnpaidPage({ ? "never" : format( new Date(order.creation_time.t_s * 1000), - "yyyy-MM-dd HH:mm:ss", + datetimeFormatForSettings(settings) )} </p> </div> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx index e68889a92..8c863f386 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/Timeline.tsx @@ -16,6 +16,7 @@ import { format } from "date-fns"; import { h } from "preact"; import { useEffect, useState } from "preact/hooks"; +import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; interface Props { events: Event[]; @@ -30,7 +31,7 @@ export function Timeline({ events: e }: Props) { }); events.sort((a, b) => a.when.getTime() - b.when.getTime()); - + const [settings] = useSettings(); const [state, setState] = useState(events); useEffect(() => { const handle = setTimeout(() => { @@ -104,7 +105,7 @@ export function Timeline({ events: e }: Props) { } })()} <div class="timeline-content"> - {e.description !== "now" && <p class="heading">{format(e.when, "yyyy/MM/dd HH:mm:ss")}</p>} + {e.description !== "now" && <p class="heading">{format(e.when, datetimeFormatForSettings(settings))}</p>} <p>{e.description}</p> </div> </div> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx index 37770d273..c29a6fa6e 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/ListPage.tsx @@ -26,19 +26,24 @@ import { useState } from "preact/hooks"; import { DatePicker } from "../../../../components/picker/DatePicker.js"; import { MerchantBackend, WithId } from "../../../../declaration.js"; import { CardTable } from "./Table.js"; +import { dateFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; export interface ListPageProps { errorOrderId: string | undefined; onShowAll: () => void; + onShowNotPaid: () => void; onShowPaid: () => void; onShowRefunded: () => void; onShowNotWired: () => void; + onShowWired: () => void; onCopyURL: (id: string) => void; isAllActive: string; isPaidActive: string; + isNotPaidActive: string; isRefundedActive: string; isNotWiredActive: string; + isWiredActive: string; jumpToDate?: Date; onSelectDate: (date?: Date) => void; @@ -66,18 +71,23 @@ export function ListPage({ onCopyURL, onShowAll, onShowPaid, + onShowNotPaid, onShowRefunded, onShowNotWired, + onShowWired, onSelectDate, isPaidActive, isRefundedActive, isNotWiredActive, onCreate, + isNotPaidActive, + isWiredActive, }: ListPageProps): VNode { const { i18n } = useTranslationContext(); const dateTooltip = i18n.str`select date to show nearby orders`; const [pickDate, setPickDate] = useState(false); const [orderId, setOrderId] = useState<string>(""); + const [settings] = useSettings(); return ( <section class="section is-main-section"> @@ -116,13 +126,13 @@ export function ListPage({ <div class="column is-two-thirds"> <div class="tabs" style={{ overflow: "inherit" }}> <ul> - <li class={isAllActive}> + <li class={isNotPaidActive}> <div class="has-tooltip-right" - data-tooltip={i18n.str`remove all filters`} + data-tooltip={i18n.str`only show paid orders`} > - <a onClick={onShowAll}> - <i18n.Translate>All</i18n.Translate> + <a onClick={onShowNotPaid}> + <i18n.Translate>New</i18n.Translate> </a> </div> </li> @@ -156,6 +166,26 @@ export function ListPage({ </a> </div> </li> + <li class={isWiredActive}> + <div + class="has-tooltip-left" + data-tooltip={i18n.str`only show orders where customers paid, but wire payments from payment provider are still pending`} + > + <a onClick={onShowWired}> + <i18n.Translate>Completed</i18n.Translate> + </a> + </div> + </li> + <li class={isAllActive}> + <div + class="has-tooltip-right" + data-tooltip={i18n.str`remove all filters`} + > + <a onClick={onShowAll}> + <i18n.Translate>All</i18n.Translate> + </a> + </div> + </li> </ul> </div> </div> @@ -180,8 +210,8 @@ export function ListPage({ class="input" type="text" readonly - value={!jumpToDate ? "" : format(jumpToDate, "yyyy/MM/dd")} - placeholder={i18n.str`date (YYYY/MM/DD)`} + value={!jumpToDate ? "" : format(jumpToDate, dateFormatForSettings(settings))} + placeholder={i18n.str`date (${dateFormatForSettings(settings)})`} onClick={() => { setPickDate(true); }} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx index 3c927033b..608c9b20d 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx @@ -36,6 +36,7 @@ import { ConfirmModal } from "../../../../components/modal/index.js"; import { useConfigContext } from "../../../../context/config.js"; import { MerchantBackend, WithId } from "../../../../declaration.js"; import { mergeRefunds } from "../../../../utils/amount.js"; +import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; type Entity = MerchantBackend.Orders.OrderHistoryEntry & WithId; interface Props { @@ -136,6 +137,7 @@ function Table({ hasMoreBefore, }: TableProps): VNode { const { i18n } = useTranslationContext(); + const [settings] = useSettings(); return ( <div class="table-container"> {onLoadMoreBefore && ( @@ -173,9 +175,9 @@ function Table({ {i.timestamp.t_s === "never" ? "never" : format( - new Date(i.timestamp.t_s * 1000), - "yyyy/MM/dd HH:mm:ss", - )} + new Date(i.timestamp.t_s * 1000), + datetimeFormatForSettings(settings), + )} </td> <td onClick={(): void => onSelect(i)} @@ -260,6 +262,7 @@ export function RefundModal({ }: RefundModalProps): VNode { type State = { mainReason?: string; description?: string; refund?: string }; const [form, setValue] = useState<State>({}); + const [settings] = useSettings(); const { i18n } = useTranslationContext(); // const [errors, setErrors] = useState<FormErrors<State>>({}); @@ -281,8 +284,8 @@ export function RefundModal({ const totalRefundable = !orderPrice ? Amounts.zeroOfCurrency(totalRefunded.currency) : refunds.length - ? Amounts.sub(orderPrice, totalRefunded).amount - : orderPrice; + ? Amounts.sub(orderPrice, totalRefunded).amount + : orderPrice; const isRefundable = Amounts.isNonZero(totalRefundable); const duplicatedText = i18n.str`duplicated`; @@ -296,10 +299,10 @@ export function RefundModal({ refund: !form.refund ? i18n.str`required` : !Amounts.parse(form.refund) - ? i18n.str`invalid format` - : Amounts.cmp(totalRefundable, Amounts.parse(form.refund)!) === -1 - ? i18n.str`this value exceed the refundable amount` - : undefined, + ? i18n.str`invalid format` + : Amounts.cmp(totalRefundable, Amounts.parse(form.refund)!) === -1 + ? i18n.str`this value exceed the refundable amount` + : undefined, }; const hasErrors = Object.keys(errors).some( (k) => (errors as any)[k] !== undefined, @@ -361,9 +364,9 @@ export function RefundModal({ {r.timestamp.t_s === "never" ? "never" : format( - new Date(r.timestamp.t_s * 1000), - "yyyy-MM-dd HH:mm:ss", - )} + new Date(r.timestamp.t_s * 1000), + datetimeFormatForSettings(settings), + )} </td> <td>{r.amount}</td> <td>{r.reason}</td> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx index 6888eda58..48f77e3d3 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/index.tsx @@ -55,7 +55,7 @@ export default function OrderList({ onSelect, onNotFound, }: Props): VNode { - const [filter, setFilter] = useState<InstanceOrderFilter>({}); + const [filter, setFilter] = useState<InstanceOrderFilter>({ paid: "no" }); const [orderToBeRefunded, setOrderToBeRefunded] = useState< MerchantBackend.Orders.OrderHistoryEntry | undefined >(undefined); @@ -88,13 +88,15 @@ export default function OrderList({ return onLoadError(result); } - const isPaidActive = filter.paid === "yes" ? "is-active" : ""; + const isNotPaidActive = filter.paid === "no" ? "is-active" : ""; + const isPaidActive = filter.paid === "yes" && filter.wired === undefined ? "is-active" : ""; const isRefundedActive = filter.refunded === "yes" ? "is-active" : ""; - const isNotWiredActive = filter.wired === "no" ? "is-active" : ""; + const isNotWiredActive = filter.wired === "no" && filter.paid === "yes" ? "is-active" : ""; + const isWiredActive = filter.wired === "yes" ? "is-active" : ""; const isAllActive = filter.paid === undefined && - filter.refunded === undefined && - filter.wired === undefined + filter.refunded === undefined && + filter.wired === undefined ? "is-active" : ""; @@ -127,7 +129,9 @@ export default function OrderList({ errorOrderId={errorOrderId} isAllActive={isAllActive} isNotWiredActive={isNotWiredActive} + isWiredActive={isWiredActive} isPaidActive={isPaidActive} + isNotPaidActive={isNotPaidActive} isRefundedActive={isRefundedActive} jumpToDate={filter.date} onCopyURL={(id) => @@ -137,9 +141,11 @@ export default function OrderList({ onSearchOrderById={testIfOrderExistAndSelect} onSelectDate={setNewDate} onShowAll={() => setFilter({})} + onShowNotPaid={() => setFilter({ paid: "no" })} onShowPaid={() => setFilter({ paid: "yes" })} onShowRefunded={() => setFilter({ refunded: "yes" })} - onShowNotWired={() => setFilter({ wired: "no" })} + onShowNotWired={() => setFilter({ wired: "no", paid: "yes" })} + onShowWired={() => setFilter({ wired: "yes" })} /> {orderToBeRefunded && ( diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx index 6bbb89dfa..cbfe1d573 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/products/list/Table.tsx @@ -32,6 +32,7 @@ import { import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputNumber } from "../../../../components/form/InputNumber.js"; import { MerchantBackend, WithId } from "../../../../declaration.js"; +import { dateFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; type Entity = MerchantBackend.Products.ProductDetail & WithId; @@ -122,6 +123,7 @@ function Table({ onDelete, }: TableProps): VNode { const { i18n } = useTranslationContext(); + const [settings] = useSettings(); return ( <div class="table-container"> <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> @@ -134,7 +136,7 @@ function Table({ <i18n.Translate>Description</i18n.Translate> </th> <th> - <i18n.Translate>Sell</i18n.Translate> + <i18n.Translate>Price per unit</i18n.Translate> </th> <th> <i18n.Translate>Taxes</i18n.Translate> @@ -156,10 +158,10 @@ function Table({ const restStockInfo = !i.next_restock ? "" : i.next_restock.t_s === "never" - ? "never" - : `restock at ${format( + ? "never" + : `restock at ${format( new Date(i.next_restock.t_s * 1000), - "yyyy/MM/dd", + dateFormatForSettings(settings), )}`; let stockInfo: ComponentChildren = ""; if (i.total_stock < 0) { @@ -332,26 +334,35 @@ function FastProductWithInfiniteStockUpdateForm({ /> </FormProvider> - <div class="buttons is-right mt-5"> - <button class="button" onClick={onCancel}> - <i18n.Translate>Cancel</i18n.Translate> - </button> - <span - class="has-tooltip-left" - data-tooltip={i18n.str`update product with new price`} - > - <button - class="button is-info" - onClick={() => - onUpdate({ - ...product, - price: value.price, - }) - } - > - <i18n.Translate>Confirm</i18n.Translate> + <div class="buttons is-expanded"> + + <div class="buttons mt-5"> + + <button class="button " onClick={onCancel}> + <i18n.Translate>Clone</i18n.Translate> </button> - </span> + </div> + <div class="buttons is-right mt-5"> + <button class="button" onClick={onCancel}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + <span + class="has-tooltip-left" + data-tooltip={i18n.str`update product with new price`} + > + <button + class="button is-info" + onClick={() => + onUpdate({ + ...product, + price: value.price, + }) + } + > + <i18n.Translate>Confirm update</i18n.Translate> + </button> + </span> + </div> </div> </Fragment> ); @@ -374,9 +385,8 @@ function FastProductWithManagedStockUpdateForm({ const errors: FormErrors<FastProductUpdate> = { lost: currentStock + value.incoming < value.lost - ? `lost cannot be greater that current + incoming (max ${ - currentStock + value.incoming - })` + ? `lost cannot be greater that current + incoming (max ${currentStock + value.incoming + })` : undefined, }; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx index 87efd1554..85c50e5ed 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx @@ -36,6 +36,7 @@ import { import { Notification } from "../../../../utils/types.js"; import { CardTable } from "./Table.js"; import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { ConfirmModal, DeleteModal } from "../../../../components/modal/index.js"; interface Props { onUnauthorized: () => VNode; @@ -53,6 +54,8 @@ export default function ProductList({ }: Props): VNode { const result = useInstanceProducts(); const { deleteProduct, updateProduct } = useProductAPI(); + const [deleting, setDeleting] = + useState<MerchantBackend.Products.ProductDetail & WithId | null>(null); const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); @@ -97,22 +100,43 @@ export default function ProductList({ } onSelect={(product) => onSelect(product.id)} onDelete={(prod: MerchantBackend.Products.ProductDetail & WithId) => - deleteProduct(prod.id) - .then(() => + setDeleting(prod) + } + /> + + {deleting && ( + <ConfirmModal + label={`Delete product`} + description={`Delete the product "${deleting.description}"`} + danger + active + onCancel={() => setDeleting(null)} + onConfirm={async (): Promise<void> => { + try { + await deleteProduct(deleting.id); setNotif({ - message: i18n.str`product delete successfully`, + message: i18n.str`Product "${deleting.description}" (ID: ${deleting.id}) has been deleted`, type: "SUCCESS", - }), - ) - .catch((error) => + }); + } catch (error) { setNotif({ - message: i18n.str`could not delete the product`, + message: i18n.str`Failed to delete product`, type: "ERROR", - description: error.message, - }), - ) - } - /> + description: error instanceof Error ? error.message : undefined, + }); + } + setDeleting(null); + }} + > + <p> + If you delete the product named <b>"{deleting.description}"</b> (ID:{" "} + <b>{deleting.id}</b>), the stock and related information will be lost + </p> + <p class="warning"> + Deleting an product <b>cannot be undone</b>. + </p> + </ConfirmModal> + )} </section> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatePage.tsx index fccb20121..2201e75a5 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatePage.tsx @@ -36,7 +36,7 @@ import { URL_REGEX, } from "../../../../utils/constants.js"; -type Entity = MerchantBackend.Tips.ReserveCreateRequest; +type Entity = MerchantBackend.Rewards.ReserveCreateRequest; interface Props { onCreate: (d: Entity) => Promise<void>; @@ -80,15 +80,15 @@ function ViewStep({ initial_balance: !reserve.initial_balance ? "cannot be empty" : !(parseInt(reserve.initial_balance.split(":")[1], 10) > 0) - ? i18n.str`it should be greater than 0` - : undefined, + ? i18n.str`it should be greater than 0` + : undefined, exchange_url: !reserve.exchange_url ? i18n.str`cannot be empty` : !URL_REGEX.test(reserve.exchange_url) - ? i18n.str`must be a valid URL` - : !exchangeQueryError - ? undefined - : exchangeQueryError, + ? i18n.str`must be a valid URL` + : !exchangeQueryError + ? undefined + : exchangeQueryError, }; const hasErrors = Object.keys(errors).some( diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.tsx index 94fcdaff7..1d512c843 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/CreatedSuccessfully.tsx @@ -22,8 +22,8 @@ import { CreatedSuccessfully as Template } from "../../../../components/notifica import { MerchantBackend, WireAccount } from "../../../../declaration.js"; type Entity = { - request: MerchantBackend.Tips.ReserveCreateRequest; - response: MerchantBackend.Tips.ReserveCreateConfirmation; + request: MerchantBackend.Rewards.ReserveCreateRequest; + response: MerchantBackend.Rewards.ReserveCreateConfirmation; }; interface Props { @@ -98,15 +98,15 @@ export function ShowAccountsOfReserveAsQRWithLink({ const accountsInfo = !accounts ? [] : accounts - .map((acc) => { - const p = parsePaytoUri(acc.payto_uri); - if (p) { - p.params["message"] = message; - p.params["amount"] = amount; - } - return p; - }) - .filter(isNotUndefined); + .map((acc) => { + const p = parsePaytoUri(acc.payto_uri); + if (p) { + p.params["message"] = message; + p.params["amount"] = amount; + } + return p; + }) + .filter(isNotUndefined); const links = accountsInfo.map((a) => stringifyPaytoUri(a)); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/index.tsx index 8a4fe1565..4bbaf1459 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/create/index.tsx @@ -39,9 +39,9 @@ export default function CreateReserve({ onBack, onConfirm }: Props): VNode { const [createdOk, setCreatedOk] = useState< | { - request: MerchantBackend.Tips.ReserveCreateRequest; - response: MerchantBackend.Tips.ReserveCreateConfirmation; - } + request: MerchantBackend.Rewards.ReserveCreateRequest; + response: MerchantBackend.Rewards.ReserveCreateConfirmation; + } | undefined >(undefined); @@ -54,7 +54,7 @@ export default function CreateReserve({ onBack, onConfirm }: Props): VNode { <NotificationCard notification={notif} /> <CreatePage onBack={onBack} - onCreate={(request: MerchantBackend.Tips.ReserveCreateRequest) => { + onCreate={(request: MerchantBackend.Rewards.ReserveCreateRequest) => { return createReserve(request) .then((r) => setCreatedOk({ request, response: r.data })) .catch((error) => { diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/DetailPage.tsx index b0173b5d3..d8840eeac 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/DetailPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/DetailPage.tsx @@ -36,11 +36,12 @@ import { InputDate } from "../../../../components/form/InputDate.js"; import { TextField } from "../../../../components/form/TextField.js"; import { SimpleModal } from "../../../../components/modal/index.js"; import { MerchantBackend } from "../../../../declaration.js"; -import { useTipDetails } from "../../../../hooks/reserves.js"; -import { TipInfo } from "./TipInfo.js"; +import { useRewardDetails } from "../../../../hooks/reserves.js"; +import { RewardInfo } from "./RewardInfo.js"; import { ShowAccountsOfReserveAsQRWithLink } from "../create/CreatedSuccessfully.js"; +import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; -type Entity = MerchantBackend.Tips.ReserveDetail; +type Entity = MerchantBackend.Rewards.ReserveDetail; type CT = MerchantBackend.ContractTerms; interface Props { @@ -116,14 +117,14 @@ export function DetailPage({ id, selected, onBack }: Props): VNode { <span class="icon"> <i class="mdi mdi-cash-register" /> </span> - <i18n.Translate>Tips</i18n.Translate> + <i18n.Translate>Rewards</i18n.Translate> </p> </header> <div class="card-content"> <div class="b-table has-pagination"> <div class="table-wrapper has-mobile-cards"> - {selected.tips && selected.tips.length > 0 ? ( - <Table tips={selected.tips} /> + {selected.rewards && selected.rewards.length > 0 ? ( + <Table rewards={selected.rewards} /> ) : ( <EmptyTable /> )} @@ -163,7 +164,7 @@ function EmptyTable(): VNode { </p> <p> <i18n.Translate> - No tips has been authorized from this reserve + No reward has been authorized from this reserve </i18n.Translate> </p> </div> @@ -171,10 +172,10 @@ function EmptyTable(): VNode { } interface TableProps { - tips: MerchantBackend.Tips.TipStatusEntry[]; + rewards: MerchantBackend.Rewards.RewardStatusEntry[]; } -function Table({ tips }: TableProps): VNode { +function Table({ rewards }: TableProps): VNode { const { i18n } = useTranslationContext(); return ( <div class="table-container"> @@ -196,8 +197,8 @@ function Table({ tips }: TableProps): VNode { </tr> </thead> <tbody> - {tips.map((t, i) => { - return <TipRow id={t.tip_id} key={i} entry={t} />; + {rewards.map((t, i) => { + return <RewardRow id={t.reward_id} key={i} entry={t} />; })} </tbody> </table> @@ -205,15 +206,16 @@ function Table({ tips }: TableProps): VNode { ); } -function TipRow({ +function RewardRow({ id, entry, }: { id: string; - entry: MerchantBackend.Tips.TipStatusEntry; + entry: MerchantBackend.Rewards.RewardStatusEntry; }) { const [selected, setSelected] = useState(false); - const result = useTipDetails(id); + const result = useRewardDetails(id); + const [settings] = useSettings(); if (result.loading) { return ( <tr> @@ -242,11 +244,11 @@ function TipRow({ <Fragment> {selected && ( <SimpleModal - description="tip" + description="reward" active onCancel={() => setSelected(false)} > - <TipInfo id={id} amount={info.total_authorized} entity={info} /> + <RewardInfo id={id} amount={info.total_authorized} entity={info} /> </SimpleModal> )} <tr> @@ -256,7 +258,7 @@ function TipRow({ <td onClick={onSelect}> {info.expiration.t_s === "never" ? "never" - : format(info.expiration.t_s * 1000, "yyyy/MM/dd HH:mm:ss")} + : format(info.expiration.t_s * 1000, datetimeFormatForSettings(settings))} </td> </tr> </Fragment> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/Details.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/Details.stories.tsx index 2592e2c6e..41c715f20 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/Details.stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/Details.stories.tsx @@ -92,7 +92,7 @@ export const NotYetFunded = createExample(TestedComponent, { }, }); -export const FundedWithEmptyTips = createExample(TestedComponent, { +export const FundedWithEmptyRewards = createExample(TestedComponent, { id: "THISISTHERESERVEID", selected: { active: true, @@ -115,10 +115,10 @@ export const FundedWithEmptyTips = createExample(TestedComponent, { }, ], exchange_url: "http://exchange.taler/", - tips: [ + rewards: [ { reason: "asdasd", - tip_id: "123", + reward_id: "123", total_amount: "TESTKUDOS:1", }, ], diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/TipInfo.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx index 360d39aba..57a051ed7 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/TipInfo.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/details/RewardInfo.tsx @@ -17,8 +17,10 @@ import { format } from "date-fns"; import { Fragment, h, VNode } from "preact"; import { useBackendContext } from "../../../../context/backend.js"; import { MerchantBackend } from "../../../../declaration.js"; +import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; +import { stringifyRewardUri } from "@gnu-taler/taler-util"; -type Entity = MerchantBackend.Tips.TipDetails; +type Entity = MerchantBackend.Rewards.RewardDetails; interface Props { id: string; @@ -26,11 +28,10 @@ interface Props { amount: string; } -export function TipInfo({ id, amount, entity }: Props): VNode { - const { url } = useBackendContext(); - const tipHost = url.replace(/.*:\/\//, ""); // remove protocol part - const proto = url.startsWith("http://") ? "taler+http" : "taler"; - const tipURL = `${proto}://tip/${tipHost}/${id}`; +export function RewardInfo({ id: merchantRewardId, amount, entity }: Props): VNode { + const { url: merchantBaseUrl } = useBackendContext(); + const [settings] = useSettings(); + const rewardURL = stringifyRewardUri({ merchantBaseUrl, merchantRewardId }) return ( <Fragment> <div class="field is-horizontal"> @@ -52,8 +53,8 @@ export function TipInfo({ id, amount, entity }: Props): VNode { <div class="field-body is-flex-grow-3"> <div class="field" style={{ overflowWrap: "anywhere" }}> <p class="control"> - <a target="_blank" rel="noreferrer" href={tipURL}> - {tipURL} + <a target="_blank" rel="noreferrer" href={rewardURL}> + {rewardURL} </a> </p> </div> @@ -73,9 +74,9 @@ export function TipInfo({ id, amount, entity }: Props): VNode { !entity.expiration || entity.expiration.t_s === "never" ? "never" : format( - entity.expiration.t_s * 1000, - "yyyy/MM/dd HH:mm:ss", - ) + entity.expiration.t_s * 1000, + datetimeFormatForSettings(settings), + ) } /> </p> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/AutorizeTipModal.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/AutorizeRewardModal.tsx index 1882f50d3..e205ee621 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/AutorizeTipModal.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/AutorizeRewardModal.tsx @@ -34,32 +34,32 @@ import { ContinueModal, } from "../../../../components/modal/index.js"; import { MerchantBackend } from "../../../../declaration.js"; -import { AuthorizeTipSchema } from "../../../../schemas/index.js"; +import { AuthorizeRewardSchema } from "../../../../schemas/index.js"; import { CreatedSuccessfully } from "./CreatedSuccessfully.js"; -interface AuthorizeTipModalProps { +interface AuthorizeRewardModalProps { onCancel: () => void; - onConfirm: (value: MerchantBackend.Tips.TipCreateRequest) => void; - tipAuthorized?: { - response: MerchantBackend.Tips.TipCreateConfirmation; - request: MerchantBackend.Tips.TipCreateRequest; + onConfirm: (value: MerchantBackend.Rewards.RewardCreateRequest) => void; + rewardAuthorized?: { + response: MerchantBackend.Rewards.RewardCreateConfirmation; + request: MerchantBackend.Rewards.RewardCreateRequest; }; } -export function AuthorizeTipModal({ +export function AuthorizeRewardModal({ onCancel, onConfirm, - tipAuthorized, -}: AuthorizeTipModalProps): VNode { + rewardAuthorized, +}: AuthorizeRewardModalProps): VNode { // const result = useOrderDetails(id) - type State = MerchantBackend.Tips.TipCreateRequest; + type State = MerchantBackend.Rewards.RewardCreateRequest; const [form, setValue] = useState<Partial<State>>({}); const { i18n } = useTranslationContext(); // const [errors, setErrors] = useState<FormErrors<State>>({}) let errors: FormErrors<State> = {}; try { - AuthorizeTipSchema.validateSync(form, { abortEarly: false }); + AuthorizeRewardSchema.validateSync(form, { abortEarly: false }); } catch (err) { if (err instanceof yup.ValidationError) { const yupErrors = err.inner as any[]; @@ -77,12 +77,12 @@ export function AuthorizeTipModal({ const validateAndConfirm = () => { onConfirm(form as State); }; - if (tipAuthorized) { + if (rewardAuthorized) { return ( - <ContinueModal description="tip" active onConfirm={onCancel}> + <ContinueModal description="reward" active onConfirm={onCancel}> <CreatedSuccessfully - entity={tipAuthorized.response} - request={tipAuthorized.request} + entity={rewardAuthorized.response} + request={rewardAuthorized.request} onConfirm={onCancel} /> </ContinueModal> @@ -91,7 +91,7 @@ export function AuthorizeTipModal({ return ( <ConfirmModal - description="tip" + description="New reward" active onCancel={onCancel} disabled={hasErrors} @@ -105,18 +105,18 @@ export function AuthorizeTipModal({ <InputCurrency<State> name="amount" label={i18n.str`Amount`} - tooltip={i18n.str`amount of tip`} + tooltip={i18n.str`amount of reward`} /> <Input<State> name="justification" label={i18n.str`Justification`} inputType="multiline" - tooltip={i18n.str`reason for the tip`} + tooltip={i18n.str`reason for the reward`} /> <Input<State> name="next_url" - label={i18n.str`URL after tip`} - tooltip={i18n.str`URL to visit after tip payment`} + label={i18n.str`URL after reward`} + tooltip={i18n.str`URL to visit after reward payment`} /> </FormProvider> </ConfirmModal> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/CreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/CreatedSuccessfully.tsx index 643651b52..b78236bc7 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/CreatedSuccessfully.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/CreatedSuccessfully.tsx @@ -17,12 +17,13 @@ import { format } from "date-fns"; import { Fragment, h, VNode } from "preact"; import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js"; import { MerchantBackend } from "../../../../declaration.js"; +import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; -type Entity = MerchantBackend.Tips.TipCreateConfirmation; +type Entity = MerchantBackend.Rewards.RewardCreateConfirmation; interface Props { entity: Entity; - request: MerchantBackend.Tips.TipCreateRequest; + request: MerchantBackend.Rewards.RewardCreateRequest; onConfirm: () => void; onCreateAnother?: () => void; } @@ -33,6 +34,7 @@ export function CreatedSuccessfully({ onConfirm, onCreateAnother, }: Props): VNode { + const [settings] = useSettings(); return ( <Fragment> <div class="field is-horizontal"> @@ -66,7 +68,7 @@ export function CreatedSuccessfully({ <div class="field-body is-flex-grow-3"> <div class="field"> <p class="control"> - <input readonly class="input" value={entity.tip_status_url} /> + <input readonly class="input" value={entity.reward_status_url} /> </p> </div> </div> @@ -82,13 +84,13 @@ export function CreatedSuccessfully({ class="input" readonly value={ - !entity.tip_expiration || - entity.tip_expiration.t_s === "never" + !entity.reward_expiration || + entity.reward_expiration.t_s === "never" ? "never" : format( - entity.tip_expiration.t_s * 1000, - "yyyy/MM/dd HH:mm:ss", - ) + entity.reward_expiration.t_s * 1000, + datetimeFormatForSettings(settings), + ) } /> </p> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/List.stories.tsx index fe305f4fd..b070bbde3 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/List.stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/List.stories.tsx @@ -25,12 +25,6 @@ import { CardTable as TestedComponent } from "./Table.js"; export default { title: "Pages/Reserve/List", component: TestedComponent, - argTypes: { - onCreate: { action: "onCreate" }, - onDelete: { action: "onDelete" }, - onNewTip: { action: "onNewTip" }, - onSelect: { action: "onSelect" }, - }, }; function createExample<Props>( diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/Table.tsx index 1f229d7cb..795e7ec82 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/Table.tsx @@ -23,12 +23,13 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { Fragment, h, VNode } from "preact"; import { MerchantBackend, WithId } from "../../../../declaration.js"; +import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; -type Entity = MerchantBackend.Tips.ReserveStatusEntry & WithId; +type Entity = MerchantBackend.Rewards.ReserveStatusEntry & WithId; interface Props { instances: Entity[]; - onNewTip: (id: Entity) => void; + onNewReward: (id: Entity) => void; onSelect: (id: Entity) => void; onDelete: (id: Entity) => void; onCreate: () => void; @@ -38,7 +39,7 @@ export function CardTable({ instances, onCreate, onSelect, - onNewTip, + onNewReward, onDelete, }: Props): VNode { const [withoutFunds, withFunds] = instances.reduce((prev, current) => { @@ -70,7 +71,7 @@ export function CardTable({ <div class="table-wrapper has-mobile-cards"> <TableWithoutFund instances={withoutFunds} - onNewTip={onNewTip} + onNewReward={onNewReward} onSelect={onSelect} onDelete={onDelete} /> @@ -108,7 +109,7 @@ export function CardTable({ {withFunds.length > 0 ? ( <Table instances={withFunds} - onNewTip={onNewTip} + onNewReward={onNewReward} onSelect={onSelect} onDelete={onDelete} /> @@ -124,13 +125,14 @@ export function CardTable({ } interface TableProps { instances: Entity[]; - onNewTip: (id: Entity) => void; + onNewReward: (id: Entity) => void; onDelete: (id: Entity) => void; onSelect: (id: Entity) => void; } -function Table({ instances, onNewTip, onSelect, onDelete }: TableProps): VNode { +function Table({ instances, onNewReward, onSelect, onDelete }: TableProps): VNode { const { i18n } = useTranslationContext(); + const [settings] = useSettings(); return ( <div class="table-container"> <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> @@ -164,7 +166,7 @@ function Table({ instances, onNewTip, onSelect, onDelete }: TableProps): VNode { > {i.creation_time.t_s === "never" ? "never" - : format(i.creation_time.t_s * 1000, "yyyy/MM/dd HH:mm:ss")} + : format(i.creation_time.t_s * 1000, datetimeFormatForSettings(settings))} </td> <td onClick={(): void => onSelect(i)} @@ -173,9 +175,9 @@ function Table({ instances, onNewTip, onSelect, onDelete }: TableProps): VNode { {i.expiration_time.t_s === "never" ? "never" : format( - i.expiration_time.t_s * 1000, - "yyyy/MM/dd HH:mm:ss", - )} + i.expiration_time.t_s * 1000, + datetimeFormatForSettings(settings), + )} </td> <td onClick={(): void => onSelect(i)} @@ -207,11 +209,11 @@ function Table({ instances, onNewTip, onSelect, onDelete }: TableProps): VNode { </button> <button class="button is-small is-info has-tooltip-left" - data-tooltip={i18n.str`authorize new tip from selected reserve`} + data-tooltip={i18n.str`authorize new reward from selected reserve`} type="button" - onClick={(): void => onNewTip(i)} + onClick={(): void => onNewReward(i)} > - New Tip + New Reward </button> </div> </td> @@ -249,6 +251,7 @@ function TableWithoutFund({ onDelete, }: TableProps): VNode { const { i18n } = useTranslationContext(); + const [settings] = useSettings(); return ( <div class="table-container"> <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> @@ -276,7 +279,7 @@ function TableWithoutFund({ > {i.creation_time.t_s === "never" ? "never" - : format(i.creation_time.t_s * 1000, "yyyy/MM/dd HH:mm:ss")} + : format(i.creation_time.t_s * 1000, datetimeFormatForSettings(settings))} </td> <td onClick={(): void => onSelect(i)} @@ -285,9 +288,9 @@ function TableWithoutFund({ {i.expiration_time.t_s === "never" ? "never" : format( - i.expiration_time.t_s * 1000, - "yyyy/MM/dd HH:mm:ss", - )} + i.expiration_time.t_s * 1000, + datetimeFormatForSettings(settings), + )} </td> <td onClick={(): void => onSelect(i)} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/index.tsx index 14387c2a9..b26ff0000 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/reserves/list/index.tsx @@ -34,9 +34,10 @@ import { useReservesAPI, } from "../../../../hooks/reserves.js"; import { Notification } from "../../../../utils/types.js"; -import { AuthorizeTipModal } from "./AutorizeTipModal.js"; +import { AuthorizeRewardModal } from "./AutorizeRewardModal.js"; import { CardTable } from "./Table.js"; import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { ConfirmModal } from "../../../../components/modal/index.js"; interface Props { onUnauthorized: () => VNode; @@ -46,12 +47,12 @@ interface Props { onCreate: () => void; } -interface TipConfirmation { - response: MerchantBackend.Tips.TipCreateConfirmation; - request: MerchantBackend.Tips.TipCreateRequest; +interface RewardConfirmation { + response: MerchantBackend.Rewards.RewardCreateConfirmation; + request: MerchantBackend.Rewards.RewardCreateRequest; } -export default function ListTips({ +export default function ListRewards({ onUnauthorized, onLoadError, onNotFound, @@ -59,14 +60,16 @@ export default function ListTips({ onCreate, }: Props): VNode { const result = useInstanceReserves(); - const { deleteReserve, authorizeTipReserve } = useReservesAPI(); + const { deleteReserve, authorizeRewardReserve } = useReservesAPI(); const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); - const [reserveForTip, setReserveForTip] = useState<string | undefined>( + const [reserveForReward, setReserveForReward] = useState<string | undefined>( undefined, ); - const [tipAuthorized, setTipAuthorized] = useState< - TipConfirmation | undefined + const [deleting, setDeleting] = + useState<MerchantBackend.Rewards.ReserveStatusEntry | null>(null); + const [rewardAuthorized, setRewardAuthorized] = useState< + RewardConfirmation | undefined >(undefined); if (result.loading) return <Loading />; @@ -88,30 +91,30 @@ export default function ListTips({ <section class="section is-main-section"> <NotificationCard notification={notif} /> - {reserveForTip && ( - <AuthorizeTipModal + {reserveForReward && ( + <AuthorizeRewardModal onCancel={() => { - setReserveForTip(undefined); - setTipAuthorized(undefined); + setReserveForReward(undefined); + setRewardAuthorized(undefined); }} - tipAuthorized={tipAuthorized} + rewardAuthorized={rewardAuthorized} onConfirm={async (request) => { try { - const response = await authorizeTipReserve( - reserveForTip, + const response = await authorizeRewardReserve( + reserveForReward, request, ); - setTipAuthorized({ + setRewardAuthorized({ request, response: response.data, }); } catch (error) { setNotif({ - message: i18n.str`could not create the tip`, + message: i18n.str`could not create the reward`, type: "ERROR", description: error instanceof Error ? error.message : undefined, }); - setReserveForTip(undefined); + setReserveForReward(undefined); } }} /> @@ -122,10 +125,47 @@ export default function ListTips({ .filter((r) => r.active) .map((o) => ({ ...o, id: o.reserve_pub }))} onCreate={onCreate} - onDelete={(reserve) => deleteReserve(reserve.reserve_pub)} + onDelete={(reserve) => { + setDeleting(reserve) + }} onSelect={(reserve) => onSelect(reserve.id)} - onNewTip={(reserve) => setReserveForTip(reserve.id)} + onNewReward={(reserve) => setReserveForReward(reserve.id)} /> + + {deleting && ( + <ConfirmModal + label={`Delete reserve`} + description={`Delete the reserve`} + danger + active + onCancel={() => setDeleting(null)} + onConfirm={async (): Promise<void> => { + try { + await deleteReserve(deleting.reserve_pub); + setNotif({ + message: i18n.str`Reserve for "${deleting.merchant_initial_amount}" (ID: ${deleting.reserve_pub}) has been deleted`, + type: "SUCCESS", + }); + } catch (error) { + setNotif({ + message: i18n.str`Failed to delete reserve`, + type: "ERROR", + description: error instanceof Error ? error.message : undefined, + }); + } + setDeleting(null); + }} + > + <p> + If you delete the reserve for <b>"{deleting.merchant_initial_amount}"</b> you won't be able to create more rewards. <br /> + Reserve ID: <b>{deleting.reserve_pub}</b> + </p> + <p class="warning"> + Deleting an template <b>cannot be undone</b>. + </p> + </ConfirmModal> + )} + </section> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx index e20b9bc27..8629d8dee 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx @@ -24,7 +24,7 @@ import { MerchantTemplateContractDetails, } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; +import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; import { @@ -35,17 +35,16 @@ import { Input } from "../../../../components/form/Input.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputDuration } from "../../../../components/form/InputDuration.js"; import { InputNumber } from "../../../../components/form/InputNumber.js"; -import { InputSelector } from "../../../../components/form/InputSelector.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; import { useBackendContext } from "../../../../context/backend.js"; +import { useInstanceContext } from "../../../../context/instance.js"; import { MerchantBackend } from "../../../../declaration.js"; import { - isBase32RFC3548Charset, - randomBase32Key, + isBase32RFC3548Charset } from "../../../../utils/crypto.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; -import { QR } from "../../../../components/exception/QR.js"; -import { useInstanceContext } from "../../../../context/instance.js"; +import { InputSearchOnList } from "../../../../components/form/InputSearchOnList.js"; +import { useInstanceOtpDevices } from "../../../../hooks/otp.js"; type Entity = MerchantBackend.Template.TemplateAddDetails; @@ -54,16 +53,11 @@ interface Props { onBack?: () => void; } -const algorithms = [0, 1, 2]; -const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"]; - export function CreatePage({ onCreate, onBack }: Props): VNode { const { i18n } = useTranslationContext(); const backend = useBackendContext(); - const { id: instanceId } = useInstanceContext(); - const issuer = new URL(backend.url).hostname; + const devices = useInstanceOtpDevices() - const [showKey, setShowKey] = useState(false); const [state, setState] = useState<Partial<Entity>>({ template_contract: { minimum_age: 0, @@ -78,7 +72,11 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { : Amounts.parse(state.template_contract?.amount); 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` + : !/[a-zA-Z0-9]*/.test(state.template_id) + ? i18n.str`no valid. only characters and numbers` + : undefined, template_description: !state.template_description ? i18n.str`should not be empty` : undefined, @@ -104,15 +102,6 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { ? i18n.str`to short` : 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( @@ -124,7 +113,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { return onCreate(state as any); }; - const qrText = `otpauth://totp/${instanceId}/${state.template_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${state.pos_key}`; + const deviceList = !devices.ok ? [] : devices.data.otp_devices return ( <div> @@ -139,7 +128,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { > <InputWithAddon<Entity> name="template_id" - help={`${backend.url}/instances/templates/${state.template_id ?? ""}`} + help={`${backend.url}/templates/${state.template_id ?? ""}`} label={i18n.str`Identifier`} tooltip={i18n.str`Name of the template in URLs.`} /> @@ -172,83 +161,21 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { help="" tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`} /> - <InputSelector<Entity> - name="pos_algorithm" - label={i18n.str`Verification algorithm`} - tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`} - values={algorithms} - toStr={(v) => algorithmsNames[v]} - fromStr={(v) => Number(v)} + <Input<Entity> + name="otp_id" + label={i18n.str`OTP device`} + readonly + tooltip={i18n.str`Use to verify transaction in offline mode.`} + /> + <InputSearchOnList + label={i18n.str`Search device`} + onChange={(p) => setState((v) => ({ ...v, otp_id: p?.id }))} + list={deviceList.map(e => ({ + description: e.device_description, + id: e.otp_device_id + }))} /> - {state.pos_algorithm && state.pos_algorithm > 0 ? ( - <Fragment> - <InputWithAddon<Entity> - name="pos_key" - label={i18n.str`Point-of-sale key`} - inputType={showKey ? "text" : "password"} - help="Be sure to be very hard to guess or use the random generator" - 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={ - <span style={{ display: "flex" }}> - <button - data-tooltip={i18n.str`generate random secret key`} - class="button is-info mr-3" - onClick={(e) => { - const pos_key = randomBase32Key(); - setState((s) => ({ ...s, pos_key })); - }} - > - <i18n.Translate>random</i18n.Translate> - </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> - } - /> - {showKey && ( - <Fragment> - <QR text={qrText} /> - <div - style={{ - color: "grey", - fontSize: "small", - width: 200, - textAlign: "center", - margin: "auto", - wordBreak: "break-all", - }} - > - {qrText} - </div> - </Fragment> - )} - </Fragment> - ) : undefined} + </FormProvider> <div class="buttons is-right mt-5"> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx index 2f91298bf..3c9bb231c 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/list/index.tsx @@ -36,6 +36,7 @@ import { import { Notification } from "../../../../utils/types.js"; import { ListPage } from "./ListPage.js"; import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { ConfirmModal } from "../../../../components/modal/index.js"; interface Props { onUnauthorized: () => VNode; @@ -61,6 +62,8 @@ export default function ListTemplates({ const [notif, setNotif] = useState<Notification | undefined>(undefined); const { deleteTemplate } = useTemplateAPI(); const result = useInstanceTemplates({ position }, (id) => setPosition(id)); + const [deleting, setDeleting] = + useState<MerchantBackend.Template.TemplateEntry | null>(null); if (result.loading) return <Loading />; if (!result.ok) { @@ -97,23 +100,45 @@ export default function ListTemplates({ onQR={(e) => { onQR(e.template_id); }} - onDelete={(e: MerchantBackend.Template.TemplateEntry) => - deleteTemplate(e.template_id) - .then(() => + onDelete={(e: MerchantBackend.Template.TemplateEntry) => { + setDeleting(e) + } + } + /> + + {deleting && ( + <ConfirmModal + label={`Delete template`} + description={`Delete the template "${deleting.template_description}"`} + danger + active + onCancel={() => setDeleting(null)} + onConfirm={async (): Promise<void> => { + try { + await deleteTemplate(deleting.template_id); setNotif({ - message: i18n.str`template delete successfully`, + message: i18n.str`Template "${deleting.template_description}" (ID: ${deleting.template_id}) has been deleted`, type: "SUCCESS", - }), - ) - .catch((error) => + }); + } catch (error) { setNotif({ - message: i18n.str`could not delete the template`, + message: i18n.str`Failed to delete template`, type: "ERROR", - description: error.message, - }), - ) - } - /> + description: error instanceof Error ? error.message : undefined, + }); + } + setDeleting(null); + }} + > + <p> + If you delete the template <b>"{deleting.template_description}"</b> (ID:{" "} + <b>{deleting.template_id}</b>) you may loose information + </p> + <p class="warning"> + Deleting an template <b>cannot be undone</b>. + </p> + </ConfirmModal> + )} </Fragment> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx index 0f30efafd..c65cf6a19 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx @@ -19,7 +19,7 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { HttpError, useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { QR } from "../../../../components/exception/QR.js"; @@ -35,35 +35,32 @@ import { useConfigContext } from "../../../../context/config.js"; import { useInstanceContext } from "../../../../context/instance.js"; import { MerchantBackend } from "../../../../declaration.js"; import { stringifyPayTemplateUri } from "@gnu-taler/taler-util"; +import { useOtpDeviceDetails } from "../../../../hooks/otp.js"; +import { Loading } from "../../../../components/exception/loading.js"; type Entity = MerchantBackend.Template.UsingTemplateDetails; interface Props { - template: MerchantBackend.Template.TemplateDetails; + contract: MerchantBackend.Template.TemplateContractDetails; id: string; onBack?: () => void; } -export function QrPage({ template, id: templateId, onBack }: Props): VNode { +export function QrPage({ contract, id: templateId, onBack }: Props): VNode { const { i18n } = useTranslationContext(); const { url: backendUrl } = useBackendContext(); const { id: instanceId } = useInstanceContext(); const config = useConfigContext(); - const [setupTOTP, setSetupTOTP] = useState(false); const [state, setState] = useState<Partial<Entity>>({ - amount: template.template_contract.amount, - summary: template.template_contract.summary, + amount: contract.amount, + summary: contract.summary, }); const errors: FormErrors<Entity> = {}; - const hasErrors = Object.keys(errors).some( - (k) => (errors as any)[k] !== undefined, - ); - - const fixedAmount = !!template.template_contract.amount; - const fixedSummary = !!template.template_contract.summary; + const fixedAmount = !!contract.amount; + const fixedSummary = !!contract.summary; const templateParams: Record<string, string> = {} if (!fixedAmount) { @@ -89,40 +86,9 @@ export function QrPage({ template, id: templateId, onBack }: Props): VNode { const issuer = encodeURIComponent( `${new URL(backendUrl).host}/${instanceId}`, ); - const oauthUri = !template.pos_algorithm - ? undefined - : template.pos_algorithm === 1 - ? `otpauth://totp/${issuer}:${templateId}?secret=${template.pos_key}&issuer=${issuer}&algorithm=SHA1&digits=8&period=30` - : template.pos_algorithm === 2 - ? `otpauth://totp/${issuer}:${templateId}?secret=${template.pos_key}&issuer=${issuer}&algorithm=SHA1&digits=8&period=30` - : undefined; - - const keySlice = template.pos_key?.substring(0, 4); - - const oauthUriWithoutSecret = !template.pos_algorithm - ? undefined - : template.pos_algorithm === 1 - ? `otpauth://totp/${issuer}:${templateId}?secret=${keySlice}...&issuer=${issuer}&algorithm=SHA1&digits=8&period=30` - : template.pos_algorithm === 2 - ? `otpauth://totp/${issuer}:${templateId}?secret=${keySlice}...&issuer=${issuer}&algorithm=SHA1&digits=8&period=30` - : undefined; + return ( <div> - {oauthUri && ( - <ConfirmModal - description="Setup TOTP" - active={setupTOTP} - onCancel={() => { - setSetupTOTP(false); - }} - > - <p>Scan this qr code with your TOTP device</p> - <QR text={oauthUri} /> - <pre style={{ textAlign: "center" }}> - <a href={oauthUri}>{oauthUriWithoutSecret}</a> - </pre> - </ConfirmModal> - )} <section class="section is-main-section"> <div class="columns"> <div class="column" /> @@ -176,14 +142,6 @@ export function QrPage({ template, id: templateId, onBack }: Props): VNode { > <i18n.Translate>Print</i18n.Translate> </button> - {oauthUri && ( - <button - class="button is-info" - onClick={() => setSetupTOTP(true)} - > - <i18n.Translate>Setup TOTP</i18n.Translate> - </button> - )} </div> </div> <div class="column" /> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/index.tsx index 1f74afc2b..7db7478f7 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/index.tsx @@ -74,7 +74,7 @@ export default function TemplateQrPage({ return ( <> <NotificationCard notification={notif} /> - <QrPage template={result.data} id={tid} onBack={onBack} /> + <QrPage contract={result.data.template_contract} id={tid} onBack={onBack} /> </> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx index 30e5502bb..30d47385c 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx @@ -61,10 +61,7 @@ const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"]; export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { const { i18n } = useTranslationContext(); const backend = useBackendContext(); - const { id: instanceId } = useInstanceContext(); - const issuer = new URL(backend.url).hostname; - const [showKey, setShowKey] = useState(false); const [state, setState] = useState<Partial<Entity>>(template); const parsedPrice = !state.template_contract?.amount @@ -78,34 +75,25 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { template_contract: !state.template_contract ? undefined : undefinedIfEmpty({ - amount: !state.template_contract?.amount - ? undefined - : !parsedPrice + amount: !state.template_contract?.amount + ? undefined + : !parsedPrice ? i18n.str`not valid` : Amounts.isZero(parsedPrice) - ? i18n.str`must be greater than 0` - : undefined, - minimum_age: - state.template_contract.minimum_age < 0 - ? i18n.str`should be greater that 0` + ? i18n.str`must be greater than 0` : undefined, - pay_duration: !state.template_contract.pay_duration - ? i18n.str`can't be empty` - : state.template_contract.pay_duration.d_us === "forever" + minimum_age: + state.template_contract.minimum_age < 0 + ? i18n.str`should be greater that 0` + : undefined, + pay_duration: !state.template_contract.pay_duration + ? i18n.str`can't be empty` + : state.template_contract.pay_duration.d_us === "forever" ? undefined : state.template_contract.pay_duration.d_us < 1000 * 1000 // less than one second - ? i18n.str`to short` - : 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, + ? i18n.str`to short` + : undefined, + } as Partial<MerchantTemplateContractDetails>), }; const hasErrors = Object.keys(errors).some( @@ -117,7 +105,6 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { return onUpdate(state as any); }; - const qrText = `otpauth://totp/${instanceId}/${state.id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${state.pos_key}`; return ( <div> @@ -128,7 +115,7 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { <div class="level-left"> <div class="level-item"> <span class="is-size-4"> - {backend.url}/instances/template/{template.id} + {backend.url}/templates/{template.id} </span> </div> </div> @@ -182,84 +169,6 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { help="" tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`} /> - <InputSelector<Entity> - name="pos_algorithm" - label={i18n.str`Verification algorithm`} - tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`} - values={algorithms} - toStr={(v) => algorithmsNames[v]} - fromStr={(v) => Number(v)} - /> - {state.pos_algorithm && state.pos_algorithm > 0 ? ( - <Fragment> - <InputWithAddon<Entity> - name="pos_key" - label={i18n.str`Point-of-sale key`} - inputType={showKey ? "text" : "password"} - help="Be sure to be very hard to guess or use the random generator" - expand - 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={ - <span style={{ display: "flex" }}> - <button - data-tooltip={i18n.str`generate random secret key`} - class="button is-info mr-3" - onClick={(e) => { - const pos_key = randomBase32Key(); - setState((s) => ({ ...s, pos_key })); - }} - > - <i18n.Translate>random</i18n.Translate> - </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> - } - /> - {showKey && ( - <Fragment> - <QR text={qrText} /> - <div - style={{ - color: "grey", - fontSize: "small", - width: 200, - textAlign: "center", - margin: "auto", - wordBreak: "break-all", - }} - > - {qrText} - </div> - </Fragment> - )} - </Fragment> - ) : undefined} </FormProvider> <div class="buttons is-right mt-5"> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx new file mode 100644 index 000000000..6ab2a2df6 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/token/DetailPage.tsx @@ -0,0 +1,165 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/exception/AsyncButton.js"; +import { FormProvider } from "../../../components/form/FormProvider.js"; +import { Input } from "../../../components/form/Input.js"; +import { useInstanceContext } from "../../../context/instance.js"; + +interface Props { + instanceId: string; + currentToken: string | undefined; + onClearToken: () => void; + onNewToken: (s: string) => void; + onBack?: () => void; +} + +export function DetailPage({ instanceId, currentToken: oldToken, onBack, onNewToken, onClearToken }: Props): VNode { + type State = { old_token: string; new_token: string; repeat_token: string }; + const [form, setValue] = useState<Partial<State>>({ + old_token: "", + new_token: "", + repeat_token: "", + }); + const { i18n } = useTranslationContext(); + + const hasOldtoken = !!oldToken + const hasInputTheCorrectOldToken = hasOldtoken && oldToken !== form.old_token; + const errors = { + old_token: hasInputTheCorrectOldToken + ? i18n.str`is not the same as the current access token` + : undefined, + new_token: !form.new_token + ? i18n.str`cannot be empty` + : form.new_token === form.old_token + ? i18n.str`cannot be the same as the old token` + : undefined, + repeat_token: + form.new_token !== form.repeat_token + ? i18n.str`is not the same` + : undefined, + }; + + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const instance = useInstanceContext(); + + const text = i18n.str`You are updating the access token from instance with id ${instance.id}`; + + async function submitForm() { + if (hasErrors) return; + onNewToken(form.new_token as any) + } + + return ( + <div> + <section class="section"> + <section class="hero is-hero-bar"> + <div class="hero-body"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <span class="is-size-4"> + Instace id: <b>{instanceId}</b> + </span> + </div> + </div> + </div> + </div> + </section> + <hr /> + + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <FormProvider errors={errors} object={form} valueHandler={setValue}> + {hasOldtoken && ( + <Input<State> + name="old_token" + label={i18n.str`Current access token`} + tooltip={i18n.str`access token currently in use`} + inputType="password" + /> + )} + {!hasInputTheCorrectOldToken && <Fragment> + {hasOldtoken && <Fragment> + <p> + <i18n.Translate> + Clearing the access token will mean public access to the instance. + </i18n.Translate> + </p> + <div class="buttons is-right mt-5"> + <button + disabled={!!hasInputTheCorrectOldToken} + class="button" + onClick={onClearToken} + > + <i18n.Translate>Clear token</i18n.Translate> + </button> + </div> + </Fragment> + } + + <Input<State> + name="new_token" + label={i18n.str`New access token`} + tooltip={i18n.str`next access token to be used`} + inputType="password" + /> + <Input<State> + name="repeat_token" + label={i18n.str`Repeat access token`} + tooltip={i18n.str`confirm the same access token`} + inputType="password" + /> + </Fragment>} + </FormProvider> + <div class="buttons is-right mt-5"> + {onBack && ( + <button class="button" onClick={onBack}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + )} + <AsyncButton + disabled={hasErrors} + data-tooltip={ + hasErrors + ? i18n.str`Need to complete marked fields` + : "confirm operation" + } + onClick={submitForm} + > + <i18n.Translate>Confirm change</i18n.Translate> + </AsyncButton> + </div> + </div> + <div class="column" /> + </div> + + </section> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx new file mode 100644 index 000000000..d5910361b --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/token/index.tsx @@ -0,0 +1,90 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ +import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { ErrorType, HttpError, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { Loading } from "../../../components/exception/loading.js"; +import { MerchantBackend } from "../../../declaration.js"; +import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance.js"; +import { DetailPage } from "./DetailPage.js"; +import { useInstanceContext } from "../../../context/instance.js"; +import { useState } from "preact/hooks"; +import { NotificationCard } from "../../../components/menu/index.js"; +import { Notification } from "../../../utils/types.js"; +import { useBackendContext } from "../../../context/backend.js"; + +interface Props { + onUnauthorized: () => VNode; + onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; + onChange: () => void; + onNotFound: () => VNode; +} + +const PREFIX = "secret-token:" + +export default function Token({ + onLoadError, + onChange, + onUnauthorized, + onNotFound, +}: Props): VNode { + const { i18n } = useTranslationContext(); + + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { clearToken, setNewToken } = useInstanceAPI(); + const { token: rootToken } = useBackendContext(); + const { token: instanceToken, id, admin } = useInstanceContext(); + + const currentToken = !admin ? rootToken : instanceToken + const hasPrefix = currentToken !== undefined && currentToken.startsWith(PREFIX) + return ( + <Fragment> + <NotificationCard notification={notif} /> + <DetailPage + instanceId={id} + currentToken={hasPrefix ? currentToken.substring(PREFIX.length) : currentToken} + onClearToken={async (): Promise<void> => { + try { + await clearToken(); + onChange(); + } catch (error) { + if (error instanceof Error) { + setNotif({ + message: i18n.str`Failed to clear token`, + type: "ERROR", + description: error.message, + }); + } + } + }} + onNewToken={async (newToken): Promise<void> => { + try { + await setNewToken(`secret-token:${newToken}`); + onChange(); + } catch (error) { + if (error instanceof Error) { + setNotif({ + message: i18n.str`Failed to set new token`, + type: "ERROR", + description: error.message, + }); + } + } + }} + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/token/stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/token/stories.tsx new file mode 100644 index 000000000..5f0f56f2d --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/token/stories.tsx @@ -0,0 +1,28 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { DetailPage as TestedComponent } from "./DetailPage.js"; + +export default { + title: "Pages/Token", + component: TestedComponent, +}; + diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx index f218f4ead..25551a031 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/create/index.tsx @@ -28,6 +28,7 @@ import { useInstanceDetails } from "../../../../hooks/instance.js"; import { useTransferAPI } from "../../../../hooks/transfer.js"; import { Notification } from "../../../../utils/types.js"; import { CreatePage } from "./CreatePage.js"; +import { useBankAccountDetails, useInstanceBankAccounts } from "../../../../hooks/bank.js"; export type Entity = MerchantBackend.Transfers.TransferInformation; interface Props { @@ -39,7 +40,7 @@ export default function CreateTransfer({ onConfirm, onBack }: Props): VNode { const { informTransfer } = useTransferAPI(); const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); - const instance = useInstanceDetails(); + const instance = useInstanceBankAccounts(); const accounts = !instance.ok ? [] : instance.data.accounts.map((a) => a.payto_uri); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx index a2e93d598..1c464cbc7 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/Table.tsx @@ -24,6 +24,7 @@ import { format } from "date-fns"; import { h, VNode } from "preact"; import { StateUpdater, useState } from "preact/hooks"; import { MerchantBackend, WithId } from "../../../../declaration.js"; +import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; type Entity = MerchantBackend.Transfers.TransferDetails & WithId; @@ -56,7 +57,7 @@ export function CardTable({ <header class="card-header"> <p class="card-header-title"> <span class="icon"> - <i class="mdi mdi-bank" /> + <i class="mdi mdi-arrow-left-right" /> </span> <i18n.Translate>Transfers</i18n.Translate> </p> @@ -121,6 +122,7 @@ function Table({ hasMoreBefore, }: TableProps): VNode { const { i18n } = useTranslationContext(); + const [settings] = useSettings(); return ( <div class="table-container"> {onLoadMoreBefore && ( @@ -175,9 +177,9 @@ function Table({ ? i.execution_time.t_s == "never" ? i18n.str`never` : format( - i.execution_time.t_s * 1000, - "yyyy/MM/dd HH:mm:ss", - ) + i.execution_time.t_s * 1000, + datetimeFormatForSettings(settings), + ) : i18n.str`unknown`} </td> <td> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx index 29e860342..1bc1673ba 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/transfers/list/index.tsx @@ -28,6 +28,7 @@ import { useInstanceDetails } from "../../../../hooks/instance.js"; import { useInstanceTransfers } from "../../../../hooks/transfer.js"; import { ListPage } from "./ListPage.js"; import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { useInstanceBankAccounts } from "../../../../hooks/bank.js"; interface Props { onUnauthorized: () => VNode; @@ -51,7 +52,7 @@ export default function ListTransfer({ const [position, setPosition] = useState<string | undefined>(undefined); - const instance = useInstanceDetails(); + const instance = useInstanceBankAccounts(); const accounts = !instance.ok ? [] : instance.data.accounts.map((a) => a.payto_uri); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/Update.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/Update.stories.tsx index 045c96c2c..817a7025c 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/update/Update.stories.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/update/Update.stories.tsx @@ -42,17 +42,15 @@ function createExample<Props>( export const Example = createExample(TestedComponent, { selected: { - accounts: [], name: "name", auth: { method: "external" }, address: {}, + user_type: "business", + use_stefan: true, jurisdiction: {}, - default_max_deposit_fee: "TESTKUDOS:2", - default_max_wire_fee: "TESTKUDOS:1", default_pay_delay: { d_us: 1000 * 1000, //one second }, - default_wire_fee_amortization: 1, default_wire_transfer_delay: { d_us: 1000 * 1000, //one second }, diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx index 547b40f07..a1c608f15 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/update/UpdatePage.tsx @@ -19,7 +19,6 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { Amounts } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -29,10 +28,8 @@ import { FormProvider, } from "../../../components/form/FormProvider.js"; import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js"; -import { UpdateTokenModal } from "../../../components/modal/index.js"; import { useInstanceContext } from "../../../context/instance.js"; import { MerchantBackend } from "../../../declaration.js"; -import { PAYTO_REGEX } from "../../../utils/constants.js"; import { undefinedIfEmpty } from "../../../utils/table.js"; type Entity = MerchantBackend.Instances.InstanceReconfigurationMessage & { @@ -53,23 +50,23 @@ interface Props { function convert( from: MerchantBackend.Instances.QueryInstancesResponse, ): Entity { - const { accounts: qAccounts, ...rest } = from; - const accounts = qAccounts - .filter((a) => a.active) - .map( - (a) => - ({ - payto_uri: a.payto_uri, - credit_facade_url: a.credit_facade_url, - credit_facade_credentials: a.credit_facade_credentials, - } as MerchantBackend.Instances.MerchantBankAccount), - ); + const { ...rest } = from; + // const accounts = qAccounts + // .filter((a) => a.active) + // .map( + // (a) => + // ({ + // payto_uri: a.payto_uri, + // credit_facade_url: a.credit_facade_url, + // credit_facade_credentials: a.credit_facade_credentials, + // } as MerchantBackend.Instances.MerchantBankAccount), + // ); const defaults = { - default_wire_fee_amortization: 1, + use_stefan: false, default_pay_delay: { d_us: 2 * 1000 * 1000 * 60 * 60 }, //two hours default_wire_transfer_delay: { d_us: 2 * 1000 * 1000 * 60 * 60 * 2 }, //two hours }; - return { ...defaults, ...rest, accounts }; + return { ...defaults, ...rest }; } function getTokenValuePart(t?: string): string | undefined { @@ -85,21 +82,21 @@ export function UpdatePage({ selected, onBack, }: Props): VNode { - const { id, token } = useInstanceContext(); - const currentTokenValue = getTokenValuePart(token); - - function updateToken(token: string | undefined | null) { - const value = - token && token.startsWith("secret-token:") - ? token.substring("secret-token:".length) - : token; - - if (!token) { - onChangeAuth({ method: "external" }); - } else { - onChangeAuth({ method: "token", token: `secret-token:${value}` }); - } - } + const { id } = useInstanceContext(); + // const currentTokenValue = getTokenValuePart(token); + + // function updateToken(token: string | undefined | null) { + // const value = + // token && token.startsWith("secret-token:") + // ? token.substring("secret-token:".length) + // : token; + + // if (!token) { + // onChangeAuth({ method: "external" }); + // } else { + // onChangeAuth({ method: "token", token: `secret-token:${value}` }); + // } + // } const [value, valueHandler] = useState<Partial<Entity>>(convert(selected)); @@ -110,35 +107,7 @@ export function UpdatePage({ user_type: !value.user_type ? i18n.str`required` : value.user_type !== "business" && value.user_type !== "individual" - ? i18n.str`should be business or individual` - : undefined, - accounts: - !value.accounts || !value.accounts.length - ? i18n.str`required` - : undefinedIfEmpty( - value.accounts.map((p) => { - return !PAYTO_REGEX.test(p.payto_uri) - ? i18n.str`is not valid` - : undefined; - }), - ), - default_max_deposit_fee: !value.default_max_deposit_fee - ? i18n.str`required` - : !Amounts.parse(value.default_max_deposit_fee) - ? i18n.str`invalid format` - : undefined, - default_max_wire_fee: !value.default_max_wire_fee - ? i18n.str`required` - : !Amounts.parse(value.default_max_wire_fee) - ? i18n.str`invalid format` - : undefined, - default_wire_fee_amortization: - value.default_wire_fee_amortization === undefined - ? i18n.str`required` - : isNaN(value.default_wire_fee_amortization) - ? i18n.str`is not a number` - : value.default_wire_fee_amortization < 1 - ? i18n.str`must be 1 or greater` + ? i18n.str`should be business or individual` : undefined, default_pay_delay: !value.default_pay_delay ? i18n.str`required` @@ -163,10 +132,11 @@ export function UpdatePage({ const hasErrors = Object.keys(errors).some( (k) => (errors as any)[k] !== undefined, ); + const submit = async (): Promise<void> => { await onUpdate(value as Entity); }; - const [active, setActive] = useState(false); + // const [active, setActive] = useState(false); return ( <div> @@ -181,7 +151,7 @@ export function UpdatePage({ </span> </div> </div> - <div class="level-right"> + {/* <div class="level-right"> <div class="level-item"> <h1 class="title"> <button @@ -200,33 +170,11 @@ export function UpdatePage({ </button> </h1> </div> - </div> + </div> */} </div> </div> </section> - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> - {active && ( - <UpdateTokenModal - oldToken={currentTokenValue} - onCancel={() => { - setActive(false); - }} - onClear={() => { - updateToken(null); - setActive(false); - }} - onConfirm={(newToken) => { - updateToken(newToken); - setActive(false); - }} - /> - )} - </div> - <div class="column" /> - </div> <hr /> <div class="columns"> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/create/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/Create.stories.tsx new file mode 100644 index 000000000..56762db7b --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/Create.stories.tsx @@ -0,0 +1,28 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { h, VNode, FunctionalComponent } from "preact"; +import { CreatePage as TestedComponent } from "./CreatePage.js"; + +export default { + title: "Pages/Validators/Create", + component: TestedComponent, +}; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatePage.tsx new file mode 100644 index 000000000..bdc86d226 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatePage.tsx @@ -0,0 +1,195 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; +import { + FormErrors, + FormProvider, +} from "../../../../components/form/FormProvider.js"; +import { Input } from "../../../../components/form/Input.js"; +import { InputCurrency } from "../../../../components/form/InputCurrency.js"; +import { InputDuration } from "../../../../components/form/InputDuration.js"; +import { InputNumber } from "../../../../components/form/InputNumber.js"; +import { useBackendContext } from "../../../../context/backend.js"; +import { MerchantBackend } from "../../../../declaration.js"; +import { InputSelector } from "../../../../components/form/InputSelector.js"; +import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; +import { isBase32RFC3548Charset, randomBase32Key } from "../../../../utils/crypto.js"; +import { QR } from "../../../../components/exception/QR.js"; +import { useInstanceContext } from "../../../../context/instance.js"; + +type Entity = MerchantBackend.OTP.OtpDeviceAddDetails; + +interface Props { + onCreate: (d: Entity) => Promise<void>; + onBack?: () => void; +} + +const algorithms = [0, 1, 2]; +const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"]; + + +export function CreatePage({ onCreate, onBack }: Props): VNode { + const { i18n } = useTranslationContext(); + const backend = useBackendContext(); + + const [state, setState] = useState<Partial<Entity>>({}); + + const [showKey, setShowKey] = useState(false); + + const errors: FormErrors<Entity> = { + otp_device_id: !state.otp_device_id ? i18n.str`required` + : !/[a-zA-Z0-9]*/.test(state.otp_device_id) + ? i18n.str`no valid. only characters and numbers` + : undefined, + otp_algorithm: !state.otp_algorithm ? i18n.str`required` : undefined, + otp_key: !state.otp_key ? i18n.str`required` : + !isBase32RFC3548Charset(state.otp_key) + ? i18n.str`just letters and numbers from 2 to 7` + : state.otp_key.length !== 32 + ? i18n.str`size of the key should be 32` + : undefined, + otp_description: !state.otp_description ? i18n.str`required` + : !/[a-zA-Z0-9]*/.test(state.otp_description) + ? i18n.str`no valid. only characters and numbers` + : undefined, + + }; + + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const submitForm = () => { + if (hasErrors) return Promise.reject(); + return onCreate(state as any); + }; + + return ( + <div> + <section class="section is-main-section"> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <FormProvider + object={state} + valueHandler={setState} + errors={errors} + > + <Input<Entity> + name="otp_device_id" + label={i18n.str`ID`} + tooltip={i18n.str`Internal id on the system`} + /> + <Input<Entity> + name="otp_description" + label={i18n.str`Descripiton`} + tooltip={i18n.str`Useful to identify the device physically`} + /> + <InputSelector<Entity> + name="otp_algorithm" + label={i18n.str`Verification algorithm`} + tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`} + values={algorithms} + toStr={(v) => algorithmsNames[v]} + fromStr={(v) => Number(v)} + /> + {state.otp_algorithm && state.otp_algorithm > 0 ? ( + <Fragment> + <InputWithAddon<Entity> + name="otp_key" + label={i18n.str`Device key`} + inputType={showKey ? "text" : "password"} + help="Be sure to be very hard to guess or use the random generator" + tooltip={i18n.str`Your device need to have exactly the same value`} + fromStr={(v) => v.toUpperCase()} + addonAfter={ + <span class="icon"> + {showKey ? ( + <i class="mdi mdi-eye" /> + ) : ( + <i class="mdi mdi-eye-off" /> + )} + </span> + } + side={ + <span style={{ display: "flex" }}> + <button + data-tooltip={i18n.str`generate random secret key`} + class="button is-info mr-3" + onClick={(e) => { + setState((s) => ({ ...s, otp_key: randomBase32Key() })); + }} + > + <i18n.Translate>random</i18n.Translate> + </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> + } + /> + </Fragment> + ) : undefined} + </FormProvider> + + <div class="buttons is-right mt-5"> + {onBack && ( + <button class="button" onClick={onBack}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + )} + <AsyncButton + disabled={hasErrors} + data-tooltip={ + hasErrors + ? i18n.str`Need to complete marked fields` + : "confirm operation" + } + onClick={submitForm} + > + <i18n.Translate>Confirm</i18n.Translate> + </AsyncButton> + </div> + </div> + <div class="column" /> + </div> + </section> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatedSuccessfully.tsx new file mode 100644 index 000000000..3ad3cb3a3 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/CreatedSuccessfully.tsx @@ -0,0 +1,104 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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/> + */ + +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { QR } from "../../../../components/exception/QR.js"; +import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js"; +import { useBackendContext } from "../../../../context/backend.js"; +import { useInstanceContext } from "../../../../context/instance.js"; +import { MerchantBackend } from "../../../../declaration.js"; + +type Entity = MerchantBackend.OTP.OtpDeviceAddDetails; + +interface Props { + entity: Entity; + onConfirm: () => void; +} + +function isNotUndefined<X>(x: X | undefined): x is X { + return !!x; +} + +export function CreatedSuccessfully({ + entity, + onConfirm, +}: Props): VNode { + const { i18n } = useTranslationContext(); + const backend = useBackendContext(); + const { id: instanceId } = useInstanceContext(); + const issuer = new URL(backend.url).hostname; + const qrText = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key}`; + const qrTextSafe = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key.substring(0, 6)}...`; + + return ( + <Template onConfirm={onConfirm} > + <p class="is-size-5"> + <i18n.Translate> + You can scan the next QR code with your device or safe the key before continue. + </i18n.Translate> + </p> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label">ID</label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class="control"> + <input + readonly + class="input" + value={entity.otp_device_id} + /> + </p> + </div> + </div> + </div> + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"><i18n.Translate>Description</i18n.Translate></label> + </div> + <div class="field-body is-flex-grow-3"> + <div class="field"> + <p class="control"> + <input + class="input" + readonly + value={entity.otp_description} + /> + </p> + </div> + </div> + </div> + <QR + text={qrText} + /> + <div + style={{ + color: "grey", + fontSize: "small", + width: 200, + textAlign: "center", + margin: "auto", + wordBreak: "break-all", + }} + > + {qrTextSafe} + </div> + </Template> + ); +} + diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/index.tsx new file mode 100644 index 000000000..648846793 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/create/index.tsx @@ -0,0 +1,70 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { MerchantBackend } from "../../../../declaration.js"; +import { useWebhookAPI } from "../../../../hooks/webhooks.js"; +import { Notification } from "../../../../utils/types.js"; +import { CreatePage } from "./CreatePage.js"; +import { useOtpDeviceAPI } from "../../../../hooks/otp.js"; +import { CreatedSuccessfully } from "./CreatedSuccessfully.js"; + +export type Entity = MerchantBackend.OTP.OtpDeviceAddDetails; +interface Props { + onBack?: () => void; + onConfirm: () => void; +} + +export default function CreateValidator({ onConfirm, onBack }: Props): VNode { + const { createOtpDevice } = useOtpDeviceAPI(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { i18n } = useTranslationContext(); + const [created, setCreated] = useState<MerchantBackend.OTP.OtpDeviceAddDetails | null>(null) + + if (created) { + return <CreatedSuccessfully entity={created} onConfirm={onConfirm} /> + } + + return ( + <> + <NotificationCard notification={notif} /> + <CreatePage + onBack={onBack} + onCreate={(request: Entity) => { + return createOtpDevice(request) + .then((d) => { + setCreated(request) + }) + .catch((error) => { + setNotif({ + message: i18n.str`could not create device`, + type: "ERROR", + description: error.message, + }); + }); + }} + /> + </> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/list/List.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/List.stories.tsx new file mode 100644 index 000000000..3aa491c53 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/List.stories.tsx @@ -0,0 +1,28 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { FunctionalComponent, h } from "preact"; +import { ListPage as TestedComponent } from "./ListPage.js"; + +export default { + title: "Pages/Validators/List", + component: TestedComponent, +}; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/list/ListPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/ListPage.tsx new file mode 100644 index 000000000..4efee9781 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/ListPage.tsx @@ -0,0 +1,64 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { h, VNode } from "preact"; +import { MerchantBackend } from "../../../../declaration.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { CardTable } from "./Table.js"; + +export interface Props { + devices: MerchantBackend.OTP.OtpDeviceEntry[]; + onLoadMoreBefore?: () => void; + onLoadMoreAfter?: () => void; + onCreate: () => void; + onDelete: (e: MerchantBackend.OTP.OtpDeviceEntry) => void; + onSelect: (e: MerchantBackend.OTP.OtpDeviceEntry) => void; +} + +export function ListPage({ + devices, + onCreate, + onDelete, + onSelect, + onLoadMoreBefore, + onLoadMoreAfter, +}: Props): VNode { + const form = { payto_uri: "" }; + + const { i18n } = useTranslationContext(); + return ( + <section class="section is-main-section"> + <CardTable + devices={devices.map((o) => ({ + ...o, + id: String(o.otp_device_id), + }))} + onCreate={onCreate} + onDelete={onDelete} + onSelect={onSelect} + onLoadMoreBefore={onLoadMoreBefore} + hasMoreBefore={!onLoadMoreBefore} + onLoadMoreAfter={onLoadMoreAfter} + hasMoreAfter={!onLoadMoreAfter} + /> + </section> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/Table.tsx new file mode 100644 index 000000000..b639a6134 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/Table.tsx @@ -0,0 +1,213 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { h, VNode } from "preact"; +import { StateUpdater, useState } from "preact/hooks"; +import { MerchantBackend } from "../../../../declaration.js"; + +type Entity = MerchantBackend.OTP.OtpDeviceEntry; + +interface Props { + devices: Entity[]; + onDelete: (e: Entity) => void; + onSelect: (e: Entity) => void; + onCreate: () => void; + onLoadMoreBefore?: () => void; + hasMoreBefore?: boolean; + hasMoreAfter?: boolean; + onLoadMoreAfter?: () => void; +} + +export function CardTable({ + devices, + onCreate, + onDelete, + onSelect, + onLoadMoreAfter, + onLoadMoreBefore, + hasMoreAfter, + hasMoreBefore, +}: Props): VNode { + const [rowSelection, rowSelectionHandler] = useState<string[]>([]); + + const { i18n } = useTranslationContext(); + + return ( + <div class="card has-table"> + <header class="card-header"> + <p class="card-header-title"> + <span class="icon"> + <i class="mdi mdi-newspaper" /> + </span> + <i18n.Translate>OTP Devices</i18n.Translate> + </p> + <div class="card-header-icon" aria-label="more options"> + <span + class="has-tooltip-left" + data-tooltip={i18n.str`add new devices`} + > + <button class="button is-info" type="button" onClick={onCreate}> + <span class="icon is-small"> + <i class="mdi mdi-plus mdi-36px" /> + </span> + </button> + </span> + </div> + </header> + <div class="card-content"> + <div class="b-table has-pagination"> + <div class="table-wrapper has-mobile-cards"> + {devices.length > 0 ? ( + <Table + instances={devices} + onDelete={onDelete} + onSelect={onSelect} + rowSelection={rowSelection} + rowSelectionHandler={rowSelectionHandler} + onLoadMoreAfter={onLoadMoreAfter} + onLoadMoreBefore={onLoadMoreBefore} + hasMoreAfter={hasMoreAfter} + hasMoreBefore={hasMoreBefore} + /> + ) : ( + <EmptyTable /> + )} + </div> + </div> + </div> + </div> + ); +} +interface TableProps { + rowSelection: string[]; + instances: Entity[]; + onDelete: (e: Entity) => void; + onSelect: (e: Entity) => void; + rowSelectionHandler: StateUpdater<string[]>; + onLoadMoreBefore?: () => void; + hasMoreBefore?: boolean; + hasMoreAfter?: boolean; + onLoadMoreAfter?: () => void; +} + +function toggleSelected<T>(id: T): (prev: T[]) => T[] { + return (prev: T[]): T[] => + prev.indexOf(id) == -1 ? [...prev, id] : prev.filter((e) => e != id); +} + +function Table({ + instances, + onLoadMoreAfter, + onDelete, + onSelect, + onLoadMoreBefore, + hasMoreAfter, + hasMoreBefore, +}: TableProps): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="table-container"> + {onLoadMoreBefore && ( + <button + class="button is-fullwidth" + data-tooltip={i18n.str`load more devices before the first one`} + disabled={!hasMoreBefore} + onClick={onLoadMoreBefore} + > + <i18n.Translate>load newer devices</i18n.Translate> + </button> + )} + <table class="table is-fullwidth is-striped is-hoverable is-fullwidth"> + <thead> + <tr> + <th> + <i18n.Translate>ID</i18n.Translate> + </th> + <th> + <i18n.Translate>Description</i18n.Translate> + </th> + <th /> + </tr> + </thead> + <tbody> + {instances.map((i) => { + return ( + <tr key={i.otp_device_id}> + <td + onClick={(): void => onSelect(i)} + style={{ cursor: "pointer" }} + > + {i.otp_device_id} + </td> + <td + onClick={(): void => onSelect(i)} + style={{ cursor: "pointer" }} + > + {i.otp_device_id} + </td> + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + <button + class="button is-danger is-small has-tooltip-left" + data-tooltip={i18n.str`delete selected devices from the database`} + onClick={() => onDelete(i)} + > + Delete + </button> + </div> + </td> + </tr> + ); + })} + </tbody> + </table> + {onLoadMoreAfter && ( + <button + class="button is-fullwidth" + data-tooltip={i18n.str`load more devices after the last one`} + disabled={!hasMoreAfter} + onClick={onLoadMoreAfter} + > + <i18n.Translate>load older devices</i18n.Translate> + </button> + )} + </div> + ); +} + +function EmptyTable(): VNode { + const { i18n } = useTranslationContext(); + return ( + <div class="content has-text-grey has-text-centered"> + <p> + <span class="icon is-large"> + <i class="mdi mdi-emoticon-sad mdi-48px" /> + </span> + </p> + <p> + <i18n.Translate> + There is no devices yet, add more pressing the + sign + </i18n.Translate> + </p> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/index.tsx new file mode 100644 index 000000000..8837c848b --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/list/index.tsx @@ -0,0 +1,106 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { HttpStatusCode } from "@gnu-taler/taler-util"; +import { + ErrorType, + HttpError, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { Loading } from "../../../../components/exception/loading.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { MerchantBackend } from "../../../../declaration.js"; +import { useInstanceOtpDevices, useOtpDeviceAPI } from "../../../../hooks/otp.js"; +import { Notification } from "../../../../utils/types.js"; +import { ListPage } from "./ListPage.js"; + +interface Props { + onUnauthorized: () => VNode; + onLoadError: (error: HttpError<MerchantBackend.ErrorDetail>) => VNode; + onNotFound: () => VNode; + onCreate: () => void; + onSelect: (id: string) => void; +} + +export default function ListValidators({ + onUnauthorized, + onLoadError, + onCreate, + onSelect, + onNotFound, +}: Props): VNode { + const [position, setPosition] = useState<string | undefined>(undefined); + const { i18n } = useTranslationContext(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const { deleteOtpDevice } = useOtpDeviceAPI(); + const result = useInstanceOtpDevices({ position }, (id) => setPosition(id)); + + if (result.loading) return <Loading />; + if (!result.ok) { + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.Unauthorized + ) + return onUnauthorized(); + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.NotFound + ) + return onNotFound(); + return onLoadError(result); + } + + return ( + <Fragment> + <NotificationCard notification={notif} /> + + <ListPage + devices={result.data.otp_devices} + onLoadMoreBefore={ + result.isReachingStart ? result.loadMorePrev : undefined + } + onLoadMoreAfter={result.isReachingEnd ? result.loadMore : undefined} + onCreate={onCreate} + onSelect={(e) => { + onSelect(e.otp_device_id); + }} + onDelete={(e: MerchantBackend.OTP.OtpDeviceEntry) => + deleteOtpDevice(e.otp_device_id) + .then(() => + setNotif({ + message: i18n.str`validator delete successfully`, + type: "SUCCESS", + }), + ) + .catch((error) => + setNotif({ + message: i18n.str`could not delete the validator`, + type: "ERROR", + description: error.message, + }), + ) + } + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/update/Update.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/update/Update.stories.tsx new file mode 100644 index 000000000..fcb77b820 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/update/Update.stories.tsx @@ -0,0 +1,32 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { h, VNode, FunctionalComponent } from "preact"; +import { UpdatePage as TestedComponent } from "./UpdatePage.js"; + +export default { + title: "Pages/Validators/Update", + component: TestedComponent, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/update/UpdatePage.tsx new file mode 100644 index 000000000..585c12e11 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/update/UpdatePage.tsx @@ -0,0 +1,185 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; +import { + FormErrors, + FormProvider, +} from "../../../../components/form/FormProvider.js"; +import { Input } from "../../../../components/form/Input.js"; +import { MerchantBackend, WithId } from "../../../../declaration.js"; +import { InputSelector } from "../../../../components/form/InputSelector.js"; +import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; +import { randomBase32Key } from "../../../../utils/crypto.js"; + +type Entity = MerchantBackend.OTP.OtpDevicePatchDetails & WithId; + +interface Props { + onUpdate: (d: Entity) => Promise<void>; + onBack?: () => void; + device: Entity; +} +const algorithms = [0, 1, 2]; +const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"]; +export function UpdatePage({ device, onUpdate, onBack }: Props): VNode { + const { i18n } = useTranslationContext(); + + const [state, setState] = useState<Partial<Entity>>(device); + const [showKey, setShowKey] = useState(false); + + const errors: FormErrors<Entity> = { + }; + + const hasErrors = Object.keys(errors).some( + (k) => (errors as any)[k] !== undefined, + ); + + const submitForm = () => { + if (hasErrors) return Promise.reject(); + return onUpdate(state as any); + }; + + return ( + <div> + <section class="section"> + <section class="hero is-hero-bar"> + <div class="hero-body"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <span class="is-size-4"> + Device: <b>{device.id}</b> + </span> + </div> + </div> + </div> + </div> + </section> + <hr /> + + <section class="section is-main-section"> + <div class="columns"> + <div class="column is-four-fifths"> + <FormProvider + object={state} + valueHandler={setState} + errors={errors} + > + <Input<Entity> + name="otp_description" + label={i18n.str`Description`} + tooltip={i18n.str`dddd`} + /> + <InputSelector<Entity> + name="otp_algorithm" + label={i18n.str`Verification algorithm`} + tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`} + values={algorithms} + toStr={(v) => algorithmsNames[v]} + fromStr={(v) => Number(v)} + /> + {state.otp_algorithm && state.otp_algorithm > 0 ? ( + <Fragment> + <InputWithAddon<Entity> + name="otp_key" + label={i18n.str`Device key`} + readonly={state.otp_key === undefined} + inputType={showKey ? "text" : "password"} + help={state.otp_key === undefined ? "Not modified" : "Be sure to be very hard to guess or use the random generator"} + tooltip={i18n.str`Your device need to have exactly the same value`} + fromStr={(v) => v.toUpperCase()} + addonAfter={ + <span class="icon"> + {showKey ? ( + <i class="mdi mdi-eye" /> + ) : ( + <i class="mdi mdi-eye-off" /> + )} + </span> + } + side={ + state.otp_key === undefined ? <button + + onClick={(e) => { + setState((s) => ({ ...s, otp_key: "" })); + }} + class="button">change key</button> : + <span style={{ display: "flex" }}> + <button + data-tooltip={i18n.str`generate random secret key`} + class="button is-info mr-3" + onClick={(e) => { + setState((s) => ({ ...s, otp_key: randomBase32Key() })); + }} + > + <i18n.Translate>random</i18n.Translate> + </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> + } + /> + </Fragment> + ) : undefined} </FormProvider> + + <div class="buttons is-right mt-5"> + {onBack && ( + <button class="button" onClick={onBack}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + )} + <AsyncButton + disabled={hasErrors} + data-tooltip={ + hasErrors + ? i18n.str`Need to complete marked fields` + : "confirm operation" + } + onClick={submitForm} + > + <i18n.Translate>Confirm</i18n.Translate> + </AsyncButton> + </div> + </div> + </div> + </section> + </section> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/validators/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/validators/update/index.tsx new file mode 100644 index 000000000..9a27ccfee --- /dev/null +++ b/packages/merchant-backoffice-ui/src/paths/instance/validators/update/index.tsx @@ -0,0 +1,102 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 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 { + ErrorType, + HttpError, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { Loading } from "../../../../components/exception/loading.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { MerchantBackend, WithId } from "../../../../declaration.js"; +import { Notification } from "../../../../utils/types.js"; +import { UpdatePage } from "./UpdatePage.js"; +import { HttpStatusCode } from "@gnu-taler/taler-util"; +import { useOtpDeviceAPI, useOtpDeviceDetails } from "../../../../hooks/otp.js"; + +export type Entity = MerchantBackend.OTP.OtpDevicePatchDetails & WithId; + +interface Props { + onBack?: () => void; + onConfirm: () => void; + onUnauthorized: () => VNode; + onNotFound: () => VNode; + onLoadError: (e: HttpError<MerchantBackend.ErrorDetail>) => VNode; + vid: string; +} +export default function UpdateValidator({ + vid, + onConfirm, + onBack, + onUnauthorized, + onNotFound, + onLoadError, +}: Props): VNode { + const { updateOtpDevice } = useOtpDeviceAPI(); + const result = useOtpDeviceDetails(vid); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + + const { i18n } = useTranslationContext(); + + if (result.loading) return <Loading />; + if (!result.ok) { + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.Unauthorized + ) + return onUnauthorized(); + if ( + result.type === ErrorType.CLIENT && + result.status === HttpStatusCode.NotFound + ) + return onNotFound(); + return onLoadError(result); + } + + return ( + <Fragment> + <NotificationCard notification={notif} /> + <UpdatePage + device={{ + id: vid, + otp_algorithm: result.data.otp_algorithm, + otp_description: result.data.device_description, + otp_key: undefined, + otp_ctr: result.data.otp_ctr + }} + onBack={onBack} + onUpdate={(data) => { + return updateOtpDevice(vid, data) + .then(onConfirm) + .catch((error) => { + setNotif({ + message: i18n.str`could not update template`, + type: "ERROR", + description: error.message, + }); + }); + }} + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx index fd7b08875..124ced1f1 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/webhooks/list/Table.tsx @@ -81,9 +81,6 @@ export function CardTable({ instances={webhooks} onDelete={onDelete} onSelect={onSelect} - onNewOrder={(d) => { - console.log("test", d); - }} rowSelection={rowSelection} rowSelectionHandler={rowSelectionHandler} onLoadMoreAfter={onLoadMoreAfter} @@ -104,7 +101,6 @@ interface TableProps { rowSelection: string[]; instances: Entity[]; onDelete: (e: Entity) => void; - onNewOrder: (e: Entity) => void; onSelect: (e: Entity) => void; rowSelectionHandler: StateUpdater<string[]>; onLoadMoreBefore?: () => void; @@ -122,7 +118,6 @@ function Table({ instances, onLoadMoreAfter, onDelete, - onNewOrder, onSelect, onLoadMoreBefore, hasMoreAfter, diff --git a/packages/merchant-backoffice-ui/src/paths/settings/index.tsx b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx index 128450553..0d514f2df 100644 --- a/packages/merchant-backoffice-ui/src/paths/settings/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx @@ -1,10 +1,10 @@ -import { VNode, h } from "preact"; -import { LangSelector } from "../../components/menu/LangSelector.js"; import { useLang, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { FormErrors, FormProvider } from "../../components/form/FormProvider.js"; +import { InputSelector } from "../../components/form/InputSelector.js"; import { InputToggle } from "../../components/form/InputToggle.js"; +import { LangSelector } from "../../components/menu/LangSelector.js"; import { Settings, useSettings } from "../../hooks/useSettings.js"; -import { FormErrors, FormProvider } from "../../components/form/FormProvider.js"; -import { useState } from "preact/hooks"; function getBrowserLang(): string | undefined { if (typeof window === "undefined") return undefined; @@ -24,7 +24,11 @@ export function Settings(): VNode { function valueHandler(s: (d: Partial<Settings>) => Partial<Settings>): void { const next = s(value) - updateValue("advanceOrderMode", next.advanceOrderMode ?? false) + const v: Settings = { + advanceOrderMode: next.advanceOrderMode ?? false, + dateFormat: next.dateFormat ?? "ymd" + } + updateValue(v) } return <div> @@ -32,41 +36,64 @@ export function Settings(): VNode { <div class="columns"> <div class="column" /> <div class="column is-four-fifths"> - <div class="field is-horizontal"> - <div class="field-label is-normal"> - <label class="label" style={{ width: 200 }}> - <i18n.Translate>Language</i18n.Translate> - <span class="icon has-tooltip-right" data-tooltip={"Force language setting instance of taking the browser"}> - <i class="mdi mdi-information" /> - </span> - </label> - </div> - <div class="field has-addons"> - <LangSelector /> - - {borwserLang !== undefined && <button - data-tooltip={i18n.str`generate random secret key`} - class="button is-info mr-3" - onClick={(e) => { - update(borwserLang.substring(0, 2)) + <div> + + <FormProvider<Settings> + name="settings" + errors={errors} + object={value} + valueHandler={valueHandler} + > + <div class="field is-horizontal"> + <div class="field-label is-normal"> + <label class="label"> + <i18n.Translate>Language</i18n.Translate> + <span class="icon has-tooltip-right" data-tooltip={"Force language setting instance of taking the browser"}> + <i class="mdi mdi-information" /> + </span> + </label> + </div> + <div class="field field-body has-addons is-flex-grow-3"> + <LangSelector /> + + {borwserLang !== undefined && <button + data-tooltip={i18n.str`generate random secret key`} + class="button is-info mr-2" + onClick={(e) => { + update(borwserLang.substring(0, 2)) + }} + > + <i18n.Translate>Set default</i18n.Translate> + </button>} + </div> + </div> + <InputToggle<Settings> + label={i18n.str`Advance order creation`} + tooltip={i18n.str`Shows more options in the order creation form`} + name="advanceOrderMode" + /> + <InputSelector<Settings> + name="dateFormat" + label={i18n.str`Date format`} + expand={true} + help={ + value.dateFormat === "dmy" ? "31/12/2001" : value.dateFormat === "mdy" ? "12/31/2001" : value.dateFormat === "ymd" ? "2001/12/31" : "" + } + toStr={(e) => { + if (e === "ymd") return "year month day" + if (e === "mdy") return "month day year" + if (e === "dmy") return "day month year" + return "choose one" }} - > - <i18n.Translate>Set default</i18n.Translate> - </button>} - </div> + values={[ + "ymd", + "mdy", + "dmy", + ]} + tooltip={i18n.str`how the date is going to be displayed`} + /> + </FormProvider> </div> - <FormProvider<Settings> - name="settings" - errors={errors} - object={value} - valueHandler={valueHandler} - > - <InputToggle<Settings> - label={i18n.str`Advance order creation`} - tooltip={i18n.str`Shows more options in the order creation form`} - name="advanceOrderMode" - /> - </FormProvider> </div> |