commit 3a1f5bf7c4c11d99885c7a8f38f4174ef74dc291 parent f306eb3c23e8d2c1d4514f4aa4cc933a7f065876 Author: Sebastian <sebasjm@gmail.com> Date: Sat, 2 Aug 2025 13:46:26 +0200 fix #10232 Diffstat:
24 files changed, 413 insertions(+), 181 deletions(-)
diff --git a/packages/merchant-backoffice-ui/src/Application.tsx b/packages/merchant-backoffice-ui/src/Application.tsx @@ -84,6 +84,7 @@ import { fetchSettings, } from "./settings.js"; import { revalidateInstanceCategories } from "./hooks/category.js"; +import { revalidateInstanceAccessTokens } from "./hooks/access-tokens.js"; const WITH_LOCAL_STORAGE_CACHE = false; export function Application(): VNode { @@ -389,6 +390,14 @@ const swrCacheEvictor = new (class await Promise.all([revalidateTokenFamilies()]); return; } + case TalerMerchantInstanceCacheEviction.CREATE_ACCESSTOKEN: { + await Promise.all([revalidateInstanceAccessTokens()]); + return ; + } + case TalerMerchantInstanceCacheEviction.DELETE_ACCESSTOKEN: { + await Promise.all([revalidateInstanceAccessTokens()]); + return ; + } case TalerMerchantInstanceCacheEviction.LAST: { return; } diff --git a/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx b/packages/merchant-backoffice-ui/src/components/form/InputDate.tsx @@ -63,7 +63,7 @@ export function InputDate<T>({ strValue = value.t_s === "never" ? withTimestampSupport - ? "never" + ? i18n.str`Never` : "" : format(new Date(value.t_s * 1000), dateFormatForSettings(settings)); } diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -147,7 +147,7 @@ export function Sidebar({ mobile }: Props): VNode { <li> <a href={"#/tokenfamilies"} class="has-icon"> <span class="icon"> - <i class="mdi mdi-key" /> + <i class="mdi mdi-clock" /> </span> <span class="menu-item-label"> <i18n.Translate>Token Families</i18n.Translate> @@ -260,10 +260,10 @@ export function Sidebar({ mobile }: Props): VNode { <li> <a href={"#/access-token"} class="has-icon"> <span class="icon"> - <i class="mdi mdi-security" /> + <i class="mdi mdi-key" /> </span> <span class="menu-item-label"> - <i18n.Translate>Active sessions</i18n.Translate> + <i18n.Translate>Access tokens</i18n.Translate> </span> </a> </li> diff --git a/packages/merchant-backoffice-ui/src/components/menu/index.tsx b/packages/merchant-backoffice-ui/src/components/menu/index.tsx @@ -82,8 +82,13 @@ function getInstanceTitle(path: string, id: string): string { return `${id}: New token family`; case InstancePaths.token_family_update: return `${id}: Update token family`; - default: + case InstancePaths.access_token_list: + return `${id}: Access tokens`; + case InstancePaths.access_token_new: + return `${id}: New access token`; + default: { return ""; + } } } diff --git a/packages/merchant-backoffice-ui/src/components/modal/index.tsx b/packages/merchant-backoffice-ui/src/components/modal/index.tsx @@ -30,13 +30,9 @@ import { import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; -import { useSessionContext } from "../../context/session.js"; import { DEFAULT_REQUEST_TIMEOUT } from "../../utils/constants.js"; -import { undefinedIfEmpty } from "../../utils/table.js"; import { Spinner } from "../exception/loading.js"; -import { FormErrors, FormProvider } from "../form/FormProvider.js"; -import { Input } from "../form/Input.js"; -import { Amount } from "../Amount.js"; +import { FormErrors } from "../form/FormProvider.js"; interface Props { active?: boolean; @@ -47,6 +43,12 @@ interface Props { children?: ComponentChildren; danger?: boolean; disabled?: boolean; + /** + * sometimes we want to prevent the user to close the dialog by error when clicking outside the box + * + * This could have been implemented as a separated component also + */ + noCancelButton?: boolean; } export function ConfirmModal({ @@ -58,6 +60,7 @@ export function ConfirmModal({ danger, disabled, label = "Confirm", + noCancelButton }: Props): VNode { const { i18n } = useTranslationContext(); return ( @@ -81,7 +84,7 @@ export function ConfirmModal({ <div class="buttons is-right" style={{ width: "100%" }}> {onConfirm ? ( <Fragment> - {onCancel ? ( + {onCancel && !noCancelButton ? ( <button class="button " onClick={onCancel}> <i18n.Translate>Cancel</i18n.Translate> </button> @@ -96,18 +99,22 @@ export function ConfirmModal({ </button> </Fragment> ) : ( - <button class="button " onClick={onCancel}> - <i18n.Translate>Close</i18n.Translate> - </button> + (noCancelButton ? undefined : + <button class="button " onClick={onCancel}> + <i18n.Translate>Close</i18n.Translate> + </button> + ) )} </div> </footer> </div> - <button - class="modal-close is-large " - aria-label="close" - onClick={onCancel} - /> + {noCancelButton ? undefined : + <button + class="modal-close is-large " + aria-label="close" + onClick={onCancel} + /> + } </div> ); } @@ -425,22 +432,22 @@ export function CompareAccountsModal({ <td>{testPayto.params["receiver-name"]}</td> </tr> {!!testPayto.params["receiver-postal-code"] && ( - <tr> - <td> - <i18n.Translate>Owner's postal code</i18n.Translate> - </td> - <td>{formPayto?.params["receiver-postal-code"] ?? "--"}</td> - <td>{testPayto.params["receiver-postal-code"]}</td> - </tr> + <tr> + <td> + <i18n.Translate>Owner's postal code</i18n.Translate> + </td> + <td>{formPayto?.params["receiver-postal-code"] ?? "--"}</td> + <td>{testPayto.params["receiver-postal-code"]}</td> + </tr> )} {!!testPayto.params["receiver-town"] && ( - <tr> - <td> - <i18n.Translate>Owner's town</i18n.Translate> - </td> - <td>{formPayto?.params["receiver-town"] ?? "--"}</td> - <td>{testPayto.params["receiver-town"]}</td> - </tr> + <tr> + <td> + <i18n.Translate>Owner's town</i18n.Translate> + </td> + <td>{formPayto?.params["receiver-town"] ?? "--"}</td> + <td>{testPayto.params["receiver-town"]}</td> + </tr> )} </tbody> </table> @@ -493,7 +500,7 @@ export function ValidBankAccount({ : origin.targetType === "iban" ? origin.iban : origin.targetType === "taler-reserve" || - origin.targetType === "taler-reserve-http" + origin.targetType === "taler-reserve-http" ? origin.reservePub : origin.targetType === "bitcoin" ? `${origin.address.substring(0, 8)}...` @@ -507,7 +514,7 @@ export function ValidBankAccount({ description={i18n.str`Validate bank account: ${from}`} active onCancel={onCancel} - // onConfirm={onConfirm} + // onConfirm={onConfirm} > <p style={{ paddingTop: 0 }}> <i18n.Translate> diff --git a/packages/merchant-backoffice-ui/src/hooks/access-tokens.ts b/packages/merchant-backoffice-ui/src/hooks/access-tokens.ts @@ -53,7 +53,7 @@ export function useInstanceAccessTokens() { const { data, error } = useSWR< TalerMerchantManagementResultByMethod<"listAccessTokens">, TalerHttpError - >([state.token, offset, "listAccessTokens"], fetcher); + >([state.token, offset, "useInstanceAccessTokens"], fetcher); if (error) return error; if (data === undefined) return undefined; @@ -63,7 +63,7 @@ export function useInstanceAccessTokens() { data.body.tokens, offset, setOffset, - (d) => d.token_id, + (d) => d.serial, PAGINATED_LIST_REQUEST, ); } diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx @@ -23,9 +23,10 @@ import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { NotificationCard } from "../../../components/menu/index.js"; import { useSessionContext } from "../../../context/session.js"; +import { usePreference } from "../../../hooks/preference.js"; import { Notification } from "../../../utils/types.js"; -import { CreatePage } from "./CreatePage.js"; import { FOREVER_REFRESHABLE_TOKEN } from "../../login/index.js"; +import { CreatePage } from "./CreatePage.js"; interface Props { onBack?: () => void; @@ -38,6 +39,7 @@ export default function Create({ onBack, onConfirm, forceId }: Props): VNode { const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); const { lib, state, logIn } = useSessionContext(); + const [settings] = usePreference(); return ( <Fragment> @@ -63,10 +65,10 @@ export default function Create({ onBack, onConfirm, forceId }: Props): VNode { const result = await lib.instance.createAccessToken( d.id, d.auth.password, - FOREVER_REFRESHABLE_TOKEN, + FOREVER_REFRESHABLE_TOKEN(i18n.str`Instace created`), ); if (result.type === "ok") { - const { access_token:token } = result.body; + const { access_token: token } = result.body; logIn(state.instance, token); } } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create/CreatePage.tsx @@ -42,6 +42,7 @@ import { undefinedIfEmpty } from "../../../../utils/table.js"; type Entity = { scope: TalerMerchantApi.LoginTokenRequest["scope"]; duration: Duration; + description: string; password: string; } & TalerForm; @@ -54,18 +55,19 @@ interface Props { } const VALID_TOKEN_SCOPE = [ - LoginTokenScope.All, - LoginTokenScope.OrderFull, - LoginTokenScope.OrderManagement, - LoginTokenScope.OrderPos, - LoginTokenScope.OrderSimple, + "", LoginTokenScope.ReadOnly, - LoginTokenScope.All_Refreshable, - LoginTokenScope.OrderFull_Refreshable, - LoginTokenScope.OrderManagement_Refreshable, - LoginTokenScope.OrderPos_Refreshable, - LoginTokenScope.OrderSimple_Refreshable, + LoginTokenScope.OrderSimple, + LoginTokenScope.OrderPos, + LoginTokenScope.OrderManagement, + LoginTokenScope.OrderFull, + // LoginTokenScope.All_Refreshable, LoginTokenScope.ReadOnly_Refreshable, + LoginTokenScope.OrderSimple_Refreshable, + LoginTokenScope.OrderPos_Refreshable, + LoginTokenScope.OrderManagement_Refreshable, + LoginTokenScope.OrderFull_Refreshable, + LoginTokenScope.All, ]; export function CreatePage({ onCreate, onBack }: Props): VNode { @@ -76,6 +78,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { const errors = undefinedIfEmpty<FormErrors<Entity>>({ password: !state.password ? i18n.str`Required` : undefined, duration: !state.duration ? i18n.str`Required` : undefined, + description: !state.description ? i18n.str`Required` : undefined, scope: !state.scope ? i18n.str`Required` : undefined, }); @@ -87,6 +90,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { return onCreate(state.password!, { scope: state.scope!, duration: Duration.toTalerProtocolDuration(state.duration!), + description: state.description!, }); }; @@ -102,9 +106,9 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { errors={errors} > <Input<Entity> - name="password" - inputType="password" - label={i18n.str`Password`} + name="description" + label={i18n.str`Description`} + help={i18n.str`Helps you remember where this token is being used before deleting it.`} /> <InputDuration<Entity> @@ -116,9 +120,44 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { <InputSelector name="scope" label={i18n.str`Scope`} - tooltip={i18n.str`Choose the authentication type for the account info URL`} + tooltip={i18n.str`The scope defines the set of permissions for the access token. Refreshable tokens has the permission to extend the expiration time.`} values={VALID_TOKEN_SCOPE} + help={((s) => { + if (!s) return "" + switch (s) { + case LoginTokenScope.All: + return i18n.str`Allows all operations without limit.` + case LoginTokenScope.ReadOnly: + return i18n.str`Allows all operations to read information.` + case LoginTokenScope.OrderSimple: + return i18n.str`Allows the creation of orders and checking of payment status.` + case LoginTokenScope.OrderPos: + return i18n.str`Allows the creation of orders, checking of payment status and inventory locking.` + case LoginTokenScope.OrderManagement: + return i18n.str`Allows the creation of orders, checking of payment status and refunds.` + case LoginTokenScope.OrderFull: + return i18n.str`Allows the creation of orders, checking of payment status, inventory locking and refunds.` + case LoginTokenScope.ReadOnly_Refreshable: + return i18n.str`Allows all operations to read information with extendable expiration time.` + case LoginTokenScope.OrderSimple_Refreshable: + return i18n.str`Allows the creation of orders and checking of payment status with extendable expiration time.` + case LoginTokenScope.OrderPos_Refreshable: + return i18n.str`Allows the creation of orders, checking of payment status and inventory locking with extendable expiration time.` + case LoginTokenScope.OrderManagement_Refreshable: + return i18n.str`Allows the creation of orders, checking of payment status and refunds with extendable expiration time.` + case LoginTokenScope.OrderFull_Refreshable: + return i18n.str`Allows the creation of orders, checking of payment status, inventory locking and refunds with extendable expiration time.` + case LoginTokenScope.All_Refreshable: + return "" + default: { + assertUnreachable(s); + } + } + })(state.scope)} toStr={(str) => { + if (!str) { + return i18n.str`Choose one` + } const s = str as LoginTokenScope; switch (s) { case LoginTokenScope.ReadOnly: @@ -126,31 +165,38 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { case LoginTokenScope.All: return i18n.str`All`; case LoginTokenScope.OrderSimple: - return i18n.str`Order Simple`; + return i18n.str`Order simple`; case LoginTokenScope.OrderPos: return i18n.str`Order Point-of-Sale`; case LoginTokenScope.OrderManagement: - return i18n.str`Order Management`; + return i18n.str`Order management`; case LoginTokenScope.OrderFull: - return i18n.str`Order Full`; + return i18n.str`Order full`; case LoginTokenScope.ReadOnly_Refreshable: return i18n.str`Read only (refreshable)`; - case LoginTokenScope.All_Refreshable: - return i18n.str`All (refreshable)`; case LoginTokenScope.OrderSimple_Refreshable: - return i18n.str`Order Simple (refreshable)`; + return i18n.str`Order simple (refreshable)`; case LoginTokenScope.OrderPos_Refreshable: return i18n.str`Order Point-of-Sale (refreshable)`; case LoginTokenScope.OrderManagement_Refreshable: - return i18n.str`Order Management (refreshable)`; + return i18n.str`Order management (refreshable)`; case LoginTokenScope.OrderFull_Refreshable: - return i18n.str`Order Full (refreshable)`; + return i18n.str`Order full (refreshable)`; + case LoginTokenScope.All_Refreshable: + return ""; default: { assertUnreachable(s); } } }} /> + <Input<Entity> + name="password" + inputType="password" + label={i18n.str`Current password`} + /> + + </FormProvider> <div class="buttons is-right mt-5"> {onBack && ( diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create/index.tsx @@ -50,9 +50,12 @@ export default function AccessTokenCreatePage({ <NotificationCard notification={notif} /> {!ok ? undefined : ( <ConfirmModal - label={`I understand`} + // label={`Confirm`} active onConfirm={() => onConfirm()} + onCancel={() => {}} + noCancelButton + description={i18n.str`Access token created`} > <div class="table-container"> <table> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/list/Table.tsx @@ -20,28 +20,26 @@ */ import { - parsePaytoUri, - PaytoType, - PaytoUri, - TokenInfo, - TokenInfos + TokenInfo } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { format } from "date-fns"; import { Fragment, h, VNode } from "preact"; import { StateUpdater, useState } from "preact/hooks"; +import { datetimeFormatForSettings, usePreference } from "../../../../hooks/preference.js"; type Entity = TokenInfo; interface Props { tokens: Entity[]; - onSelect: (e: Entity) => void; + onDelete: (e: Entity) => void; onCreate: () => void; } export function CardTable({ tokens, onCreate, - onSelect, + onDelete, }: Props): VNode { const [rowSelection, rowSelectionHandler] = useState<string[]>([]); @@ -52,9 +50,9 @@ export function CardTable({ <header class="card-header"> <p class="card-header-title"> <span class="icon"> - <i class="mdi mdi-bank" /> + <i class="mdi mdi-key" /> </span> - <i18n.Translate>Active sessions</i18n.Translate> + <i18n.Translate>Access tokens</i18n.Translate> </p> <div class="card-header-icon" aria-label="more options"> <span @@ -75,7 +73,7 @@ export function CardTable({ {tokens.length > 0 ? ( <Table tokens={tokens} - onSelect={onSelect} + onDelete={onDelete} rowSelection={rowSelection} rowSelectionHandler={rowSelectionHandler} /> @@ -91,12 +89,13 @@ export function CardTable({ interface TableProps { rowSelection: string[]; tokens: Entity[]; - onSelect: (e: Entity) => void; + onDelete: (e: Entity) => void; rowSelectionHandler: StateUpdater<string[]>; } -function Table({ tokens, onSelect }: TableProps): VNode { +function Table({ tokens, onDelete }: TableProps): VNode { const { i18n } = useTranslationContext(); + const [settings] = usePreference(); return ( <Fragment> <div class="table-container"> @@ -110,10 +109,7 @@ function Table({ tokens, onSelect }: TableProps): VNode { <i18n.Translate>Created at</i18n.Translate> </th> <th> - <i18n.Translate>Last access</i18n.Translate> - </th> - <th> - <i18n.Translate>Valid until</i18n.Translate> + <i18n.Translate>Expires at</i18n.Translate> </th> <th> <i18n.Translate>Description</i18n.Translate> @@ -126,33 +122,63 @@ function Table({ tokens, onSelect }: TableProps): VNode { return ( <tr key={idx}> <td - onClick={(): void => onSelect(t)} - style={{ cursor: "pointer" }} + // onClick={(): void => onSelect(t)} + // style={{ cursor: "pointer" }} > {t.scope} </td> <td - onClick={(): void => onSelect(t)} - style={{ cursor: "pointer" }} + // onClick={(): void => onSelect(t)} + // style={{ cursor: "pointer" }} > - {t.creation_time} + {t.creation_time.t_s === "never" + ? i18n.str`Never` + : format( + new Date( + t.creation_time.t_s * 1000, + ), + datetimeFormatForSettings(settings), + )} </td> <td - onClick={(): void => onSelect(t)} - style={{ cursor: "pointer" }} + // onClick={(): void => onSelect(t)} + // style={{ cursor: "pointer" }} > - {t.last_access} + {t.expiration.t_s === "never" + ? i18n.str`Never` + : format( + new Date( + t.expiration.t_s * 1000, + ), + datetimeFormatForSettings(settings), + )} </td> <td - onClick={(): void => onSelect(t)} - style={{ cursor: "pointer" }} + // onClick={(): void => onSelect(t)} + // style={{ cursor: "pointer" }} > - {t.expiration} + {t.description} </td> + <td class="is-actions-cell right-sticky"> + <div class="buttons is-right"> + <span + class="has-tooltip-left" + data-tooltip={i18n.str`Remove this access token`} + > + <button + class="button is-small is-danger" + type="button" + onClick={(): void => onDelete(t)} + > + <i18n.Translate>Delete</i18n.Translate> + </button> + </span> + </div> + </td> + </tr> ); })} - </tbody> </table> </div> 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 @@ -22,6 +22,8 @@ import { HttpStatusCode, TalerError, + TalerMerchantApi, + TokenInfo, assertUnreachable, } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; @@ -32,6 +34,11 @@ import { useInstanceAccessTokens } from "../../../../hooks/access-tokens.js"; import { LoginPage } from "../../../login/index.js"; import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; import { CardTable } from "./Table.js"; +import { NotificationCard } from "../../../../components/menu/index.js"; +import { ConfirmModal } from "../../../../components/modal/index.js"; +import { useSessionContext } from "../../../../context/session.js"; +import { Notification } from "../../../../utils/types.js"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; interface Props { onCreate: () => void; @@ -39,7 +46,13 @@ interface Props { export default function AccessTokenListPage({ onCreate }: Props): VNode { const result = useInstanceAccessTokens(); - const [selected, setSelected] = useState<number>(); + const { state, lib } = useSessionContext(); + const [deleting, setDeleting] = useState< + (TokenInfo) | null + >(null); + const { i18n } = useTranslationContext(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + if (!result) return <Loading />; if (result instanceof TalerError) { return <ErrorLoadingMerchant error={result} />; @@ -64,17 +77,62 @@ export default function AccessTokenListPage({ onCreate }: Props): VNode { return ( <Fragment> <section class="section is-main-section"> - {!selected ? undefined : <div>selected</div>} + <NotificationCard notification={notif} /> + <CardTable tokens={result.body.map((o) => ({ ...o, - id: String(o.token_id), + id: String(o.serial), }))} onCreate={onCreate} - onSelect={(e) => { - setSelected(e.token_id); + onDelete={(d) => { + setDeleting(d) }} + /> + {deleting && ( + <ConfirmModal + label={`Delete access token`} + description={deleting.description} + danger + active + onCancel={() => setDeleting(null)} + onConfirm={async (): Promise<void> => { + try { + const resp = await lib.instance.deleteAccessToken( + state.token, + deleting.serial, + ); + if (resp.type === "ok") { + setNotif({ + message: i18n.str`Access token has been deleted`, + type: "SUCCESS", + }); + } else { + setNotif({ + message: i18n.str`Could not delete the access token`, + type: "ERROR", + description: resp.detail?.hint, + }); + } + } catch (error) { + setNotif({ + message: i18n.str`Could not delete the access token`, + type: "ERROR", + description: error instanceof Error ? error.message : undefined, + }); + } + setDeleting(null); + }} + > + <p class="warning"> + <i18n.Translate> + Deleting an access token cannot be undone. + </i18n.Translate> + </p> + </ConfirmModal> + )} + </section> </Fragment> ); 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 @@ -254,7 +254,7 @@ function ClaimedPage({ <i18n.Translate>Claimed at</i18n.Translate>: </b>{" "} {order.contract_terms.timestamp.t_s === "never" - ? "never" + ? i18n.str`Never` : format( new Date( order.contract_terms.timestamp.t_s * 1000, @@ -686,7 +686,7 @@ function UnpaidPage({ <i18n.Translate>Created at</i18n.Translate>: </b>{" "} {order.creation_time.t_s === "never" - ? "never" + ? i18n.str`Never` : format( new Date(order.creation_time.t_s * 1000), datetimeFormatForSettings(settings), 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 @@ -162,7 +162,7 @@ function Table({ style={{ cursor: "pointer" }} > {i.timestamp.t_s === "never" - ? "never" + ? i18n.str`Never` : format( new Date(i.timestamp.t_s * 1000), datetimeFormatForSettings(settings), @@ -362,7 +362,7 @@ export function RefundModal({ <tr key={r.timestamp.t_s}> <td> {r.timestamp.t_s === "never" - ? "never" + ? i18n.str`Never` : format( new Date(r.timestamp.t_s * 1000), datetimeFormatForSettings(settings), diff --git a/packages/merchant-backoffice-ui/src/paths/instance/password/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/password/index.tsx @@ -31,6 +31,7 @@ import { useInstanceDetails, useManagedInstanceDetails, } from "../../../hooks/instance.js"; +import { usePreference } from "../../../hooks/preference.js"; import { Notification } from "../../../utils/types.js"; import { FOREVER_REFRESHABLE_TOKEN, @@ -49,6 +50,8 @@ export default function PasswordPage(props: Props): VNode { const { lib, state, logIn } = useSessionContext(); const result = useInstanceDetails(); const instanceId = state.instance; + const { i18n } = useTranslationContext(); + const [settings] = usePreference(); async function changePassword( currentPassword: string | undefined, @@ -58,7 +61,7 @@ export default function PasswordPage(props: Props): VNode { const resp = await lib.instance.createAccessToken( instanceId, currentPassword, - TEMP_TEST_TOKEN, + TEMP_TEST_TOKEN(i18n.str`Testing password`), ); if (resp.type !== "ok") { throw Error(resp.detail?.hint ?? "The current password is wrong"); @@ -81,7 +84,7 @@ export default function PasswordPage(props: Props): VNode { const resp = await lib.instance.createAccessToken( instanceId, newPassword, - FOREVER_REFRESHABLE_TOKEN, + FOREVER_REFRESHABLE_TOKEN(i18n.str`Password changed`), ); if (resp.type === "ok") { logIn(state.instance, resp.body.access_token); @@ -96,6 +99,8 @@ export default function PasswordPage(props: Props): VNode { export function AdminPassword(props: Props & { instanceId: string }): VNode { const { lib, state } = useSessionContext(); + const { i18n } = useTranslationContext(); + const [settings] = usePreference(); const subInstanceLib = lib.subInstanceApi(props.instanceId).instance; const result = useManagedInstanceDetails(props.instanceId); @@ -110,7 +115,7 @@ export function AdminPassword(props: Props & { instanceId: string }): VNode { const resp = await lib.instance.createAccessToken( instanceId, currentPassword, - TEMP_TEST_TOKEN, + TEMP_TEST_TOKEN(i18n.str`Testing password for instance ${instanceId}`), ); if (resp.type !== "ok") { throw Error(resp.detail?.hint ?? "The current password is wrong"); @@ -133,7 +138,7 @@ export function AdminPassword(props: Props & { instanceId: string }): VNode { const resp = await subInstanceLib.createAccessToken( instanceId, newPassword, - FOREVER_REFRESHABLE_TOKEN, + FOREVER_REFRESHABLE_TOKEN(i18n.str`Password changed for instance ${instanceId}`), ); if (resp.type === "ok") { return; 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 @@ -186,7 +186,7 @@ function Table({ const restStockInfo = !i.next_restock ? "" : i.next_restock.t_s === "never" - ? "never" + ? i18n.str`Never` : `restock at ${format( new Date(i.next_restock.t_s * 1000), dateFormatForSettings(preference), diff --git a/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/tokenfamilies/list/Table.tsx @@ -55,7 +55,7 @@ export function CardTable({ <header class="card-header"> <p class="card-header-title"> <span class="icon"> - <i class="mdi mdi-shopping" /> + <i class="mdi mdi-clock" /> </span> <i18n.Translate>Token Families</i18n.Translate> </p> @@ -164,7 +164,7 @@ function Table({ style={{ cursor: "pointer" }} > {i.valid_after.t_s === "never" - ? "never" + ? i18n.str`Never` : format(new Date(i.valid_after.t_s * 1000), "yyyy/MM/dd hh:mm:ss")} </td> <td @@ -174,7 +174,7 @@ function Table({ style={{ cursor: "pointer" }} > {i.valid_before.t_s === "never" - ? "never" + ? i18n.str`Never` : format(new Date(i.valid_before.t_s * 1000), "yyyy/MM/dd hh:mm:ss")} </td> <td diff --git a/packages/merchant-backoffice-ui/src/paths/login/index.tsx b/packages/merchant-backoffice-ui/src/paths/login/index.tsx @@ -22,8 +22,10 @@ import { Duration, HttpStatusCode, + InternationalizationAPI, LoginTokenRequest, - LoginTokenScope + LoginTokenScope, + TranslatedString } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; @@ -32,25 +34,30 @@ import { AsyncButton } from "../../components/exception/AsyncButton.js"; import { NotificationCard } from "../../components/menu/index.js"; import { useSessionContext } from "../../context/session.js"; import { Notification } from "../../utils/types.js"; +import { format } from "date-fns"; +import { datetimeFormatForSettings, usePreference } from "../../hooks/preference.js"; -interface Props {} +interface Props { } -export const TEMP_TEST_TOKEN = { +export const TEMP_TEST_TOKEN = (description: TranslatedString) => ({ scope: LoginTokenScope.All, duration: Duration.toTalerProtocolDuration(Duration.fromMilliseconds(100)), -} as LoginTokenRequest; + description +} as LoginTokenRequest); -export const FOREVER_REFRESHABLE_TOKEN = { +export const FOREVER_REFRESHABLE_TOKEN = (description: TranslatedString) => ({ scope: LoginTokenScope.All_Refreshable, duration: Duration.toTalerProtocolDuration(Duration.getForever()), -} as LoginTokenRequest; + description, +} as LoginTokenRequest); const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; export function LoginPage(_p: Props): VNode { const [password, setPassword] = useState(""); const [notif, setNotif] = useState<Notification | undefined>(undefined); - const { lib, state, logIn, getInstanceForUsername } = useSessionContext(); + const { state, logIn, getInstanceForUsername } = useSessionContext(); const [username, setUsername] = useState(state.instance); + const [settings] = usePreference(); const { i18n } = useTranslationContext(); @@ -60,7 +67,7 @@ export function LoginPage(_p: Props): VNode { const result = await api.createAccessToken( username, password, - FOREVER_REFRESHABLE_TOKEN, + FOREVER_REFRESHABLE_TOKEN(i18n.str`Logged in`), ); if (result.type === "ok") { const { access_token: token } = result.body; @@ -187,35 +194,3 @@ export function LoginPage(_p: Props): VNode { </Fragment> ); } - -// function AsyncButton({ -// onClick, -// disabled, -// type = "", -// children, -// }: { -// type?: string; -// disabled?: boolean; -// onClick: () => Promise<void>; -// children: ComponentChildren; -// }): VNode { -// const [running, setRunning] = useState(false); -// return ( -// <button -// class={"button " + type} -// disabled={disabled || running} -// onClick={() => { -// setRunning(true); -// onClick() -// .then(() => { -// setRunning(false); -// }) -// .catch((e) => { -// setRunning(false); -// }); -// }} -// > -// {children} -// </button> -// ); -// } diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -129,6 +129,8 @@ export enum TalerMerchantInstanceCacheEviction { CREATE_TOKENFAMILY, UPDATE_TOKENFAMILY, DELETE_TOKENFAMILY, + CREATE_ACCESSTOKEN, + DELETE_ACCESSTOKEN, LAST, } @@ -236,8 +238,12 @@ export class TalerMerchantInstanceHttpClient { body, }); switch (resp.status) { - case HttpStatusCode.Ok: + case HttpStatusCode.Ok: { + this.cacheEvictor.notifySuccess( + TalerMerchantInstanceCacheEviction.CREATE_ACCESSTOKEN, + ); return opSuccessFromHttp(resp, codecForLoginTokenSuccessResponse()); + } case HttpStatusCode.Unauthorized: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotFound: @@ -280,17 +286,24 @@ export class TalerMerchantInstanceHttpClient { * https://docs.taler.net/core/api-merchant.html#delete-[-instances-$INSTANCE]-private-tokens-$SERIAL * */ - async deleteAcessToken(token: AccessToken) { - const url = new URL(`private/token`, this.baseUrl); + async deleteAccessToken( + token: AccessToken | undefined, serial: number) { + const url = new URL(`private/tokens/${String(serial)}`, this.baseUrl); + const headers: Record<string, string> = {}; + if (token) { + headers.Authorization = makeBearerTokenAuthHeader(token); + } const resp = await this.httpLib.fetch(url.href, { method: "DELETE", - headers: { - Authorization: makeBearerTokenAuthHeader(token), - }, + headers, }); switch (resp.status) { - case HttpStatusCode.NoContent: + case HttpStatusCode.NoContent: { + this.cacheEvictor.notifySuccess( + TalerMerchantInstanceCacheEviction.DELETE_ACCESSTOKEN, + ); return opEmptySuccess(); + } case HttpStatusCode.Forbidden: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.Unauthorized: @@ -698,9 +711,9 @@ export class TalerMerchantInstanceHttpClient { | OperationOk<TalerMerchantApi.MerchantAccountKycRedirectsResponse> | OperationOk<void> | OperationAlternative< - HttpStatusCode.BadGateway, - TalerMerchantApi.MerchantAccountKycRedirectsResponse - > + HttpStatusCode.BadGateway, + TalerMerchantApi.MerchantAccountKycRedirectsResponse + > | OperationFail<HttpStatusCode.Unauthorized> | OperationFail<HttpStatusCode.NotFound> | OperationFail<HttpStatusCode.ServiceUnavailable> @@ -1449,9 +1462,9 @@ export class TalerMerchantInstanceHttpClient { | OperationFail<HttpStatusCode.BadGateway> // FIXME: This can't be right! | OperationAlternative< - HttpStatusCode.GatewayTimeout, - TalerMerchantApi.OutOfStockResponse - > + HttpStatusCode.GatewayTimeout, + TalerMerchantApi.OutOfStockResponse + > > { const url = new URL(`private/orders/${orderId}`, this.baseUrl); diff --git a/packages/taler-util/src/types-taler-common.ts b/packages/taler-util/src/types-taler-common.ts @@ -400,10 +400,7 @@ export interface TokenInfo { // Time when the token was created. creation_time: Timestamp; - // Expiration determined by the server. - // Can be based on the token_duration - // from the request, but ultimately the - // server decides the expiration. + // Time when the token expires. expiration: Timestamp; // Scope for the token. @@ -418,14 +415,8 @@ export interface TokenInfo { // Optional token description description?: string; - // Time when the token was last used. - last_access: Timestamp; - - // ID identifying the token - token_id: Integer; - - // deprecated since **v9**. Use *token_id* instead. - // row_id?: Integer; + // Opaque unique ID used for pagination. + serial: Integer; } export const codecForTokenInfo = (): Codec<TokenInfo> => @@ -435,8 +426,7 @@ export const codecForTokenInfo = (): Codec<TokenInfo> => .property("scope", codecForString()) .property("refreshable", codecForBoolean()) .property("description", codecOptional(codecForString())) - .property("last_access", codecForTimestamp) - .property("token_id", codecForNumber()) + .property("serial", codecForNumber()) .build("TokenInfo"); export const codecForTokenInfoList = (): Codec<TokenInfos> => diff --git a/packages/taler-util/src/types-taler-merchant.ts b/packages/taler-util/src/types-taler-merchant.ts @@ -1662,6 +1662,9 @@ export interface LoginTokenRequest { // on the token validity duration duration?: RelativeTime; + // Optional token description + description?: string; + // Can this token be refreshed? // Defaults to false. Deprecated since **v19**. // Use ":refreshable" scope prefix instead. diff --git a/packages/taler-wallet-webextension/src/components/BalanceTable.tsx b/packages/taler-wallet-webextension/src/components/BalanceTable.tsx @@ -17,7 +17,7 @@ import { Amounts, ScopeInfo, ScopeType, WalletBalance } from "@gnu-taler/taler-util"; import { Fragment, VNode, h } from "preact"; import { - TableWithRoundRows as TableWithRoundedRows + TableWithRoundRows } from "./styled/index.js"; export function BalanceTable({ @@ -29,7 +29,7 @@ export function BalanceTable({ }): VNode { return ( <Fragment> - <TableWithRoundedRows> + <TableWithRoundRows> {balances.map((entry, idx) => { const av = Amounts.parseOrThrow(entry.available); @@ -50,7 +50,7 @@ export function BalanceTable({ {Amounts.stringifyValue(av, 2)} <div style={{ fontSize: "small", color: "grey" }}> {entry.scopeInfo.type === ScopeType.Exchange || - entry.scopeInfo.type === ScopeType.Auditor + entry.scopeInfo.type === ScopeType.Auditor ? entry.scopeInfo.url : undefined} </div> @@ -58,7 +58,7 @@ export function BalanceTable({ </tr> ); })} - </TableWithRoundedRows> + </TableWithRoundRows> </Fragment> ); } diff --git a/packages/taler-wallet-webextension/src/cta/Payment/index.ts b/packages/taler-wallet-webextension/src/cta/Payment/index.ts @@ -76,6 +76,8 @@ export namespace State { payStatus: PreparePayResultPaymentPossible; payHandler: ButtonHandler; balance: AmountJson; + onSelectChoice: (idx: number) => void; + selectedChoice: number; } export interface Confirmed extends BaseInfo { diff --git a/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx b/packages/taler-wallet-webextension/src/cta/Payment/stories.tsx @@ -23,6 +23,8 @@ import { AmountString, Amounts, MerchantContractTerms as ContractTerms, + MerchantContractInputType, + MerchantContractOutputType, PreparePayResultType, TransactionIdStr, } from "@gnu-taler/taler-util"; @@ -335,6 +337,66 @@ export const PaymentPossible = tests.createExample(BaseView, { }, }); +export const PaymentWithChoices = tests.createExample(BaseView, { + status: "ready", + error: undefined, + amount: Amounts.parseOrThrow("USD:9"), + balance: { + currency: "USD", + fraction: 40000000, + value: 11, + }, + payHandler: { + onClick: nullFunction, + }, + + uri: "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", + payStatus: { + transactionId: + "txn:payment:96YY92RQZGF3V7TJSPN4SF9549QX7BRF88Q5PYFCSBNQ0YK4RPK0" as TransactionIdStr, + scopes: [], + status: PreparePayResultType.PaymentPossible, + talerUri: "taler://pay/..", + amountEffective: "USD:10" as AmountString, + amountRaw: "USD:10" as AmountString, + contractTerms: { + version: 1, + nonce: "123213123", + choices: [{ + amount: "USD:0.5" as AmountString, + description: "Access to the article", + inputs: [], + max_fee: "USD:1" as AmountString, + outputs: [], + },{ + amount: "USD:10" as AmountString, + description: "One month of access", + description_i18n: "Buy one month of access to articles", + inputs: [], + max_fee: "USD:1" as AmountString, + outputs: [{ + token_family_slug: "zxc", + type: MerchantContractOutputType.Token, + key_index: 1, + // count: 1 + }], + }], + merchant: { + name: "the merchant", + logo: merchantIcon, + website: "https://www.themerchant.taler", + email: "contact@merchant.taler", + }, + pay_deadline: { + t_s: new Date().getTime() / 1000 + 60 * 60 * 3, + }, + amount: "USD:10" as AmountString, + summary: "some beers", + } as Partial<ContractTerms> as any, + contractTermsHash: "123456", + }, +}); + export const PaymentPossibleWithFee = tests.createExample(BaseView, { status: "ready", error: undefined, diff --git a/packages/taler-wallet-webextension/src/cta/Payment/views.tsx b/packages/taler-wallet-webextension/src/cta/Payment/views.tsx @@ -31,6 +31,7 @@ import { Time } from "../../components/Time.js"; import { AgeSign, SuccessBox, + TableWithRoundRows, WarningBox, } from "../../components/styled/index.js"; import { MerchantDetails } from "../../wallet/Transaction.js"; @@ -67,7 +68,7 @@ export function BaseView(state: SupportedStates): VNode { !expiration || expiration.t_ms === "never" ? undefined : AbsoluteTime.cmp(expiration, inFiveMinutes) === -1; - + const choices = contractTerms.version && contractTerms.version === 1 ? contractTerms.choices : []; // choices[0].inputs[0].type === return ( @@ -115,7 +116,32 @@ export function BaseView(state: SupportedStates): VNode { /> </section> </EnabledBySettings> - + <TableWithRoundRows> + {choices.map((entry, idx) => { + const selected = state.status === "ready" && state.selectedChoice === idx; + const av = Amounts.parseOrThrow(entry.amount); + + return ( + <tr + key={idx} + onClick={state.status === "ready" ? () => state.onSelectChoice(idx) : undefined} + style={{ cursor: !selected ? "pointer" : "default" }} + > + <td>{av.currency}</td> + <td + style={{ + fontSize: "2em", + textAlign: "right", + width: "100%", + }} + > + {Amounts.stringifyValue(av, 2)} + </td> + </tr> + ); + })} + </TableWithRoundRows> + <PaymentButtons amount={effective} payStatus={state.payStatus}