taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit a2fe54a89260d0047f63640708062356c76f805e
parent 5949a356a0a556c899499bdd9b67fc9dc77fbb3d
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Mon, 20 Apr 2026 12:57:12 -0300

#9515 special form for POS access token

Diffstat:
Mpackages/merchant-backoffice-ui/src/Routing.tsx | 12++++++++++++
Mpackages/merchant-backoffice-ui/src/components/menu/SideBar.tsx | 5+++++
Mpackages/merchant-backoffice-ui/src/components/menu/index.tsx | 2++
Mpackages/merchant-backoffice-ui/src/hooks/preference.ts | 1+
Apackages/merchant-backoffice-ui/src/paths/instance/accessTokens/create-pos/Create.stories.tsx | 27+++++++++++++++++++++++++++
Apackages/merchant-backoffice-ui/src/paths/instance/accessTokens/create-pos/CreatePage.tsx | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backoffice-ui/src/paths/instance/accessTokens/create-pos/index.tsx | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/merchant-backoffice-ui/src/paths/instance/accessTokens/list/index.tsx | 10++++++++++
Mpackages/web-util/src/components/QR.tsx | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 415 insertions(+), 0 deletions(-)

diff --git a/packages/merchant-backoffice-ui/src/Routing.tsx b/packages/merchant-backoffice-ui/src/Routing.tsx @@ -97,6 +97,7 @@ import { LoginPage } from "./paths/login/index.js"; import { NewAccount } from "./paths/newAccount/index.js"; import { ResetAccount } from "./paths/resetAccount/index.js"; import { Settings } from "./paths/settings/index.js"; +import PosTokenCreatePage from "./paths/instance/accessTokens/create-pos/index.js"; const TALER_SCREEN_ID = 3; @@ -107,6 +108,7 @@ export enum InstancePaths { access_token_list = "/access-token", access_token_new = "/access-token/new", + access_token_new_pos = "/access-token/new-pos", bank_list = "/bank", bank_update = "/bank/:bid/update", @@ -616,6 +618,16 @@ export function Routing(_p: Props): VNode { route(InstancePaths.access_token_list); }} /> + <Route + path={InstancePaths.access_token_new_pos} + component={PosTokenCreatePage} + onConfirm={() => { + route(InstancePaths.access_token_list); + }} + onBack={() => { + route(InstancePaths.access_token_list); + }} + /> {/** * Order pages */} diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -435,6 +435,7 @@ export function getAvailableForPersona(p: MerchantPersona): ElementMap { [UIElement.sidebar_inventory]: true, [UIElement.sidebar_otpDevices]: true, [UIElement.action_createOrderManually]: true, + [UIElement.action_createPosAuthToken]: false, [UIElement.option_advanceInstanceSettings]: true, [UIElement.option_advanceOrderCreation]: true, [UIElement.option_otpDevicesOnTemplate]: true, @@ -473,6 +474,7 @@ export function getAvailableForPersona(p: MerchantPersona): ElementMap { [UIElement.sidebar_inventory]: false, [UIElement.sidebar_otpDevices]: false, [UIElement.action_createOrderManually]: false, + [UIElement.action_createPosAuthToken]: false, [UIElement.option_advanceInstanceSettings]: false, [UIElement.option_advanceOrderCreation]: false, [UIElement.option_otpDevicesOnTemplate]: false, @@ -508,6 +510,7 @@ export function getAvailableForPersona(p: MerchantPersona): ElementMap { [UIElement.sidebar_wireTransfers]: false, [UIElement.sidebar_otpDevices]: false, [UIElement.action_createOrderManually]: false, + [UIElement.action_createPosAuthToken]: false, [UIElement.option_advanceInstanceSettings]: false, [UIElement.option_advanceOrderCreation]: false, [UIElement.option_otpDevicesOnTemplate]: false, @@ -543,6 +546,7 @@ export function getAvailableForPersona(p: MerchantPersona): ElementMap { [UIElement.sidebar_inventory]: false, [UIElement.sidebar_otpDevices]: false, [UIElement.action_createOrderManually]: false, + [UIElement.action_createPosAuthToken]: false, [UIElement.option_advanceInstanceSettings]: false, [UIElement.option_advanceOrderCreation]: false, [UIElement.option_otpDevicesOnTemplate]: false, @@ -577,6 +581,7 @@ export function getAvailableForPersona(p: MerchantPersona): ElementMap { [UIElement.sidebar_inventory]: false, [UIElement.sidebar_otpDevices]: false, [UIElement.action_createOrderManually]: false, + [UIElement.action_createPosAuthToken]: false, [UIElement.option_advanceInstanceSettings]: false, [UIElement.option_advanceOrderCreation]: false, [UIElement.option_otpDevicesOnTemplate]: false, diff --git a/packages/merchant-backoffice-ui/src/components/menu/index.tsx b/packages/merchant-backoffice-ui/src/components/menu/index.tsx @@ -96,6 +96,8 @@ function getInstanceTitle( return i18n.ctx("title")`${id}: Access tokens`; case InstancePaths.access_token_new: return i18n.ctx("title")`${id}: New access token`; + case InstancePaths.access_token_new_pos: + return i18n.ctx("title")`${id}: New POS access token`; default: { return ""; } diff --git a/packages/merchant-backoffice-ui/src/hooks/preference.ts b/packages/merchant-backoffice-ui/src/hooks/preference.ts @@ -49,6 +49,7 @@ export enum UIElement { sidebar_password, sidebar_accessTokens, action_createOrderManually, + action_createPosAuthToken, option_otpDevicesOnTemplate, option_advanceOrderCreation, option_advanceInstanceSettings, diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create-pos/Create.stories.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create-pos/Create.stories.tsx @@ -0,0 +1,27 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 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 { CreatePage as TestedComponent } from "./CreatePage.js"; + +export default { + title: "Pages/AccessTokenPos/Create", + component: TestedComponent, +}; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create-pos/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create-pos/CreatePage.tsx @@ -0,0 +1,182 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 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 { + assertUnreachable, + Duration, + HttpStatusCode, + LoginTokenScope, + TalerMerchantApi, +} from "@gnu-taler/taler-util"; +import { + ButtonBetterBulma, + LocalNotificationBannerBulma, + useChallengeHandler, + useLocalNotificationBetter, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { + FormErrors, + FormProvider, + TalerForm, +} from "../../../../components/form/FormProvider.js"; +import { Input } from "../../../../components/form/Input.js"; +import { SolveMFAChallenges } from "../../../../components/SolveMFA.js"; +import { Tooltip } from "../../../../components/Tooltip.js"; +import { useSessionContext } from "../../../../context/session.js"; +import { usePreference } from "../../../../hooks/preference.js"; +import { undefinedIfEmpty } from "../../../../utils/table.js"; +import { maybeTryFirstMFA } from "../../accounts/create/CreatePage.js"; + +const TALER_SCREEN_ID = 29; + +type Entity = { + name: string; + password: string; +} & TalerForm; + +interface Props { + onCreated: (asd: TalerMerchantApi.LoginTokenSuccessResponse) => void; + onBack?: () => void; +} + + +export function CreatePage({ onCreated, onBack }: Props): VNode { + const { i18n } = useTranslationContext(); + + const [state, setState] = useState<Partial<Entity>>({}); + const { state: session, lib } = useSessionContext(); + + const errors = undefinedIfEmpty<FormErrors<Entity>>({ + password: !state.password ? i18n.str`Required` : undefined, + name: !state.name ? i18n.str`Required` : undefined, + }); + + const hasErrors = errors !== undefined; + + const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); + + const data: TalerMerchantApi.LoginTokenRequest = { + scope: LoginTokenScope.OrderPos_Refreshable, + description: `POS access from: ${state.name}`, + duration: Duration.toTalerProtocolDuration(Duration.fromSpec({days:10})), + }; + + const create = safeFunctionHandler( + i18n.str`create pos access token`, + async ( + pwd: string, + request: TalerMerchantApi.LoginTokenRequest, + challengeIds: string[], + ) => { + const resp = await lib.instance.createAccessToken(session.instance, pwd, request, { + challengeIds, + }) + if (resp.type === "fail" && resp.case === HttpStatusCode.Accepted) { + await maybeTryFirstMFA(lib.instance, mfa, resp.body) + } + return resp + }, + !!errors || !state.password ? undefined : [state.password, data, []], + ); + create.onFail = (fail) => { + switch (fail.case) { + case HttpStatusCode.Accepted: + return undefined; + case HttpStatusCode.Unauthorized: + return i18n.str`Check the password.`; + case HttpStatusCode.NotFound: + return i18n.str`Instance not found.`; + default: + assertUnreachable(fail); + } + }; + create.onSuccess = onCreated; + + const retry = create.lambda((ids: string[]) => [ + create.args![0], + create.args![1], + ids, + ]); + + if (mfa.pendingChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={mfa.pendingChallenge} + initial={mfa.initial} + focus + onCompleted={retry} + onCancel={mfa.doCancelChallenge} + /> + ); + } + return ( + <Fragment> + <LocalNotificationBannerBulma notification={notification} /> + <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="name" + label={i18n.str`Device name`} + help={i18n.str`Helps you remember where this access token is being used before deleting it.`} + /> + + <Input<Entity> + name="password" + inputType="password" + label={i18n.str`Current password`} + /> + <div class="buttons is-right mt-5"> + {onBack && ( + <button class="button" type="button" onClick={onBack}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + )} + <Tooltip + text={ + hasErrors + ? i18n.str`Please complete the marked fields` + : i18n.str`Confirm operation` + } + > + <ButtonBetterBulma type="submit" onClick={create}> + <i18n.Translate>Confirm</i18n.Translate> + </ButtonBetterBulma> + </Tooltip> + </div> + </FormProvider> + </div> + <div class="column" /> + </div> + </section> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create-pos/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create-pos/index.tsx @@ -0,0 +1,123 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 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 { + AbsoluteTime, + TalerMerchantApi, + TalerUri, +} from "@gnu-taler/taler-util"; +import { + QR_Generic, + QR_Taler, + Time, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { ConfirmModal, Row } from "../../../../components/modal/index.js"; +import { CreatePage } from "./CreatePage.js"; +import { useSessionContext } from "../../../../context/session.js"; + +const TALER_SCREEN_ID = 30; + +export type Entity = TalerMerchantApi.LoginTokenRequest; +interface Props { + onBack?: () => void; + onConfirm: () => void; +} + +export default function PosTokenCreatePage({ + onConfirm, + onBack, +}: Props): VNode { + const { i18n } = useTranslationContext(); + + const [ok, setOk] = useState<{ token: string; expiration: AbsoluteTime }>(); + const { state } = useSessionContext(); + const issuer = state.backendUrl.href.replace(/.*:\/\//, ""); // remove http(s):// + + return ( + <Fragment> + {!ok ? undefined : ( + <ConfirmModal + active + onCancel={onConfirm} + description={i18n.str`Access token created`} + label={i18n.str`Confirm`} + > + <div class=""> + <table> + <tbody> + <tr> + <td colSpan={3}> + <i18n.Translate> + Scan this QR with the Taler POS app. + </i18n.Translate> + </td> + </tr> + + <div + style={{ + width: "100%", + display: "flex", + justifyContent: "center", + }} + > + <QR_Generic + content={`taler-pos://${issuer}#/username=${state.instance}&password=${ok.token}`} + title="Taler POS" + /> + </div> + + <tr> + <td colSpan={3}> + {AbsoluteTime.isNever(ok.expiration) ? ( + <i18n.Translate> + This token will never expire + </i18n.Translate> + ) : ( + <i18n.Translate> + This token will be available until{" "} + <Time + format="dd/MM/yyyy HH:mm:ss" + timestamp={ok.expiration} + /> + </i18n.Translate> + )} + </td> + </tr> + </tbody> + </table> + </div> + </ConfirmModal> + )} + <CreatePage + onBack={onBack} + onCreated={(c) => { + setOk({ + expiration: AbsoluteTime.fromProtocolTimestamp(c.expiration), + token: c.access_token, + }); + }} + /> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/list/index.tsx @@ -103,6 +103,16 @@ export default function AccessTokenListPage({ onCreate }: Props): VNode { <section class="section is-main-section"> <LocalNotificationBannerBulma notification={notification} /> + + <p style={{marginBottom: 10}}> + <a href={"#/access-token/new-pos"} class="has-icon"> + <i class="icon mdi mdi-account-plus" /> + <span class="menu-item-label"> + <i18n.Translate>New point-of-sale access</i18n.Translate> + </span> + </a> + </p> + <CardTable tokens={result.body.map((o) => ({ ...o, diff --git a/packages/web-util/src/components/QR.tsx b/packages/web-util/src/components/QR.tsx @@ -138,6 +138,59 @@ export function QR_TOTP({ otpAuthURI }: { otpAuthURI: string }): VNode { ); } +export function QR_Generic({ + content, + title, +}: { + content: string; + title?: string; +}): VNode { + return ( + <div + style={{ + width: "90%", + maxWidth: 400, + margin: "auto", + padding: 10, + position: "relative", + }} + > + <div + style={{ + padding: 10, // separate the qr from the animated border + borderRadius: 20, // match the radius from the parent + backgroundColor: "white", // hide the background + }} + > + <img style={{ margin: 5 }} src={generate_qr(content)} /> + </div> + + {title === undefined ? undefined : ( + <div + style={{ + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + alignContent: "center", + }} + > + <div + style={{ + display: "flex", + border: "1px solid black", + backgroundColor: "white", + fontWeight: "bold", + padding: 5, + }} + > + {title} + </div> + </div> + )} + </div> + ); +} /** * Based on the definition of Swiss Implementation Guidelines * for the QR-bill