diff options
Diffstat (limited to 'packages')
10 files changed, 227 insertions, 20 deletions
diff --git a/packages/merchant-backoffice-ui/src/components/form/InputSelector.tsx b/packages/merchant-backoffice-ui/src/components/form/InputSelector.tsx index 7a419ebb9..021977dfe 100644 --- a/packages/merchant-backoffice-ui/src/components/form/InputSelector.tsx +++ b/packages/merchant-backoffice-ui/src/components/form/InputSelector.tsx @@ -25,6 +25,7 @@ interface Props<T> extends InputProps<T> { readonly?: boolean; expand?: boolean; values: string[]; + convert?: (v: string) => any; toStr?: (v?: any) => string; fromStr?: (s: string) => any; } @@ -41,6 +42,7 @@ export function InputSelector<T>({ label, help, values, + convert, toStr = defaultToString, }: Props<keyof T>): VNode { const { error, value, onChange } = useField<T>(name); @@ -66,7 +68,10 @@ export function InputSelector<T>({ disabled={readonly} readonly={readonly} onChange={(e) => { - onChange(e.currentTarget.value as any); + const v = convert + ? convert(e.currentTarget.value) + : e.currentTarget.value; + onChange(v); }} > {placeholder && <option>{placeholder}</option>} diff --git a/packages/merchant-backoffice-ui/src/declaration.d.ts b/packages/merchant-backoffice-ui/src/declaration.d.ts index c9380760c..9fc4f0d77 100644 --- a/packages/merchant-backoffice-ui/src/declaration.d.ts +++ b/packages/merchant-backoffice-ui/src/declaration.d.ts @@ -1287,6 +1287,9 @@ export namespace MerchantBackend { // This parameter is optional. pos_key?: string; + // Algorithm for computing the POS confirmation, 0 for none. + pos_algorithm?: number; + // Additional information in a separate template. template_contract: TemplateContractDetails; } @@ -1313,6 +1316,9 @@ export namespace MerchantBackend { // This parameter is optional. pos_key?: string; + // Algorithm for computing the POS confirmation, 0 for none. + pos_algorithm?: Integer; + // Additional information in a separate template. template_contract: TemplateContractDetails; } @@ -1338,6 +1344,9 @@ export namespace MerchantBackend { // This parameter is optional. pos_key?: string; + // Algorithm for computing the POS confirmation, 0 for none. + pos_algorithm?: Integer; + // Additional information in a separate template. template_contract: TemplateContractDetails; } diff --git a/packages/merchant-backoffice-ui/src/hooks/templates.ts b/packages/merchant-backoffice-ui/src/hooks/templates.ts index dd096e4f9..97fb165b9 100644 --- a/packages/merchant-backoffice-ui/src/hooks/templates.ts +++ b/packages/merchant-backoffice-ui/src/hooks/templates.ts @@ -244,7 +244,11 @@ export function useTemplateDetails( }); if (isValidating) return { loading: true, data: data?.data }; - if (data) return data; + if (data) { + const d = structuredClone(data); + d.data.pos_algorithm = 1; + return d; + } if (error) return error.info; return { loading: true }; } 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 22f86002a..144e968c5 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 @@ -31,9 +31,11 @@ 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 { MerchantBackend } from "../../../../declaration.js"; +import { randomBase32Key } from "../../../../utils/crypto.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; type Entity = MerchantBackend.Template.TemplateAddDetails; @@ -43,6 +45,13 @@ interface Props { onBack?: () => void; } +const algorithms = ["0", "1", "2"]; +const algorithmsNames = [ + "off", + "30s 8d TOTP-SHA1 without amount", + "30s 8d eTOTP-SHA1 with amount", +]; + export function CreatePage({ onCreate, onBack }: Props): VNode { const { i18n } = useTranslationContext(); const backend = useBackendContext(); @@ -104,7 +113,6 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { label={i18n.str`Identifier`} tooltip={i18n.str`Name of the template in URLs.`} /> - <Input<Entity> name="template_description" label={i18n.str`Description`} @@ -134,12 +142,35 @@ 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.`} /> - <Input<Entity> - name="pos_key" - label={i18n.str`Point-of-sale key`} - help="" - tooltip={i18n.str`Useful to validate the purchase`} + <InputSelector<Entity> + name="pos_algorithm" + label={i18n.str`Veritifaction algorithm`} + tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`} + values={algorithms} + toStr={(v) => algorithmsNames[v]} + convert={(v) => Number(v)} /> + {state.pos_algorithm && state.pos_algorithm > 0 ? ( + <Input<Entity> + name="pos_key" + label={i18n.str`Point-of-sale key`} + help="" + tooltip={i18n.str`Useful to validate the purchase`} + side={ + <span data-tooltip={i18n.str`generate random secret key`}> + <button + class="button is-info mr-3" + onClick={(e) => { + const pos_key = randomBase32Key(); + setState((s) => ({ ...s, pos_key })); + }} + > + <i18n.Translate>random</i18n.Translate> + </button> + </span> + } + /> + ) : undefined} </FormProvider> <div class="buttons is-right mt-5"> 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 756909d15..66ac72ff5 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 @@ -31,8 +31,10 @@ import { } from "../../../../components/form/FormProvider.js"; import { Input } from "../../../../components/form/Input.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js"; +import { ConfirmModal } from "../../../../components/modal/index.js"; import { useBackendContext } from "../../../../context/backend.js"; import { useConfigContext } from "../../../../context/config.js"; +import { useInstanceContext } from "../../../../context/instance.js"; import { MerchantBackend } from "../../../../declaration.js"; type Entity = MerchantBackend.Template.UsingTemplateDetails; @@ -46,7 +48,9 @@ interface Props { export function QrPage({ template, 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, @@ -82,8 +86,33 @@ export function QrPage({ template, id: templateId, onBack }: Props): VNode { const payTemplateUri = `${talerProto}//pay-template/${merchantURL.hostname}/${templateId}${paramsStr}`; + const issuer = encodeURIComponent( + `${new URL(backendUrl).hostname}/${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; return ( <div> + {oauthUri && ( + <ConfirmModal + description="Setup TOTP" + active={setupTOTP} + onConfirm={() => { + setSetupTOTP(false); + }} + > + <p>Scan this qr code with your TOTP device</p> + <QR text={oauthUri} /> + <pre style={{ textAlign: "center" }}> + <a href={oauthUri}>{oauthUri}</a> + </pre> + </ConfirmModal> + )} <section class="section is-main-section"> <div class="columns"> <div class="column" /> @@ -114,20 +143,48 @@ export function QrPage({ template, id: templateId, onBack }: Props): VNode { <i18n.Translate>Cancel</i18n.Translate> </button> )} - <button class="button is-info" onClick={onBack}> + <button + class="button is-info" + onClick={() => saveAsPDF(templateId)} + > <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" /> </div> </section> - <section> - <pre> + <section id="printThis"> + <QR text={payTemplateUri} /> + <pre style={{ textAlign: "center" }}> <a href={payTemplateUri}>{payTemplateUri}</a> </pre> - <QR text={payTemplateUri} /> </section> </div> ); } + +function saveAsPDF(name: string): void { + const printWindow = window.open("", "", "height=400,width=800"); + if (!printWindow) return; + const divContents = document.getElementById("printThis"); + if (!divContents) return; + printWindow.document.write( + `<html><head><title>Order template for ${name}</title><style>`, + ); + printWindow.document.write("</style></head><body> </body></html>"); + printWindow.document.close(); + printWindow.document.body.appendChild(divContents.cloneNode(true)); + printWindow.addEventListener("load", () => { + printWindow.print(); + printWindow.close(); + }); +} 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 97d25b700..044cc7d79 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 @@ -51,10 +51,8 @@ export default function TemplateQrPage({ onNotFound, onUnauthorized, }: Props): VNode { - const { createOrderFromTemplate } = useTemplateAPI(); const result = useTemplateDetails(tid); const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { i18n } = useTranslationContext(); if (result.clientError && result.isUnauthorized) return onUnauthorized(); if (result.clientError && result.isNotfound) return onNotFound(); 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 eba212517..e34e2c746 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 @@ -31,9 +31,11 @@ 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 { MerchantBackend, WithId } from "../../../../declaration.js"; +import { randomBase32Key } from "../../../../utils/crypto.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId; @@ -44,6 +46,13 @@ interface Props { template: Entity; } +const algorithms = ["0", "1", "2"]; +const algorithmsNames = [ + "off", + "30s 8d TOTP-SHA1 without amount", + "30s 8d eTOTP-SHA1 with amount", +]; + export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { const { i18n } = useTranslationContext(); const backend = useBackendContext(); @@ -143,12 +152,35 @@ 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.`} /> - <Input<Entity> - name="pos_key" - label={i18n.str`Point-of-sale key`} - help="" - tooltip={i18n.str`Useful to validate the purchase`} + <InputSelector<Entity> + name="pos_algorithm" + label={i18n.str`Veritifaction algorithm`} + tooltip={i18n.str`Algorithm to use to verify transaction in offline mode`} + values={algorithms} + toStr={(v) => algorithmsNames[v]} + convert={(v) => Number(v)} /> + {state.pos_algorithm && state.pos_algorithm > 0 ? ( + <Input<Entity> + name="pos_key" + label={i18n.str`Point-of-sale key`} + help="" + tooltip={i18n.str`Useful to validate the purchase`} + side={ + <span data-tooltip={i18n.str`generate random secret key`}> + <button + class="button is-info mr-3" + onClick={(e) => { + const pos_key = randomBase32Key(); + setState((s) => ({ ...s, pos_key })); + }} + > + <i18n.Translate>random</i18n.Translate> + </button> + </span> + } + /> + ) : undefined} </FormProvider> <div class="buttons is-right mt-5"> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/use/UsePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/use/UsePage.tsx index a63469763..5abc6b153 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/use/UsePage.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/use/UsePage.tsx @@ -34,12 +34,13 @@ import { MerchantBackend } from "../../../../declaration.js"; type Entity = MerchantBackend.Template.UsingTemplateDetails; interface Props { + id: string; template: MerchantBackend.Template.TemplateDetails; onCreateOrder: (d: Entity) => Promise<void>; onBack?: () => void; } -export function UsePage({ template, onCreateOrder, onBack }: Props): VNode { +export function UsePage({ id, template, onCreateOrder, onBack }: Props): VNode { const { i18n } = useTranslationContext(); const [state, setState] = useState<Partial<Entity>>({ @@ -75,6 +76,22 @@ export function UsePage({ template, onCreateOrder, onBack }: Props): VNode { 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"> + <i18n.Translate>New order for template</i18n.Translate>:{" "} + <b>{id}</b> + </span> + </div> + </div> + </div> + </div> + </section> + </section> <section class="section is-main-section"> <div class="columns"> <div class="column" /> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/use/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/use/index.tsx index d5fa6d39d..b6175bcfb 100644 --- a/packages/merchant-backoffice-ui/src/paths/instance/templates/use/index.tsx +++ b/packages/merchant-backoffice-ui/src/paths/instance/templates/use/index.tsx @@ -68,6 +68,7 @@ export default function TemplateUsePage({ <NotificationCard notification={notif} /> <UsePage template={result.data} + id={tid} onBack={onBack} onCreateOrder={( request: MerchantBackend.Template.UsingTemplateDetails, diff --git a/packages/merchant-backoffice-ui/src/utils/crypto.ts b/packages/merchant-backoffice-ui/src/utils/crypto.ts new file mode 100644 index 000000000..7bab8abf1 --- /dev/null +++ b/packages/merchant-backoffice-ui/src/utils/crypto.ts @@ -0,0 +1,53 @@ +/* + 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) + */ + +const encTable = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; +// base32 RFC 3548 +function encodeBase32(data: ArrayBuffer) { + const dataBytes = new Uint8Array(data); + let sb = ""; + const size = data.byteLength; + let bitBuf = 0; + let numBits = 0; + let pos = 0; + while (pos < size || numBits > 0) { + if (pos < size && numBits < 5) { + const d = dataBytes[pos++]; + bitBuf = (bitBuf << 8) | d; + numBits += 8; + } + if (numBits < 5) { + // zero-padding + bitBuf = bitBuf << (5 - numBits); + numBits = 5; + } + const v = (bitBuf >>> (numBits - 5)) & 31; + sb += encTable[v]; + numBits -= 5; + } + return sb; +} + +export function randomBase32Key(): string { + var buf = new Uint8Array(20); + window.crypto.getRandomValues(buf); + return encodeBase32(buf); +} |