commit d978582967a4053d7409ac04ebc196397d7fa0db parent cc062961c0050ea82d3386723b2b120b6d04d65f Author: Sebastian <sebasjm@gmail.com> Date: Tue, 28 Oct 2025 09:56:19 -0300 wip backoffice Diffstat:
15 files changed, 763 insertions(+), 978 deletions(-)
diff --git a/packages/merchant-backoffice-ui/src/components/modal/index.tsx b/packages/merchant-backoffice-ui/src/components/modal/index.tsx @@ -20,12 +20,13 @@ */ import { + Paytos, PaytoString, PaytoUri, stringifyPaytoUri, TranslatedString, } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { ButtonBetterBulma, SafeHandlerTemplate, useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import { DEFAULT_REQUEST_TIMEOUT } from "../../utils/constants.js"; @@ -36,11 +37,10 @@ interface Props { active?: boolean; description?: string; onCancel?: () => void; - onConfirm?: () => void; + confirm?: SafeHandlerTemplate<any,any>; label?: string; children?: ComponentChildren; danger?: boolean; - disabled?: boolean; /** * sometimes we want to prevent the user to close the dialog by error when clicking outside the box * @@ -53,17 +53,16 @@ export function ConfirmModal({ active, description, onCancel, - onConfirm, + confirm, children, danger, - disabled, label = "Confirm", noCancelButton }: Props): VNode { const { i18n } = useTranslationContext(); return ( <div class={active ? "modal is-active" : "modal"}> - <div class="modal-background " onClick={onCancel ?? onConfirm} /> + <div class="modal-background " onClick={onCancel} /> <div class="modal-card" style={{ maxWidth: 700 }}> <header class="modal-card-head"> {!description ? null : ( @@ -74,13 +73,13 @@ export function ConfirmModal({ <button class="delete " aria-label="close" - onClick={onCancel ?? onConfirm} + onClick={onCancel} /> </header> <section class="modal-card-body">{children}</section> <footer class="modal-card-foot"> <div class="buttons is-right" style={{ width: "100%" }}> - {onConfirm ? ( + {confirm ? ( <Fragment> {onCancel && !noCancelButton ? ( <button class="button " onClick={onCancel}> @@ -88,13 +87,12 @@ export function ConfirmModal({ </button> ) : undefined} - <button + <ButtonBetterBulma class={danger ? "button is-danger " : "button is-info "} - disabled={disabled} - onClick={onConfirm} + onClick={confirm} > <i18n.Translate>{label}</i18n.Translate> - </button> + </ButtonBetterBulma> </Fragment> ) : ( (noCancelButton ? undefined : @@ -121,9 +119,8 @@ export function ContinueModal({ active, description, onCancel, - onConfirm, + confirm, children, - disabled, }: Props): VNode { const { i18n } = useTranslationContext(); return ( @@ -137,13 +134,12 @@ export function ContinueModal({ <section class="modal-card-body">{children}</section> <footer class="modal-card-foot"> <div class="buttons is-right" style={{ width: "100%" }}> - <button + <ButtonBetterBulma class="button is-success " - disabled={disabled} - onClick={onConfirm} + onClick={confirm} > <i18n.Translate>Continue</i18n.Translate> - </button> + </ButtonBetterBulma> </div> </footer> </div> @@ -182,7 +178,7 @@ export function ClearConfirmModal({ description, onCancel, onClear, - onConfirm, + confirm, children, }: Props & { onClear?: () => void }): VNode { const { i18n } = useTranslationContext(); @@ -209,13 +205,12 @@ export function ClearConfirmModal({ <button class="button " onClick={onCancel}> <i18n.Translate>Cancel</i18n.Translate> </button> - <button + <ButtonBetterBulma class="button is-info" - onClick={onConfirm} - disabled={onConfirm === undefined} + onClick={confirm} > <i18n.Translate>Confirm</i18n.Translate> - </button> + </ButtonBetterBulma> </div> </footer> </div> @@ -230,9 +225,9 @@ export function ClearConfirmModal({ interface CompareAccountsModalProps { onCancel: () => void; - onConfirm: (account: PaytoString) => void; - formPayto: PaytoUri | undefined; - testPayto: PaytoUri; + confirm: SafeHandlerTemplate<any,any>; + formPayto: Paytos.FullPaytoString | undefined; + testPayto: Paytos.FullPaytoString; } function getHostFromHostPath(s: string | undefined) { @@ -259,7 +254,7 @@ function getAccountIdFromHostPath(s: string | undefined) { export function CompareAccountsModal({ onCancel, - onConfirm, + confirm, formPayto, testPayto, }: CompareAccountsModalProps): VNode { @@ -270,7 +265,7 @@ export function CompareAccountsModal({ description={i18n.str`Comparing account details`} active onCancel={onCancel} - onConfirm={() => onConfirm(stringifyPaytoUri(testPayto))} + confirm={confirm} > <p> <i18n.Translate> @@ -372,8 +367,8 @@ export function CompareAccountsModal({ interface ValidateBankAccountModalProps { onCancel: () => void; - origin: PaytoUri; - targets: PaytoUri[]; + origin: Paytos.URI; + targets: Paytos.URI[]; } export function ValidBankAccount({ onCancel, @@ -517,7 +512,7 @@ export function ValidBankAccount({ </i18n.Translate> </td> <td> - <CopyButton getContent={() => stringifyPaytoUri(payto)} /> + <CopyButton getContent={() => Paytos.toFullString(payto)} /> </td> </tr> </tbody> @@ -625,275 +620,6 @@ export const CopiedIcon = (): VNode => ( </svg> ); -interface DeleteModalProps { - element: { id: string; name: string }; - onCancel: () => void; - onConfirm: (id: string) => void; -} - -export function DeleteModal({ - element, - onCancel, - onConfirm, -}: DeleteModalProps): VNode { - const { i18n } = useTranslationContext(); - return ( - <ConfirmModal - label={`Delete instance`} - description={`Delete the instance "${element.name}"`} - danger - active - onCancel={onCancel} - onConfirm={() => onConfirm(element.id)} - > - <p> - <i18n.Translate> - If you delete the instance named <b>"{element.name}"</b>{" "} - (ID: <b>{element.id}</b>), the merchant will no longer be able to process orders and refunds - </i18n.Translate> - </p> - <p> - <i18n.Translate> - This action deletes the instance's private key, but preserves all transaction data. - You can still access the transaction data after having deleted the instance. - </i18n.Translate> - </p> - <p class="warning"> - <i18n.Translate> - Deleting an instance{" "} - <b> - <i18n.Translate>This cannot be undone!</i18n.Translate> - </b> - </i18n.Translate> - </p> - </ConfirmModal> - ); -} - -export function PurgeModal({ - element, - onCancel, - onConfirm, -}: DeleteModalProps): VNode { - const { i18n } = useTranslationContext(); - return ( - <ConfirmModal - label={`Purge the instance`} - description={`Purge the instance "${element.name}"`} - danger - active - onCancel={onCancel} - onConfirm={() => onConfirm(element.id)} - > - <p> - <i18n.Translate> - If you purge the instance named <b>"{element.name}"</b> (ID:{" "} - <b>{element.id}</b>), you will also delete all of its transaction data! - </i18n.Translate> - </p> - <p> - <i18n.Translate> - The instance will disappear from your list and you will no longer be able to access its data. - </i18n.Translate> - </p> - <p class="warning"> - <i18n.Translate> - Purging an instance{" "} - <b> - <i18n.Translate>This cannot be undone!</i18n.Translate> - </b> - </i18n.Translate> - </p> - </ConfirmModal> - ); -} - -// interface UpdateTokenModalProps { -// oldToken?: string; -// onCancel: () => void; -// onConfirm: (value: string) => void; -// onClear: () => void; -// } - -// //FIXME: merge UpdateTokenModal with SetTokenNewInstanceModal -// export function UpdateTokenModal({ -// onCancel, -// onClear, -// onConfirm, -// oldToken, -// }: UpdateTokenModalProps): VNode { -// type State = { old_token: string; new_token: string; repeat_token: string }; -// const [form, setValue] = useState<Partial<State>>({ -// old_token: "", -// new_token: "", -// repeat_token: "", -// }); -// const { i18n } = useTranslationContext(); - -// const hasInputTheCorrectOldToken = oldToken && oldToken !== form.old_token; -// const errors = undefinedIfEmpty({ -// old_token: hasInputTheCorrectOldToken -// ? i18n.str`This is not matching the current password` -// : undefined, -// new_token: !form.new_token -// ? i18n.str`Required` -// : form.new_token === form.old_token -// ? i18n.str`The new token cannot be the old token` -// : undefined, -// repeat_token: -// form.new_token !== form.repeat_token -// ? i18n.str`This is not matching` -// : undefined, -// }); - -// const hasErrors = errors !== undefined; - -// const { state } = useSessionContext(); - -// const text = i18n.str`You are updating the password for the instance with ID ${state.instance}`; - -// return ( -// <ClearConfirmModal -// description={text} -// onCancel={onCancel} -// onConfirm={!hasErrors ? () => onConfirm(form.new_token!) : undefined} -// onClear={!hasInputTheCorrectOldToken && oldToken ? onClear : undefined} -// > -// <div class="columns"> -// <div class="column" /> -// <div class="column is-four-fifths"> -// <FormProvider errors={errors} object={form} valueHandler={setValue}> -// {oldToken && ( -// <Input<State> -// name="old_token" -// label={i18n.str`Old password`} -// tooltip={i18n.str`The password you are currently using`} -// inputType="password" -// /> -// )} -// <Input<State> -// name="new_token" -// label={i18n.str`New password`} -// tooltip={i18n.str`The new password to be used`} -// inputType="password" -// /> -// <Input<State> -// name="repeat_token" -// label={i18n.str`Repeat the password`} -// tooltip={i18n.str`Please repeat your new password`} -// inputType="password" -// /> -// </FormProvider> -// <p> -// <i18n.Translate> -// Leaving the password void will enable public access to the instance -// </i18n.Translate> -// </p> -// </div> -// <div class="column" /> -// </div> -// </ClearConfirmModal> -// ); -// } - -// export function SetTokenNewInstanceModal({ -// onCancel, -// onClear, -// onConfirm, -// }: UpdateTokenModalProps): VNode { -// type State = { old_token: string; new_token: string; repeat_token: string }; -// const [form, setValue] = useState<Partial<State>>({ -// new_token: "", -// repeat_token: "", -// }); -// const { i18n } = useTranslationContext(); - -// const errors = undefinedIfEmpty({ -// new_token: !form.new_token -// ? i18n.str`Required` -// : form.new_token === form.old_token -// ? i18n.str`The new password cannot be the old password` -// : undefined, -// repeat_token: -// form.new_token !== form.repeat_token -// ? i18n.str`This is not matching` -// : undefined, -// }); - -// const hasErrors = errors !== undefined; - -// return ( -// <div class="modal is-active"> -// <div class="modal-background " onClick={onCancel} /> -// <div class="modal-card"> -// <header class="modal-card-head"> -// <p class="modal-card-title">{i18n.str`You are setting the password for the new instance`}</p> -// <button class="delete " aria-label="close" onClick={onCancel} /> -// </header> -// <section class="modal-card-body is-main-section"> -// <div class="columns"> -// <div class="column" /> -// <div class="column is-four-fifths"> -// <FormProvider -// errors={errors} -// object={form} -// valueHandler={setValue} -// > -// <Input<State> -// name="new_token" -// label={i18n.str`New password`} -// tooltip={i18n.str`The new password to be used`} -// inputType="password" -// /> -// <Input<State> -// name="repeat_token" -// label={i18n.str`Repeat the password`} -// tooltip={i18n.str`Please repeat your new password`} -// inputType="password" -// /> -// </FormProvider> -// <p> -// <i18n.Translate> -// Making use of the external authorization method, no check will be performed by -// the Taler Merchant Backend -// </i18n.Translate> -// </p> -// </div> -// <div class="column" /> -// </div> -// </section> -// <footer class="modal-card-foot"> -// {onClear && ( -// <button -// class="button is-danger" -// onClick={onClear} -// disabled={onClear === undefined} -// > -// <i18n.Translate>Enable external authorization</i18n.Translate> -// </button> -// )} -// <div class="buttons is-right" style={{ width: "100%" }}> -// <button class="button " onClick={onCancel}> -// <i18n.Translate>Cancel</i18n.Translate> -// </button> -// <button -// class="button is-info" -// onClick={() => onConfirm(form.new_token!)} -// disabled={hasErrors} -// > -// <i18n.Translate>Set password</i18n.Translate> -// </button> -// </div> -// </footer> -// </div> -// <button -// class="modal-close is-large " -// aria-label="close" -// onClick={onCancel} -// /> -// </div> -// ); -// } export function LoadingModal({ onCancel }: { onCancel: () => void }): VNode { const { i18n } = useTranslationContext(); diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/CreatePage.tsx @@ -20,12 +20,22 @@ */ import { + AccessToken, Duration, + HttpStatusCode, MerchantAuthMethod, - TalerMerchantApi + opEmptySuccess, + opFixedSuccess, + TalerMerchantApi, } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; +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, @@ -33,9 +43,16 @@ import { } from "../../../components/form/FormProvider.js"; import { Input } from "../../../components/form/Input.js"; import { DefaultInstanceFormFields } from "../../../components/instance/DefaultInstanceFormFields.js"; +import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; +import { useSessionContext } from "../../../context/session.js"; import { usePreference } from "../../../hooks/preference.js"; -import { EMAIL_REGEX, INSTANCE_ID_REGEX, PHONE_JUST_NUMBERS_REGEX } from "../../../utils/constants.js"; +import { + EMAIL_REGEX, + INSTANCE_ID_REGEX, + PHONE_JUST_NUMBERS_REGEX, +} from "../../../utils/constants.js"; import { undefinedIfEmpty } from "../../../utils/table.js"; +import { FOREVER_REFRESHABLE_TOKEN } from "../../login/index.js"; export type Entity = TalerMerchantApi.InstanceConfigurationMessage & { auth_token?: string; @@ -44,7 +61,7 @@ export type Entity = TalerMerchantApi.InstanceConfigurationMessage & { }; export interface Props { - onCreate: (d: TalerMerchantApi.InstanceConfigurationMessage) => Promise<void>; + onConfirm: () => void; onBack?: () => void; forceId?: string; } @@ -63,7 +80,7 @@ function with_defaults(id?: string): Partial<Entity> { type TokenForm = { password: string; repeat: string }; -export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { +export function CreatePage({ onConfirm, onBack, forceId }: Props): VNode { const [pref, updatePref] = usePreference(); const { i18n } = useTranslationContext(); const [value, valueHandler] = useState(with_defaults(forceId)); @@ -76,16 +93,6 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { ? i18n.str`Invalid` : undefined, name: !value.name ? i18n.str`Required` : undefined, - // accounts: - // !value.accounts || !value.accounts.length - // ? i18n.str`Required` - // : undefinedIfEmpty( - // value.accounts.map((p) => { - // return !PAYTO_REGEX.test(p.payto_uri) - // ? i18n.str`Invalid` - // : undefined; - // }), - // ), email: !value.email ? undefined : !EMAIL_REGEX.test(value.email) @@ -135,24 +142,82 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { }); const hasTokenErrors = tokenFormErrors === undefined; + const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const { state: session, lib, logIn } = useSessionContext(); + const mfa = useChallengeHandler(); - const submit = (): Promise<void> => { - // use conversion instead of this - const newValue = structuredClone(value); - - newValue.auth_token = undefined; - newValue.auth = { + const data: TalerMerchantApi.InstanceConfigurationMessage = { + ...(value as TalerMerchantApi.InstanceConfigurationMessage), + auth: { method: MerchantAuthMethod.TOKEN, password: tokenForm.password!, - }; - if (!newValue.address) newValue.address = {}; - if (!newValue.jurisdiction) newValue.jurisdiction = {}; - - return onCreate(newValue as TalerMerchantApi.InstanceConfigurationMessage); + }, }; + // if (!newValue.address) newValue.address = {}; + // if (!newValue.jurisdiction) newValue.jurisdiction = {}; + const create = safeFunctionHandler( + async ( + token: AccessToken, + data: TalerMerchantApi.InstanceConfigurationMessage, + challengeIds: string[], + ) => { + const instanceResp = await lib.instance.createInstance(token, data, { + challengeIds, + }); + if (instanceResp.type === "fail") return instanceResp; + if (data.auth.password) { + const tokenResp = await lib.instance.createAccessToken( + data.id, + data.auth.password, + FOREVER_REFRESHABLE_TOKEN(i18n.str`Instance created`), + ); + if (tokenResp.type === "fail") return tokenResp; + return opFixedSuccess(tokenResp.body); + } + return opEmptySuccess(); + }, + !session.token || hasErrors || hasTokenErrors + ? undefined + : [session.token, data, []], + ); + create.onSuccess = (success, oldtoken, data) => { + if (success) { + logIn(data.id, success.access_token); + } + onConfirm(); + }; + create.onFail = (fail) => { + switch (fail.case) { + case HttpStatusCode.Accepted: + mfa.onChallengeRequired(fail.body); + return i18n.str`Second factor authentication required.`; + case HttpStatusCode.Unauthorized: + return i18n.str`Unaouthorized.`; + case HttpStatusCode.Conflict: + return i18n.str`Conflcit.`; + case HttpStatusCode.NotFound: + return i18n.str`Not found.`; + } + }; + const retry = create.lambda((ids: string[]) => [ + create.args![0], + create.args![1], + ids, + ]); + + if (mfa.pendingChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={mfa.pendingChallenge} + onCompleted={retry} + onCancel={mfa.doCancelChallenge} + /> + ); + } return ( <div> + <LocalNotificationBannerBulma notification={notification} /> <section class="section is-main-section"> <div class="tabs is-toggle is-fullwidth is-small"> <ul> @@ -221,8 +286,7 @@ export function CreatePage({ onCreate, onBack, forceId }: Props): VNode { </button> )} <ButtonBetterBulma - onClick={submit} - disabled={hasErrors || !hasTokenErrors} + onClick={create} data-tooltip={ hasErrors ? i18n.str`Please complete the marked fields and choose authorization method` diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx @@ -44,71 +44,9 @@ interface Props { export type Entity = TalerMerchantApi.InstanceConfigurationMessage; export default function Create({ onBack, onConfirm, forceId }: Props): VNode { - - const { i18n } = useTranslationContext(); - const { lib, state, logIn } = useSessionContext(); - - const { doCancelChallenge, pendingChallenge, withMfaHandler } = - useChallengeHandler(); - - const [onFirstCall, repeatCall] = withMfaHandler( - ({ challengeIds, onChallengeRequired }) => - async function createInstaceImpl(d: InstanceConfigurationMessage) { - if (state.status !== "loggedIn") return; - try { - const resp = await lib.instance.createInstance(state.token, d, { - challengeIds: challengeIds, - }); - if (resp.type === "fail") { - if (resp.case === HttpStatusCode.Accepted) { - onChallengeRequired(resp.body); - return; - } - setNotif({ - message: i18n.str`Failed to create instance`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - if (d.auth.password) { - //if auth has been updated, request a new access token - const result = await lib.instance.createAccessToken( - d.id, - d.auth.password, - FOREVER_REFRESHABLE_TOKEN(i18n.str`Instance created`), - ); - if (result.type === "ok") { - const { access_token: token } = result.body; - logIn(state.instance, token); - } - } - onConfirm(); - } catch (error) { - setNotif({ - message: i18n.str`Failed to create instance`, - type: "ERROR", - description: error instanceof Error ? error.message : String(error), - }); - } - }, - ); - - if (pendingChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={pendingChallenge} - onCompleted={repeatCall} - onCancel={doCancelChallenge} - /> - ); - } - return ( <Fragment> - <LocalNotificationBannerBulma notification={notification} /> - - <CreatePage onBack={onBack} forceId={forceId} onCreate={onFirstCall} /> + <CreatePage onBack={onBack} forceId={forceId} onConfirm={onConfirm} /> </Fragment> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/admin/list/View.tsx b/packages/merchant-backoffice-ui/src/paths/admin/list/View.tsx @@ -19,27 +19,37 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { TalerMerchantApi } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + AccessToken, + HttpStatusCode, + TalerMerchantApi, +} from "@gnu-taler/taler-util"; +import { + LocalNotificationBannerBulma, + SafeHandlerTemplate, + useChallengeHandler, + useLocalNotificationBetter, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { CardTable as CardTableActive } from "./TableActive.js"; +import { Fragment } from "preact"; +import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; +import { useSessionContext } from "../../../context/session.js"; +import { ConfirmModal } from "../../../components/modal/index.js"; interface Props { instances: TalerMerchantApi.Instance[]; onCreate: () => void; onUpdate: (id: string) => void; onChangePassword: (id: string) => void; - onDelete: (id: TalerMerchantApi.Instance) => void; - onPurge: (id: TalerMerchantApi.Instance) => void; selected?: boolean; } export function View({ instances, onCreate, - onDelete, - onPurge, onUpdate, onChangePassword, selected, @@ -56,55 +66,219 @@ export function View({ ? instances.filter((i) => !i.deleted) : instances; + const { state: session, lib } = useSessionContext(); + + const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); + const deleteAction = safeFunctionHandler( + ( + token: AccessToken, + instance: TalerMerchantApi.Instance, + purge: boolean, + challengeIds: string[], + ) => + lib.instance.deleteInstance(token, instance.id, { challengeIds, purge }), + ); + + const [deleting, setDeleting] = useState<TalerMerchantApi.Instance>(); + const [purging, setPurging] = useState<boolean>(); + + deleteAction.onSuccess = (_, t, instance) => { + setDeleting(undefined); + return i18n.str`Instance "${instance.name}" (ID: ${instance.id}) has been deleted.`; + }; + + deleteAction.onFail = (fail, t, i, p) => { + switch (fail.case) { + case HttpStatusCode.Accepted: + mfa.onChallengeRequired( + fail.body, + deleteAction.lambda((ids: string[]) => [t, i, p, ids]), + ); + return i18n.str`Second factor authentication required.`; + case HttpStatusCode.Unauthorized: + return i18n.str`Unauthorized.`; + case HttpStatusCode.NotFound: + return i18n.str`Not found.`; + case HttpStatusCode.Conflict: + return i18n.str`Conflict.`; + } + }; + + const remove = + !session.token || !deleting + ? deleteAction + : deleteAction.withArgs(session.token, deleting, false, []); + const purge = + !session.token || !deleting + ? deleteAction + : deleteAction.withArgs(session.token, deleting, true, []); + + if (mfa.pendingChallenge && mfa.repeatCall) { + return ( + <SolveMFAChallenges + currentChallenge={mfa.pendingChallenge} + onCompleted={mfa.repeatCall} + onCancel={mfa.doCancelChallenge} + /> + ); + } + return ( - <section class="section is-main-section"> - <div class="columns"> - <div class="column is-two-thirds"> - <div class="tabs" style={{ overflow: "inherit" }}> - <ul> - <li class={showIsActive}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`Only show active instances`} - > - <a onClick={() => setShow("active")}> - <i18n.Translate>Active</i18n.Translate> - </a> - </div> - </li> - <li class={showIsDeleted}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`Only show deleted instances`} - > - <a onClick={() => setShow("deleted")}> - <i18n.Translate>Deleted</i18n.Translate> - </a> - </div> - </li> - <li class={showAll}> - <div - class="has-tooltip-right" - data-tooltip={i18n.str`Show all instances`} - > - <a onClick={() => setShow(null)}> - <i18n.Translate>All</i18n.Translate> - </a> - </div> - </li> - </ul> + <Fragment> + <LocalNotificationBannerBulma notification={notification} /> + {deleting && + (purging ? ( + <PurgeModal + element={deleting} + onCancel={() => setDeleting(undefined)} + confirm={purge} + /> + ) : ( + <DeleteModal + element={deleting} + onCancel={() => setDeleting(undefined)} + confirm={remove} + /> + ))} + <section class="section is-main-section"> + <div class="columns"> + <div class="column is-two-thirds"> + <div class="tabs" style={{ overflow: "inherit" }}> + <ul> + <li class={showIsActive}> + <div + class="has-tooltip-right" + data-tooltip={i18n.str`Only show active instances`} + > + <a onClick={() => setShow("active")}> + <i18n.Translate>Active</i18n.Translate> + </a> + </div> + </li> + <li class={showIsDeleted}> + <div + class="has-tooltip-right" + data-tooltip={i18n.str`Only show deleted instances`} + > + <a onClick={() => setShow("deleted")}> + <i18n.Translate>Deleted</i18n.Translate> + </a> + </div> + </li> + <li class={showAll}> + <div + class="has-tooltip-right" + data-tooltip={i18n.str`Show all instances`} + > + <a onClick={() => setShow(null)}> + <i18n.Translate>All</i18n.Translate> + </a> + </div> + </li> + </ul> + </div> </div> </div> - </div> - <CardTableActive - instances={showingInstances} - onDelete={onDelete} - onPurge={onPurge} - onChangePassword={onChangePassword} - onUpdate={onUpdate} - selected={selected} - onCreate={onCreate} - /> - </section> + <CardTableActive + instances={showingInstances} + onDelete={(d) => { + setDeleting(d) + setPurging(false) + }} + onPurge={(d) => { + setDeleting(d) + setPurging(true) + }} + onChangePassword={onChangePassword} + onUpdate={onUpdate} + selected={selected} + onCreate={onCreate} + /> + </section> + </Fragment> + ); +} + +interface DeleteModalProps { + element: { id: string; name: string }; + onCancel: () => void; + confirm: SafeHandlerTemplate<any, any>; +} + +export function DeleteModal({ + element, + onCancel, + confirm, +}: DeleteModalProps): VNode { + const { i18n } = useTranslationContext(); + return ( + <ConfirmModal + label={`Delete instance`} + description={`Delete the instance "${element.name}"`} + danger + active + onCancel={onCancel} + confirm={confirm} + > + <p> + <i18n.Translate> + If you delete the instance named <b>"{element.name}"</b>{" "} + (ID: <b>{element.id}</b>), the merchant will no longer be able to + process orders and refunds + </i18n.Translate> + </p> + <p> + <i18n.Translate> + This action deletes the instance's private key, but preserves all + transaction data. You can still access the transaction data after + having deleted the instance. + </i18n.Translate> + </p> + <p class="warning"> + <i18n.Translate> + Deleting an instance{" "} + <b> + <i18n.Translate>This cannot be undone!</i18n.Translate> + </b> + </i18n.Translate> + </p> + </ConfirmModal> + ); +} + +function PurgeModal({ element, onCancel, confirm }: DeleteModalProps): VNode { + const { i18n } = useTranslationContext(); + return ( + <ConfirmModal + label={`Purge the instance`} + description={`Purge the instance "${element.name}"`} + danger + active + onCancel={onCancel} + confirm={confirm} + > + <p> + <i18n.Translate> + If you purge the instance named <b>"{element.name}"</b> (ID:{" "} + <b>{element.id}</b>), you will also delete all of its transaction + data! + </i18n.Translate> + </p> + <p> + <i18n.Translate> + The instance will disappear from your list and you will no longer be + able to access its data. + </i18n.Translate> + </p> + <p class="warning"> + <i18n.Translate> + Purging an instance{" "} + <b> + <i18n.Translate>This cannot be undone!</i18n.Translate> + </b> + </i18n.Translate> + </p> + </ConfirmModal> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx @@ -20,30 +20,17 @@ */ import { - ChallengeResponse, HttpStatusCode, TalerError, TalerMerchantApi, - assertUnreachable, + assertUnreachable } from "@gnu-taler/taler-util"; -import { - LocalNotificationBannerBulma, - useChallengeHandler, - useLocalNotificationBetter, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { useState } from "preact/hooks"; import { ErrorLoadingMerchant } from "../../../components/ErrorLoadingMerchant.js"; import { Loading } from "../../../components/exception/loading.js"; -import { NotificationCard } from "../../../components/menu/index.js"; -import { DeleteModal, PurgeModal } from "../../../components/modal/index.js"; -import { useSessionContext } from "../../../context/session.js"; import { useBackendInstances } from "../../../hooks/instance.js"; -import { Notification } from "../../../utils/types.js"; import { LoginPage } from "../../login/index.js"; import { View } from "./View.js"; -import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; interface Props { onCreate: () => void; @@ -58,14 +45,6 @@ export default function Instances({ onChangePassword, }: Props): VNode { const result = useBackendInstances(); - const [deleting, setDeleting] = useState<TalerMerchantApi.Instance>(); - const [purging, setPurging] = useState<boolean>(); - - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); - const mfa = useChallengeHandler(); - - const { i18n } = useTranslationContext(); - const { state, lib } = useSessionContext(); if (!result) return <Loading />; if (result instanceof TalerError) { @@ -82,90 +61,16 @@ export default function Instances({ } } - const [doDelete, repeatDelete] = mfa.withMfaHandler( - ({ challengeIds, onChallengeRequired }) => - async function doDeleteImpl(): Promise<void> { - if (state.status !== "loggedIn") { - return; - } - try { - const resp = await lib.instance.deleteInstance( - state.token, - deleting!.id, - { challengeIds }, - ); - if (resp.type === "ok") { - setNotif({ - message: i18n.str`Instance "${deleting!.name}" (ID: ${ - deleting!.id - }) has been deleted`, - type: "SUCCESS", - }); - } else { - if (resp.case === HttpStatusCode.Accepted) { - onChallengeRequired(resp.body); - return; - } - setNotif({ - message: i18n.str`Failed to delete instance`, - type: "ERROR", - description: resp.detail?.hint, - }); - } - } catch (error) { - setNotif({ - message: i18n.str`Failed to delete instance`, - type: "ERROR", - description: error instanceof Error ? error.message : undefined, - }); - // pushNotification({message: 'delete_error', type: 'ERROR' }) - } - setDeleting(undefined); - }, - ); - - if (mfa.pendingChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={mfa.pendingChallenge} - onCompleted={repeatDelete} - onCancel={mfa.doCancelChallenge} - /> - ); - } return ( <Fragment> - <LocalNotificationBannerBulma notification={notification} /> <View instances={result.body.instances} - onDelete={(d) => { - setDeleting(d); - setPurging(false); - }} - onCreate={onCreate} - onPurge={(d) => { - setDeleting(d); - setPurging(true); - }} onUpdate={onUpdate} onChangePassword={onChangePassword} - selected={!!deleting} + onCreate={onCreate} + /> - {deleting && - (purging ? ( - <PurgeModal - element={deleting} - onCancel={() => setDeleting(undefined)} - onConfirm={doDelete} - /> - ) : ( - <DeleteModal - element={deleting} - onCancel={() => setDeleting(undefined)} - onConfirm={doDelete} - /> - ))} </Fragment> ); } 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 @@ -25,7 +25,13 @@ import { LoginTokenScope, TalerMerchantApi, } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { + ButtonBetterBulma, + LocalNotificationBannerBulma, + useChallengeHandler, + useLocalNotificationBetter, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { @@ -37,6 +43,8 @@ import { Input } from "../../../../components/form/Input.js"; import { InputDuration } from "../../../../components/form/InputDuration.js"; import { InputSelector } from "../../../../components/form/InputSelector.js"; import { undefinedIfEmpty } from "../../../../utils/table.js"; +import { useSessionContext } from "../../../../context/session.js"; +import { SolveMFAChallenges } from "../../../../components/SolveMFA.js"; type Entity = { scope: TalerMerchantApi.LoginTokenRequest["scope"]; @@ -46,10 +54,7 @@ type Entity = { } & TalerForm; interface Props { - onCreate: ( - pwd: string, - d: TalerMerchantApi.LoginTokenRequest, - ) => Promise<void>; + onCreated: (asd: TalerMerchantApi.LoginTokenSuccessResponse) => void; onBack?: () => void; } @@ -69,10 +74,11 @@ const VALID_TOKEN_SCOPE = [ LoginTokenScope.All, ]; -export function CreatePage({ onCreate, onBack }: Props): VNode { +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, @@ -83,18 +89,48 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { const hasErrors = errors !== undefined; - const submitForm = () => { - if (hasErrors) return Promise.reject(); + const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const mfa = useChallengeHandler(); - return onCreate(state.password!, { - scope: state.scope!, - duration: Duration.toTalerProtocolDuration(state.duration!), - description: state.description!, - }); + const data: TalerMerchantApi.LoginTokenRequest = { + scope: state.scope!, + description: state.description, + duration: !state.duration + ? undefined + : Duration.toTalerProtocolDuration(state.duration), }; + const create = safeFunctionHandler( + ( + pwd: string, + request: TalerMerchantApi.LoginTokenRequest, + challengeIds: string[], + ) => + lib.instance.createAccessToken(session.instance, pwd, request, { + challengeIds, + }), + !!errors || !state.password ? undefined : [state.password, data, []], + ); + + create.onSuccess = onCreated; + const retry = create.lambda((ids: string[]) => [ + create.args![0], + create.args![1], + ids, + ]); + + if (mfa.pendingChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={mfa.pendingChallenge} + onCompleted={retry} + onCancel={mfa.doCancelChallenge} + /> + ); + } return ( <Fragment> + <LocalNotificationBannerBulma notification={notification} /> <section class="section is-main-section"> <div class="columns"> <div class="column" /> @@ -122,30 +158,30 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { 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 "" + if (!s) return ""; switch (s) { case LoginTokenScope.All: - return i18n.str`Allows all operations without limit.` + return i18n.str`Allows all operations without limit.`; case LoginTokenScope.ReadOnly: - return i18n.str`Allows all operations to read information.` + 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.` + 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.` + 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.` + 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.` + 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.` + 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.` + 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.` + 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.` + 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.` + 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 i18n.str`All (refreshable)`; case LoginTokenScope.Spa: @@ -159,7 +195,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { })(state.scope)} toStr={(str) => { if (!str) { - return i18n.str`Choose one` + return i18n.str`Choose one`; } const s = str as LoginTokenScope; switch (s) { @@ -202,8 +238,6 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { inputType="password" label={i18n.str`Current password`} /> - - </FormProvider> <div class="buttons is-right mt-5"> {onBack && ( @@ -212,13 +246,12 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { </button> )} <ButtonBetterBulma - disabled={hasErrors} data-tooltip={ hasErrors ? i18n.str`Please complete the marked fields` : i18n.str`Confirm operation` } - onClick={submitForm} + onClick={create} > <i18n.Translate>Confirm</i18n.Translate> </ButtonBetterBulma> 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 @@ -21,25 +21,16 @@ import { AbsoluteTime, - ChallengeResponse, - HttpStatusCode, - TalerMerchantApi, + TalerMerchantApi } from "@gnu-taler/taler-util"; import { - LocalNotificationBannerBulma, Time, - useChallengeHandler, - useLocalNotificationBetter, - useTranslationContext, + useTranslationContext } from "@gnu-taler/web-util/browser"; 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 { Notification } from "../../../../utils/types.js"; -import { CreatePage } from "./CreatePage.js"; import { ConfirmModal, Row } from "../../../../components/modal/index.js"; -import { SolveMFAChallenges } from "../../../../components/SolveMFA.js"; +import { CreatePage } from "./CreatePage.js"; export type Entity = TalerMerchantApi.LoginTokenRequest; interface Props { @@ -51,73 +42,54 @@ export default function AccessTokenCreatePage({ onConfirm, onBack, }: Props): VNode { - const { state, lib } = useSessionContext(); - const { i18n } = useTranslationContext(); const [ok, setOk] = useState<{ token: string; expiration: AbsoluteTime }>(); - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); - const mfa = useChallengeHandler(); - - const [doCreate, repeatCreate] = mfa.withMfaHandler( - ({ challengeIds, onChallengeRequired }) => - async function doCreateImpl(pwd:string, request: Entity) { - try { - const resp = await lib.instance.createAccessToken( - state.instance, - pwd, - request, - { challengeIds }, - ); - if (resp.type === "fail") { - if (resp.case === HttpStatusCode.Accepted) { - onChallengeRequired(resp.body); - return; - } - setNotif({ - message: i18n.str`Could not create access token`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - setOk({ - expiration: AbsoluteTime.fromProtocolTimestamp( - resp.body.expiration, - ), - token: resp.body.access_token, - }); - } catch (error) { - setNotif({ - message: i18n.str`Could not create access token`, - type: "ERROR", - description: error instanceof Error ? error.message : String(error), - }); - } - }, - ); - - if (mfa.pendingChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={mfa.pendingChallenge} - onCompleted={repeatCreate} - onCancel={mfa.doCancelChallenge} - /> - ); - } + // const [doCreate, repeatCreate] = mfa.withMfaHandler( + // ({ challengeIds, onChallengeRequired }) => + // async function doCreateImpl(pwd:string, request: Entity) { + // try { + // const resp = await lib.instance.createAccessToken( + // state.instance, + // pwd, + // request, + // { challengeIds }, + // ); + // if (resp.type === "fail") { + // if (resp.case === HttpStatusCode.Accepted) { + // onChallengeRequired(resp.body); + // return; + // } + // setNotif({ + // message: i18n.str`Could not create access token`, + // type: "ERROR", + // description: resp.detail?.hint, + // }); + // return; + // } + // setOk({ + // expiration: AbsoluteTime.fromProtocolTimestamp( + // resp.body.expiration, + // ), + // token: resp.body.access_token, + // }); + // } catch (error) { + // setNotif({ + // message: i18n.str`Could not create access token`, + // type: "ERROR", + // description: error instanceof Error ? error.message : String(error), + // }); + // } + // }, + // ); return ( <Fragment> - <LocalNotificationBannerBulma notification={notification} /> {!ok ? undefined : ( <ConfirmModal - // label={`Confirm`} active - onConfirm={() => onConfirm()} - onCancel={() => {}} - noCancelButton + onCancel={onConfirm} description={i18n.str`Access token created`} > <div class="table-container"> @@ -155,7 +127,15 @@ export default function AccessTokenCreatePage({ </div> </ConfirmModal> )} - <CreatePage onBack={onBack} onCreate={doCreate} /> + <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 @@ -22,23 +22,24 @@ import { HttpStatusCode, TalerError, - TalerMerchantApi, TokenInfo, - assertUnreachable, + assertUnreachable } from "@gnu-taler/taler-util"; +import { + LocalNotificationBannerBulma, + useLocalNotificationBetter, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; import { Loading } from "../../../../components/exception/loading.js"; +import { ConfirmModal } from "../../../../components/modal/index.js"; +import { useSessionContext } from "../../../../context/session.js"; 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 { LocalNotificationBannerBulma, useTranslationContext } from "@gnu-taler/web-util/browser"; interface Props { onCreate: () => void; @@ -46,11 +47,10 @@ interface Props { export default function AccessTokenListPage({ onCreate }: Props): VNode { const result = useInstanceAccessTokens(); - const { state, lib } = useSessionContext(); + const { state: session, lib } = useSessionContext(); const [deleting, setDeleting] = useState<TokenInfo | null>(null); const { i18n } = useTranslationContext(); - if (!result) return <Loading />; if (result instanceof TalerError) { return <ErrorLoadingMerchant error={result} />; @@ -72,6 +72,26 @@ export default function AccessTokenListPage({ onCreate }: Props): VNode { } } + const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const deleteToken = safeFunctionHandler( + lib.instance.deleteAccessToken, + !session.token || !deleting ? undefined : [session.token, deleting.serial], + ); + + deleteToken.onSuccess = () => { + setDeleting(null); + }; + deleteToken.onFail = (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Unauthorized.`; + case HttpStatusCode.Forbidden: + return i18n.str`Forbidden.`; + case HttpStatusCode.NotFound: + return i18n.str`Not found.`; + } + }; + return ( <Fragment> <section class="section is-main-section"> @@ -96,34 +116,7 @@ export default function AccessTokenListPage({ onCreate }: Props): VNode { 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); - }} + confirm={deleteToken} > <p class="warning"> <i18n.Translate> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/password/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/password/index.tsx @@ -18,6 +18,7 @@ import { HttpStatusCode, MerchantAuthMethod, TalerError, + TalerMerchantManagementHttpClient, TalerMerchantManagementResultByMethod, assertUnreachable, } from "@gnu-taler/taler-util"; @@ -58,74 +59,64 @@ export default function PasswordPage(props: Props): VNode { const result = useInstanceDetails(); const instanceId = state.instance; - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); - const mfa = useChallengeHandler(); + // const [notification, safeFunctionHandler] = useLocalNotificationBetter(); - const [doChangePassword, repeatChangePassword] = mfa.withMfaHandler( - ({ challengeIds, onChallengeRequired }) => - async function changePassword( - // currentPassword: string | undefined, - newPassword: string, - ) { - // if (currentPassword) { - // const resp = await lib.instance.createAccessToken( - // instanceId, - // currentPassword, - // TEMP_TEST_TOKEN(i18n.str`Testing password`), + // const [doChangePassword, repeatChangePassword] = mfa.withMfaHandler( + // ({ challengeIds, onChallengeRequired }) => + // async function changePassword( + // // currentPassword: string | undefined, + // newPassword: string, + // ) { + // // if (currentPassword) { + // // const resp = await lib.instance.createAccessToken( + // // instanceId, + // // currentPassword, + // // TEMP_TEST_TOKEN(i18n.str`Testing password`), + // // ); + // // if (resp.case === HttpStatusCode.Accepted) { + // // throw Error("FIXME!!!!"); + // // } + // // if (resp.type !== "ok") { + // // throw Error(resp.detail?.hint ?? "The current password is wrong"); + // // } + // // } + + // { + // const resp = await lib.instance.updateCurrentInstanceAuthentication( + // state.token, + // { + // password: newPassword, + // method: MerchantAuthMethod.TOKEN, + // }, + // { challengeIds }, // ); - // if (resp.case === HttpStatusCode.Accepted) { - // throw Error("FIXME!!!!"); - // } - // if (resp.type !== "ok") { - // throw Error(resp.detail?.hint ?? "The current password is wrong"); + // if (resp.type === "fail") { + // if (resp.case === HttpStatusCode.Accepted) { + // onChallengeRequired(resp.body); + // return; + // } + // throw Error(resp.detail?.hint ?? "The request failed"); // } // } - { - const resp = await lib.instance.updateCurrentInstanceAuthentication( - state.token, - { - password: newPassword, - method: MerchantAuthMethod.TOKEN, - }, - { challengeIds }, - ); - if (resp.type === "fail") { - if (resp.case === HttpStatusCode.Accepted) { - onChallengeRequired(resp.body); - return; - } - throw Error(resp.detail?.hint ?? "The request failed"); - } - } + // // const resp = await lib.instance.createAccessToken( + // // instanceId, + // // newPassword, + // // FOREVER_REFRESHABLE_TOKEN(i18n.str`Password changed`), + // // ); + // // if (resp.type === "ok") { + // // logIn(state.instance, resp.body.access_token); + // // return; + // // } else { + // // if (resp.case === HttpStatusCode.Accepted) { + // // throw Error("FIXME!!!!"); + // // } + // // throw Error(resp.detail?.hint ?? "The new login failed"); + // // } + // }, + // ); - // const resp = await lib.instance.createAccessToken( - // instanceId, - // newPassword, - // FOREVER_REFRESHABLE_TOKEN(i18n.str`Password changed`), - // ); - // if (resp.type === "ok") { - // logIn(state.instance, resp.body.access_token); - // return; - // } else { - // if (resp.case === HttpStatusCode.Accepted) { - // throw Error("FIXME!!!!"); - // } - // throw Error(resp.detail?.hint ?? "The new login failed"); - // } - }, - ); - if (mfa.pendingChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={mfa.pendingChallenge} - onCompleted={repeatChangePassword} - onCancel={mfa.doCancelChallenge} - /> - ); - } - - return CommonPassword({ ...props, instanceId }, result, doChangePassword); + return CommonPassword({ ...props, instanceId }, result, lib.instance); } export function AdminPassword(props: Props & { instanceId: string }): VNode { @@ -136,76 +127,67 @@ export function AdminPassword(props: Props & { instanceId: string }): VNode { const instanceId = props.instanceId; - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); - const mfa = useChallengeHandler(); + // const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + // const mfa = useChallengeHandler(); - const [doChangePassword, repeatChangePassword] = mfa.withMfaHandler( - ({ challengeIds, onChallengeRequired }) => - async function changePassword( - // currentPassword: string | undefined, - newPassword: string, - ) { - // if (currentPassword) { - // const resp = await lib.instance.createAccessToken( - // instanceId, - // currentPassword, - // TEMP_TEST_TOKEN(i18n.str`Testing password for instance ${instanceId}`), - // ); - // if (resp.type !== "ok") { - // if (resp.case === HttpStatusCode.Accepted) { - // throw Error("FIXME!!!!"); - // } - // throw Error(resp.detail?.hint ?? "The current password is wrong"); - // } - // } + // const [doChangePassword, repeatChangePassword] = mfa.withMfaHandler( + // ({ challengeIds, onChallengeRequired }) => + // async function changePassword( + // // currentPassword: string | undefined, + // newPassword: string, + // ) { + // // if (currentPassword) { + // // const resp = await lib.instance.createAccessToken( + // // instanceId, + // // currentPassword, + // // TEMP_TEST_TOKEN(i18n.str`Testing password for instance ${instanceId}`), + // // ); + // // if (resp.type !== "ok") { + // // if (resp.case === HttpStatusCode.Accepted) { + // // throw Error("FIXME!!!!"); + // // } + // // throw Error(resp.detail?.hint ?? "The current password is wrong"); + // // } + // // } - { - const resp = await lib.instance.updateInstanceAuthentication( - state.token, - props.instanceId, - { - password: newPassword, - method: MerchantAuthMethod.TOKEN, - }, - { challengeIds }, - ); - if (resp.type === "fail") { - if (resp.case === HttpStatusCode.Accepted) { - onChallengeRequired(resp.body); - return; - } - throw Error(resp.detail?.hint ?? "The request failed"); - } - } - // const resp = await subInstanceLib.createAccessToken( - // instanceId, - // newPassword, - // FOREVER_REFRESHABLE_TOKEN( - // i18n.str`Password changed for instance ${instanceId}`, - // ), - // ); - // if (resp.type === "ok") { - // return; - // } else { - // if (resp.case === HttpStatusCode.Accepted) { - // throw Error("FIXME!!!!"); - // } - // throw Error(resp.detail?.hint ?? "The new login failed"); - // } - }, - ); + // { + // const resp = await lib.instance.updateInstanceAuthentication( + // state.token, + // props.instanceId, + // { + // password: newPassword, + // method: MerchantAuthMethod.TOKEN, + // }, + // { challengeIds }, + // ); + // if (resp.type === "fail") { + // if (resp.case === HttpStatusCode.Accepted) { + // onChallengeRequired(resp.body); + // return; + // } + // throw Error(resp.detail?.hint ?? "The request failed"); + // } + // } + // // const resp = await subInstanceLib.createAccessToken( + // // instanceId, + // // newPassword, + // // FOREVER_REFRESHABLE_TOKEN( + // // i18n.str`Password changed for instance ${instanceId}`, + // // ), + // // ); + // // if (resp.type === "ok") { + // // return; + // // } else { + // // if (resp.case === HttpStatusCode.Accepted) { + // // throw Error("FIXME!!!!"); + // // } + // // throw Error(resp.detail?.hint ?? "The new login failed"); + // // } + // }, + // ); - if (mfa.pendingChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={mfa.pendingChallenge} - onCompleted={repeatChangePassword} - onCancel={mfa.doCancelChallenge} - /> - ); - } - return CommonPassword(props, result, doChangePassword); + return CommonPassword(props, result, subInstanceLib); } function CommonPassword( @@ -214,16 +196,16 @@ function CommonPassword( | TalerMerchantManagementResultByMethod<"getInstanceDetails"> | TalerError | undefined, - onNewPassword: ( - // oldToken: string | undefined, - newToken: string, - challengeIds: undefined | string[], - ) => Promise<void>, + // onNewPassword: ( + // // oldToken: string | undefined, + // newToken: string, + // challengeIds: undefined | string[], + // ) => Promise<void>, + api: TalerMerchantManagementHttpClient, ): VNode { const { i18n } = useTranslationContext(); const { state } = useSessionContext(); - if (!result) return <Loading />; if (result instanceof TalerError) { return <ErrorLoadingMerchant error={result} />; @@ -249,6 +231,53 @@ function CommonPassword( !adminChangingPwdForAnotherInstance; const id = result.body.name; + + // if (currentPassword) { + // const resp = await lib.instance.createAccessToken( + // instanceId, + // currentPassword, + // TEMP_TEST_TOKEN(i18n.str`Testing password`), + // ); + // if (resp.case === HttpStatusCode.Accepted) { + // throw Error("FIXME!!!!"); + // } + // if (resp.type !== "ok") { + // throw Error(resp.detail?.hint ?? "The current password is wrong"); + // } + // } + + // { + // const resp = await lib.instance.updateCurrentInstanceAuthentication( + // state.token, + // { + // password: newPassword, + // method: MerchantAuthMethod.TOKEN, + // }, + // { challengeIds }, + // ); + // if (resp.type === "fail") { + // if (resp.case === HttpStatusCode.Accepted) { + // onChallengeRequired(resp.body); + // return; + // } + // throw Error(resp.detail?.hint ?? "The request failed"); + // } + // } + + // const resp = await lib.instance.createAccessToken( + // instanceId, + // newPassword, + // FOREVER_REFRESHABLE_TOKEN(i18n.str`Password changed`), + // ); + // if (resp.type === "ok") { + // logIn(state.instance, resp.body.access_token); + // return; + // } else { + // if (resp.case === HttpStatusCode.Accepted) { + // throw Error("FIXME!!!!"); + // } + // throw Error(resp.detail?.hint ?? "The new login failed"); + // } return ( <Fragment> <LocalNotificationBannerBulma notification={notification} /> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/create/CreatePage.tsx @@ -19,38 +19,56 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { TalerMerchantApi } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { HttpStatusCode, TalerMerchantApi } from "@gnu-taler/taler-util"; +import { + ButtonBetterBulma, + LocalNotificationBanner, + LocalNotificationBannerBulma, + useLocalNotificationBetter, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { ProductForm } from "../../../../components/product/ProductForm.js"; import { useListener } from "../../../../hooks/listener.js"; - -type Entity = TalerMerchantApi.ProductAddDetail & { - product_id: string; -}; +import { useState } from "preact/hooks"; +import { useSessionContext } from "../../../../context/session.js"; export interface Props { - onCreate: (d: Entity) => Promise<void>; + onCreate: () => void; onBack?: () => void; } export function CreatePage({ onCreate, onBack }: Props): VNode { - const [submitForm, addFormSubmitter] = useListener<Entity | undefined>( - (result) => { - if (result) return onCreate(result); - return Promise.reject(); - }, + const { state: session, lib } = useSessionContext(); + const [form, setForm] = useState<TalerMerchantApi.ProductAddDetail>(); + + const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const create = safeFunctionHandler( + lib.instance.addProduct, + !session.token || !form ? undefined : [session.token, form], ); + create.onSuccess = onCreate; + create.onFail = (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Unauthorized.`; + case HttpStatusCode.NotFound: + return i18n.str`Not found.`; + case HttpStatusCode.Conflict: + return i18n.str`Conflict.`; + } + }; const { i18n } = useTranslationContext(); return ( <div> + <LocalNotificationBannerBulma notification={notification} /> <section class="section is-main-section"> <div class="columns"> <div class="column" /> <div class="column is-four-fifths"> - <ProductForm onSubscribe={addFormSubmitter} /> + <ProductForm onSubscribe={setForm} /> <div class="buttons is-right mt-5"> {onBack && ( @@ -59,13 +77,12 @@ export function CreatePage({ onCreate, onBack }: Props): VNode { </button> )} <ButtonBetterBulma - onClick={submitForm} + onClick={create} data-tooltip={ - !submitForm + !create.args ? i18n.str`Please complete the marked fields` : "confirm operation" } - disabled={!submitForm} > <i18n.Translate>Confirm</i18n.Translate> </ButtonBetterBulma> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/create/index.tsx @@ -34,41 +34,12 @@ interface Props { onConfirm: () => void; } export default function CreateProduct({ onConfirm, onBack }: Props): VNode { - const { state, lib } = useSessionContext(); - - const { i18n } = useTranslationContext(); return ( <Fragment> - <LocalNotificationBannerBulma notification={notification} /> <CreatePage onBack={onBack} - onCreate={(request: TalerMerchantApi.ProductAddDetail) => { - return lib.instance - .addProduct(state.token, request) - .then((resp) => { - if (resp.type === "ok") { - setNotif({ - message: i18n.str`Product created successfully`, - type: "SUCCESS", - }); - onConfirm(); - } else { - setNotif({ - message: i18n.str`Could not create product`, - type: "ERROR", - description: resp.detail?.hint, - }); - } - }) - .catch((error) => { - setNotif({ - message: i18n.str`Could not create product`, - type: "ERROR", - description: error instanceof Error ? error.message : String(error), - }); - }); - }} + onCreate={onConfirm} /> </Fragment> ); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/list/index.tsx @@ -31,15 +31,13 @@ import { useState } from "preact/hooks"; import { ErrorLoadingMerchant } from "../../../../components/ErrorLoadingMerchant.js"; import { Loading } from "../../../../components/exception/loading.js"; import { JumpToElementById } from "../../../../components/form/JumpToElementById.js"; -import { NotificationCard } from "../../../../components/menu/index.js"; import { ConfirmModal } from "../../../../components/modal/index.js"; import { useSessionContext } from "../../../../context/session.js"; +import { WithId } from "../../../../declaration.js"; import { useInstanceProducts } from "../../../../hooks/product.js"; -import { Notification } from "../../../../utils/types.js"; import { LoginPage } from "../../../login/index.js"; import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; import { CardTable } from "./Table.js"; -import { WithId } from "../../../../declaration.js"; interface Props { onCreate: undefined | (() => void); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/update/UpdatePage.tsx @@ -19,32 +19,54 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { TalerMerchantApi } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { HttpStatusCode, TalerMerchantApi } from "@gnu-taler/taler-util"; +import { + ButtonBetterBulma, + LocalNotificationBannerBulma, + useLocalNotificationBetter, + useTranslationContext +} from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; import { ProductForm } from "../../../../components/product/ProductForm.js"; -import { useListener } from "../../../../hooks/listener.js"; +import { useSessionContext } from "../../../../context/session.js"; type Entity = TalerMerchantApi.ProductDetail & { product_id: string }; interface Props { - onUpdate: (d: Entity) => Promise<void>; onBack?: () => void; + onConfirm: () => void; product: Entity; } -export function UpdatePage({ product, onUpdate, onBack }: Props): VNode { - const [submitForm, addFormSubmitter] = useListener<Entity | undefined>( - (result) => { - if (result) return onUpdate(result); - return Promise.resolve(); - }, +export function UpdatePage({ product, onBack, onConfirm }: Props): VNode { + const { state: session, lib } = useSessionContext(); + const [form, setForm] = useState<TalerMerchantApi.ProductDetail>(); + + const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const update = safeFunctionHandler( + lib.instance.updateProduct, + !session.token || !form + ? undefined + : [session.token, product.product_id, form], ); + update.onSuccess = onConfirm + update.onFail = (fail) => { + switch (fail.case) { + case HttpStatusCode.Unauthorized: + return i18n.str`Unauthorized.`; + case HttpStatusCode.NotFound: + return i18n.str`Not found.`; + case HttpStatusCode.Conflict: + return i18n.str`Conflict.`; + } + } const { i18n } = useTranslationContext(); return ( <div> + <LocalNotificationBannerBulma notification={notification} /> <section class="section"> <section class="hero is-hero-bar"> <div class="hero-body"> @@ -65,11 +87,7 @@ export function UpdatePage({ product, onUpdate, onBack }: Props): VNode { <div class="columns"> <div class="column" /> <div class="column is-four-fifths"> - <ProductForm - initial={product} - onSubscribe={addFormSubmitter} - alreadyExist - /> + <ProductForm initial={product} onSubscribe={setForm} alreadyExist /> <div class="buttons is-right mt-5"> {onBack && ( @@ -78,13 +96,12 @@ export function UpdatePage({ product, onUpdate, onBack }: Props): VNode { </button> )} <ButtonBetterBulma - onClick={submitForm} + onClick={update} data-tooltip={ - !submitForm + !update.args ? i18n.str`Please complete the marked fields` : "confirm operation" } - disabled={!submitForm} > <i18n.Translate>Confirm</i18n.Translate> </ButtonBetterBulma> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/products/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/products/update/index.tsx @@ -49,9 +49,6 @@ export default function UpdateProduct({ }: Props): VNode { const result = useProductDetails(pid); - const { state, lib } = useSessionContext(); - - const { i18n } = useTranslationContext(); if (!result) return <Loading />; if (result instanceof TalerError) { @@ -74,36 +71,10 @@ export default function UpdateProduct({ return ( <Fragment> - <LocalNotificationBannerBulma notification={notification} /> <UpdatePage product={{ ...result.body, product_id: pid }} onBack={onBack} - onUpdate={(data) => { - return lib.instance.updateProduct(state.token, pid, data) - .then(resp => { - if (resp.type === "ok") { - setNotif({ - message: i18n.str`Product (ID: ${pid}) has been updated`, - type: "SUCCESS", - }); - onConfirm() - } else { - setNotif({ - message: i18n.str`Could not update product`, - type: "ERROR", - description: resp.detail?.hint, - }); - - } - }) - .catch((error) => { - setNotif({ - message: i18n.str`Could not update product`, - type: "ERROR", - description: error instanceof Error ? error.message : String(error), - }); - }); - }} + onConfirm={onConfirm} /> </Fragment> ); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/DeletePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/DeletePage.tsx @@ -19,14 +19,13 @@ * @author Sebastian Javier Marchano (sebasjm) */ +import { AccessToken, HttpStatusCode } from "@gnu-taler/taler-util"; import { - assertUnreachable, - HttpStatusCode -} from "@gnu-taler/taler-util"; -import { + ButtonBetterBulma, + LocalNotificationBannerBulma, useChallengeHandler, useLocalNotificationBetter, - useTranslationContext, + useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -56,7 +55,6 @@ export function DeletePage({ instanceId, onBack, onDeleted }: Props): VNode { const { i18n } = useTranslationContext(); const { state: session, lib, logOut } = useSessionContext(); - const errors = undefinedIfEmpty({ name: !form.name ? i18n.str`Required` @@ -69,69 +67,40 @@ export function DeletePage({ instanceId, onBack, onDeleted }: Props): VNode { const text = i18n.str`You are deleting the instance with ID "${instanceId}"`; - const [notification, safeFunctionHandler] = useLocalNotificationBetter(); + const [notification, safeFunctionHandler] = useLocalNotificationBetter(); const mfa = useChallengeHandler(); - const [doDelete, repeatDelete] = mfa.withMfaHandler( - ({ challengeIds, onChallengeRequired }) => - async function doDeleteImpl() { - if (hasErrors) return; - try { - const resp = await lib.instance.deleteCurrentInstance(session.token, { - purge: form.purge, - challengeIds, - }); - if (resp.type === "ok") { - logOut(); - return onDeleted(); - } - switch (resp.case) { - case HttpStatusCode.Accepted: { - onChallengeRequired(resp.body); - return; - } - case HttpStatusCode.Unauthorized: { - setNotif({ - message: i18n.str`Failed to delete the instance`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - case HttpStatusCode.NotFound: { - setNotif({ - message: i18n.str`Failed to delete the instance`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - case HttpStatusCode.Conflict: { - setNotif({ - message: i18n.str`Failed to delete the instance`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - default: { - assertUnreachable(resp); - } - } - } catch (error) { - setNotif({ - message: i18n.str`Failed to delete the instance.`, - type: "ERROR", - description: error instanceof Error ? error.message : undefined, - }); - } - }, + const remove = safeFunctionHandler( + (token: AccessToken, purge: boolean, challengeIds: string[]) => + lib.instance.deleteCurrentInstance(token, { + purge, + challengeIds, + }), + !session.token ? undefined : [session.token, form.purge!!, []], ); - if (mfa.pendingChallenge) { + + remove.onSuccess = onDeleted; + remove.onFail = (fail, t, p) => { + switch (fail.case) { + case HttpStatusCode.Accepted: + mfa.onChallengeRequired( + fail.body, + remove.lambda((ids: string[]) => [t, p, ids]), + ); + return i18n.str`Second factor authentication required.`; + case HttpStatusCode.Unauthorized: + return i18n.str`Unaouthorized.`; + case HttpStatusCode.NotFound: + return i18n.str`Not found.`; + case HttpStatusCode.Conflict: + return i18n.str`Conflict.`; + } + }; + if (mfa.pendingChallenge && mfa.repeatCall) { return ( <SolveMFAChallenges currentChallenge={mfa.pendingChallenge} - onCompleted={repeatDelete} + onCompleted={mfa.repeatCall} onCancel={mfa.doCancelChallenge} /> ); @@ -139,6 +108,7 @@ export function DeletePage({ instanceId, onBack, onDeleted }: Props): VNode { return ( <div> + <LocalNotificationBannerBulma notification={notification} /> <section class="section"> <section class="hero is-hero-bar"> <div class="hero-body"> @@ -175,19 +145,18 @@ export function DeletePage({ instanceId, onBack, onDeleted }: Props): VNode { </a> )} - <button + <ButtonBetterBulma class="button is-small is-danger" type="button" - disabled={hasErrors} data-tooltip={ hasErrors ? i18n.str`Please complete the marked fields` : i18n.str`Confirm operation` } - onClick={doDelete} + onClick={remove} > <i18n.Translate>DELETE</i18n.Translate> - </button> + </ButtonBetterBulma> </div> </FormProvider> </div>