print and setup totp

This commit is contained in:
Sebastian 2023-03-12 23:56:54 -03:00
parent ae1aee1358
commit b874f9a0c5
No known key found for this signature in database
GPG Key ID: 173909D1A5F66069
10 changed files with 227 additions and 20 deletions

View File

@ -25,6 +25,7 @@ interface Props<T> extends InputProps<T> {
readonly?: boolean; readonly?: boolean;
expand?: boolean; expand?: boolean;
values: string[]; values: string[];
convert?: (v: string) => any;
toStr?: (v?: any) => string; toStr?: (v?: any) => string;
fromStr?: (s: string) => any; fromStr?: (s: string) => any;
} }
@ -41,6 +42,7 @@ export function InputSelector<T>({
label, label,
help, help,
values, values,
convert,
toStr = defaultToString, toStr = defaultToString,
}: Props<keyof T>): VNode { }: Props<keyof T>): VNode {
const { error, value, onChange } = useField<T>(name); const { error, value, onChange } = useField<T>(name);
@ -66,7 +68,10 @@ export function InputSelector<T>({
disabled={readonly} disabled={readonly}
readonly={readonly} readonly={readonly}
onChange={(e) => { onChange={(e) => {
onChange(e.currentTarget.value as any); const v = convert
? convert(e.currentTarget.value)
: e.currentTarget.value;
onChange(v);
}} }}
> >
{placeholder && <option>{placeholder}</option>} {placeholder && <option>{placeholder}</option>}

View File

@ -1287,6 +1287,9 @@ export namespace MerchantBackend {
// This parameter is optional. // This parameter is optional.
pos_key?: string; pos_key?: string;
// Algorithm for computing the POS confirmation, 0 for none.
pos_algorithm?: number;
// Additional information in a separate template. // Additional information in a separate template.
template_contract: TemplateContractDetails; template_contract: TemplateContractDetails;
} }
@ -1313,6 +1316,9 @@ export namespace MerchantBackend {
// This parameter is optional. // This parameter is optional.
pos_key?: string; pos_key?: string;
// Algorithm for computing the POS confirmation, 0 for none.
pos_algorithm?: Integer;
// Additional information in a separate template. // Additional information in a separate template.
template_contract: TemplateContractDetails; template_contract: TemplateContractDetails;
} }
@ -1338,6 +1344,9 @@ export namespace MerchantBackend {
// This parameter is optional. // This parameter is optional.
pos_key?: string; pos_key?: string;
// Algorithm for computing the POS confirmation, 0 for none.
pos_algorithm?: Integer;
// Additional information in a separate template. // Additional information in a separate template.
template_contract: TemplateContractDetails; template_contract: TemplateContractDetails;
} }

View File

@ -244,7 +244,11 @@ export function useTemplateDetails(
}); });
if (isValidating) return { loading: true, data: data?.data }; 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; if (error) return error.info;
return { loading: true }; return { loading: true };
} }

View File

@ -31,9 +31,11 @@ import { Input } from "../../../../components/form/Input.js";
import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js";
import { InputDuration } from "../../../../components/form/InputDuration.js"; import { InputDuration } from "../../../../components/form/InputDuration.js";
import { InputNumber } from "../../../../components/form/InputNumber.js"; import { InputNumber } from "../../../../components/form/InputNumber.js";
import { InputSelector } from "../../../../components/form/InputSelector.js";
import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { randomBase32Key } from "../../../../utils/crypto.js";
import { undefinedIfEmpty } from "../../../../utils/table.js"; import { undefinedIfEmpty } from "../../../../utils/table.js";
type Entity = MerchantBackend.Template.TemplateAddDetails; type Entity = MerchantBackend.Template.TemplateAddDetails;
@ -43,6 +45,13 @@ interface Props {
onBack?: () => void; 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 { export function CreatePage({ onCreate, onBack }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext(); const backend = useBackendContext();
@ -104,7 +113,6 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
label={i18n.str`Identifier`} label={i18n.str`Identifier`}
tooltip={i18n.str`Name of the template in URLs.`} tooltip={i18n.str`Name of the template in URLs.`}
/> />
<Input<Entity> <Input<Entity>
name="template_description" name="template_description"
label={i18n.str`Description`} label={i18n.str`Description`}
@ -134,12 +142,35 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
help="" help=""
tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`} 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`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> <Input<Entity>
name="pos_key" name="pos_key"
label={i18n.str`Point-of-sale key`} label={i18n.str`Point-of-sale key`}
help="" help=""
tooltip={i18n.str`Useful to validate the purchase`} 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> </FormProvider>
<div class="buttons is-right mt-5"> <div class="buttons is-right mt-5">

View File

@ -31,8 +31,10 @@ import {
} from "../../../../components/form/FormProvider.js"; } from "../../../../components/form/FormProvider.js";
import { Input } from "../../../../components/form/Input.js"; import { Input } from "../../../../components/form/Input.js";
import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js";
import { ConfirmModal } from "../../../../components/modal/index.js";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { useConfigContext } from "../../../../context/config.js"; import { useConfigContext } from "../../../../context/config.js";
import { useInstanceContext } from "../../../../context/instance.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
type Entity = MerchantBackend.Template.UsingTemplateDetails; type Entity = MerchantBackend.Template.UsingTemplateDetails;
@ -46,7 +48,9 @@ interface Props {
export function QrPage({ template, id: templateId, onBack }: Props): VNode { export function QrPage({ template, id: templateId, onBack }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const { url: backendUrl } = useBackendContext(); const { url: backendUrl } = useBackendContext();
const { id: instanceId } = useInstanceContext();
const config = useConfigContext(); const config = useConfigContext();
const [setupTOTP, setSetupTOTP] = useState(false);
const [state, setState] = useState<Partial<Entity>>({ const [state, setState] = useState<Partial<Entity>>({
amount: template.template_contract.amount, 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 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 ( return (
<div> <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"> <section class="section is-main-section">
<div class="columns"> <div class="columns">
<div class="column" /> <div class="column" />
@ -114,20 +143,48 @@ export function QrPage({ template, id: templateId, onBack }: Props): VNode {
<i18n.Translate>Cancel</i18n.Translate> <i18n.Translate>Cancel</i18n.Translate>
</button> </button>
)} )}
<button class="button is-info" onClick={onBack}> <button
class="button is-info"
onClick={() => saveAsPDF(templateId)}
>
<i18n.Translate>Print</i18n.Translate> <i18n.Translate>Print</i18n.Translate>
</button> </button>
{oauthUri && (
<button
class="button is-info"
onClick={() => setSetupTOTP(true)}
>
<i18n.Translate>Setup TOTP</i18n.Translate>
</button>
)}
</div> </div>
</div> </div>
<div class="column" /> <div class="column" />
</div> </div>
</section> </section>
<section> <section id="printThis">
<pre> <QR text={payTemplateUri} />
<pre style={{ textAlign: "center" }}>
<a href={payTemplateUri}>{payTemplateUri}</a> <a href={payTemplateUri}>{payTemplateUri}</a>
</pre> </pre>
<QR text={payTemplateUri} />
</section> </section>
</div> </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>&nbsp;</body></html>");
printWindow.document.close();
printWindow.document.body.appendChild(divContents.cloneNode(true));
printWindow.addEventListener("load", () => {
printWindow.print();
printWindow.close();
});
}

View File

@ -51,10 +51,8 @@ export default function TemplateQrPage({
onNotFound, onNotFound,
onUnauthorized, onUnauthorized,
}: Props): VNode { }: Props): VNode {
const { createOrderFromTemplate } = useTemplateAPI();
const result = useTemplateDetails(tid); const result = useTemplateDetails(tid);
const [notif, setNotif] = useState<Notification | undefined>(undefined); const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext();
if (result.clientError && result.isUnauthorized) return onUnauthorized(); if (result.clientError && result.isUnauthorized) return onUnauthorized();
if (result.clientError && result.isNotfound) return onNotFound(); if (result.clientError && result.isNotfound) return onNotFound();

View File

@ -31,9 +31,11 @@ import { Input } from "../../../../components/form/Input.js";
import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js";
import { InputDuration } from "../../../../components/form/InputDuration.js"; import { InputDuration } from "../../../../components/form/InputDuration.js";
import { InputNumber } from "../../../../components/form/InputNumber.js"; import { InputNumber } from "../../../../components/form/InputNumber.js";
import { InputSelector } from "../../../../components/form/InputSelector.js";
import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { MerchantBackend, WithId } from "../../../../declaration.js"; import { MerchantBackend, WithId } from "../../../../declaration.js";
import { randomBase32Key } from "../../../../utils/crypto.js";
import { undefinedIfEmpty } from "../../../../utils/table.js"; import { undefinedIfEmpty } from "../../../../utils/table.js";
type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId; type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId;
@ -44,6 +46,13 @@ interface Props {
template: Entity; 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 { export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext(); const backend = useBackendContext();
@ -143,12 +152,35 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
help="" help=""
tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`} 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`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> <Input<Entity>
name="pos_key" name="pos_key"
label={i18n.str`Point-of-sale key`} label={i18n.str`Point-of-sale key`}
help="" help=""
tooltip={i18n.str`Useful to validate the purchase`} 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> </FormProvider>
<div class="buttons is-right mt-5"> <div class="buttons is-right mt-5">

View File

@ -34,12 +34,13 @@ import { MerchantBackend } from "../../../../declaration.js";
type Entity = MerchantBackend.Template.UsingTemplateDetails; type Entity = MerchantBackend.Template.UsingTemplateDetails;
interface Props { interface Props {
id: string;
template: MerchantBackend.Template.TemplateDetails; template: MerchantBackend.Template.TemplateDetails;
onCreateOrder: (d: Entity) => Promise<void>; onCreateOrder: (d: Entity) => Promise<void>;
onBack?: () => void; onBack?: () => void;
} }
export function UsePage({ template, onCreateOrder, onBack }: Props): VNode { export function UsePage({ id, template, onCreateOrder, onBack }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const [state, setState] = useState<Partial<Entity>>({ const [state, setState] = useState<Partial<Entity>>({
@ -75,6 +76,22 @@ export function UsePage({ template, onCreateOrder, onBack }: Props): VNode {
return ( return (
<div> <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"> <section class="section is-main-section">
<div class="columns"> <div class="columns">
<div class="column" /> <div class="column" />

View File

@ -68,6 +68,7 @@ export default function TemplateUsePage({
<NotificationCard notification={notif} /> <NotificationCard notification={notif} />
<UsePage <UsePage
template={result.data} template={result.data}
id={tid}
onBack={onBack} onBack={onBack}
onCreateOrder={( onCreateOrder={(
request: MerchantBackend.Template.UsingTemplateDetails, request: MerchantBackend.Template.UsingTemplateDetails,

View File

@ -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);
}