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:
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