diff options
Diffstat (limited to 'packages/demobank-ui/src/pages')
8 files changed, 413 insertions, 28 deletions
diff --git a/packages/demobank-ui/src/pages/ConversionConfig.tsx b/packages/demobank-ui/src/pages/ConversionConfig.tsx new file mode 100644 index 000000000..73a6ab3ee --- /dev/null +++ b/packages/demobank-ui/src/pages/ConversionConfig.tsx @@ -0,0 +1,333 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AmountString, + Amounts, + HttpStatusCode, + OperationOk, + OperationResult, + TalerBankConversionApi, + TranslatedString, + assertUnreachable +} from "@gnu-taler/taler-util"; +import { + LocalNotificationBanner, + ShowInputErrorLabel, + useLocalNotification, + useTranslationContext +} from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { useBankCoreApiContext } from "../context/config.js"; +import { useBackendState } from "../hooks/backend.js"; +import { RouteDefinition } from "../route.js"; +import { ProfileNavigation } from "./ProfileNavigation.js"; +import { useState } from "preact/hooks"; +import { undefinedIfEmpty } from "../utils.js"; +import { InputAmount } from "./PaytoWireTransferForm.js"; + +interface Props { + routeMyAccountDetails: RouteDefinition; + routeMyAccountDelete: RouteDefinition; + routeMyAccountPassword: RouteDefinition; + routeMyAccountCashout: RouteDefinition; + routeConversionConfig: RouteDefinition; + routeCancel: RouteDefinition; + onUpdateSuccess: () => void; +} + +type FormType<T> = { + [k in keyof T]: string | undefined; +} + +type ErrorsType<T> = { + [k in keyof T]?: TranslatedString; +} + + +type FormHandler<T> = { + [k in keyof T]?: { + value: string | undefined; + onUpdate: (s: string) => void; + error: TranslatedString | undefined; + } +} +function useFormState<T>(defaultValue: FormType<T>, validate: (f: FormType<T>) => ErrorsType<T>): FormHandler<T> { + const [form, updateForm] = useState<FormType<T>>(defaultValue) + + const errors = undefinedIfEmpty<ErrorsType<T>>(validate(form)) + + const p = (Object.keys(form) as Array<keyof T>) + console.log("FORM", p) + const handler = p.reduce((prev, fieldName) => { + console.log("fie;d", fieldName) + const currentValue = form[fieldName] + const currentError = errors !== undefined ? errors[fieldName] : undefined + prev[fieldName] = { + error: currentError, + value: currentValue, + onUpdate: (newValue) => { + updateForm({ ...form, [fieldName]: newValue }) + } + } + return prev + }, {} as FormHandler<T>) + + return handler +} + +/** + * Show histories of public accounts. + */ +export function ConversionConfig({ + routeMyAccountCashout, + routeMyAccountDelete, + routeMyAccountDetails, + routeMyAccountPassword, + routeConversionConfig, + routeCancel, + onUpdateSuccess, +}: Props): VNode { + const { i18n } = useTranslationContext(); + + const { state: credentials } = useBackendState(); + const creds = + credentials.status !== "loggedIn" || !credentials.isUserAdministrator + ? undefined + : credentials; + const { api, config } = useBankCoreApiContext(); + + const [notification, notify, handleError] = useLocalNotification(); + + if (!creds) { + return <div>only admin can setup conversion</div>; + } + + const form = useFormState<TalerBankConversionApi.ConversionRate>({ + cashin_min_amount: undefined, + cashin_tiny_amount: undefined, + cashin_fee: undefined, + cashin_ratio: undefined, + cashin_rounding_mode: undefined, + cashout_min_amount: undefined, + cashout_tiny_amount: undefined, + cashout_fee: undefined, + cashout_ratio: undefined, + cashout_rounding_mode: undefined, + }, (state) => { + return ({ + cashin_min_amount: !state.cashin_min_amount ? i18n.str`required` : + !Amounts.parse(`${config.currency}:${state.cashin_min_amount}`) ? i18n.str`invalid` : + undefined, + + }) + }) + + + async function doUpdate() { + if (!creds) return + await handleError(async () => { + const resp = await api + .getConversionInfoAPI() + .updateConversionRate(creds.token, { + + } as any) + if (resp.type === "ok") { + onUpdateSuccess() + } else { + switch (resp.case) { + case HttpStatusCode.Unauthorized: { + return notify({ + type: "error", + title: i18n.str`Wrong credentials`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + } + case HttpStatusCode.NotImplemented: { + return notify({ + type: "error", + title: i18n.str`Conversion is disabled`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }); + } + default: + assertUnreachable(resp); + } + } + }); + } + + + return ( + <div> + <ProfileNavigation current="conversion" + routeMyAccountCashout={routeMyAccountCashout} + routeMyAccountDelete={routeMyAccountDelete} + routeMyAccountDetails={routeMyAccountDetails} + routeMyAccountPassword={routeMyAccountPassword} + routeConversionConfig={routeConversionConfig} + /> + + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <LocalNotificationBanner notification={notification} /> + + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>Conversion</i18n.Translate> + </h2> + </div> + + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" + autoCapitalize="none" + autoCorrect="off" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="px-6 pt-6"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <label + for="cashout_amount_min" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Minimum amount`}</label> + <InputAmount + name="cashout_amount_min" + left + currency={config.currency} + value={form.cashin_min_amount?.value ?? ""} + onChange={form.cashin_min_amount?.onUpdate} + /> + <ShowInputErrorLabel + message={form.cashin_min_amount?.error} + isDirty={form.cashin_min_amount?.value !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Only cashout operation above this threshold will be allowed</i18n.Translate> + </p> + </div> + </div> + </div> + + <div class="px-6 pt-6"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <label + for="cashout_amount_tiny" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Minimum difference`}</label> + <InputAmount + name="cashout_amount_tiny" + left + currency={config.currency} + value={form.cashin_min_amount?.value ?? ""} + onChange={form.cashin_min_amount?.onUpdate} + /> + <ShowInputErrorLabel + message={form.cashin_min_amount?.error} + isDirty={form.cashin_min_amount?.value !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Smallest difference between two amounts</i18n.Translate> + </p> + </div> + </div> + </div> + + <div class="px-6 pt-6"> + <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <div class="sm:col-span-5"> + <label + for="cashin_fee" + class="block text-sm font-medium leading-6 text-gray-900" + >{i18n.str`Fee`}</label> + <InputAmount + name="cashin_fee" + left + currency={config.currency} + value={form.cashin_min_amount?.value ?? ""} + onChange={form.cashin_fee?.onUpdate} + /> + <ShowInputErrorLabel + message={form.cashin_fee?.error} + isDirty={form.cashin_fee?.value !== undefined} + /> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate>Operation fee</i18n.Translate> + </p> + </div> + </div> + </div> + + <div class="px-6 pt-6"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="password" + > + {i18n.str`Ratio`} + </label> + <div class="mt-2"> + <input + type="number" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 data-[error=true]:ring-red-500 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + name="current" + id="cashout_ratio" + data-error={!!form.cashin_ratio?.error && form.cashout_ratio?.value !== undefined} + value={form.cashout_ratio?.value ?? ""} + onChange={(e) => { + form.cashout_ratio?.onUpdate(e.currentTarget.value); + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={form.cashin_ratio?.error} + isDirty={form.cashout_ratio?.value !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500"> + <i18n.Translate> + Your current password, for security + </i18n.Translate> + </p> + </div> + + <div class="flex items-center justify-between mt-6 gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <a name="cancel" + href={routeCancel.url({})} + class="text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="update conversion" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={async () => { + doUpdate() + }} + > + <i18n.Translate>Update</i18n.Translate> + </button> + </div> + </form> + </div> + </div> + ); +} + diff --git a/packages/demobank-ui/src/pages/LoginForm.tsx b/packages/demobank-ui/src/pages/LoginForm.tsx index f90b985e6..b351785d9 100644 --- a/packages/demobank-ui/src/pages/LoginForm.tsx +++ b/packages/demobank-ui/src/pages/LoginForm.tsx @@ -88,10 +88,7 @@ export function LoginForm({ .createAccessToken(password, { // scope: "readwrite" as "write", // FIX: different than merchant scope: "readwrite", - duration: { - d_us: "forever", // FIX: should return shortest - // d_us: 60 * 60 * 24 * 7 * 1000 * 1000 - }, + duration: { d_us: "forever" }, refreshable: true, }); if (resp.type === "ok") { diff --git a/packages/demobank-ui/src/pages/ProfileNavigation.tsx b/packages/demobank-ui/src/pages/ProfileNavigation.tsx index ba02d07b9..8b7a8205f 100644 --- a/packages/demobank-ui/src/pages/ProfileNavigation.tsx +++ b/packages/demobank-ui/src/pages/ProfileNavigation.tsx @@ -13,13 +13,13 @@ You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ +import { assertUnreachable } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { VNode, h } from "preact"; +import { Fragment, VNode, h } from "preact"; import { useBankCoreApiContext } from "../context/config.js"; -import { useBackendState } from "../hooks/backend.js"; -import { assertUnreachable } from "@gnu-taler/taler-util"; import { useNavigationContext } from "../context/navigation.js"; -import { EmptyObject, RouteDefinition } from "../route.js"; +import { useBackendState } from "../hooks/backend.js"; +import { RouteDefinition } from "../route.js"; export function ProfileNavigation({ current, @@ -27,20 +27,24 @@ export function ProfileNavigation({ routeMyAccountDelete, routeMyAccountDetails, routeMyAccountPassword, + routeConversionConfig }: { - current: "details" | "delete" | "credentials" | "cashouts", + current: "details" | "delete" | "credentials" | "cashouts" | "conversion", routeMyAccountDetails: RouteDefinition; routeMyAccountDelete: RouteDefinition; routeMyAccountPassword: RouteDefinition; routeMyAccountCashout: RouteDefinition; + routeConversionConfig: RouteDefinition; }): VNode { const { i18n } = useTranslationContext(); const { config } = useBankCoreApiContext(); const { state: credentials } = useBackendState(); - const nonAdminUser = + const isAdminUser = credentials.status !== "loggedIn" ? false - : !credentials.isUserAdministrator; + : credentials.isUserAdministrator; + const nonAdminUser = !isAdminUser; + const { navigateTo } = useNavigationContext(); return ( <div> @@ -71,6 +75,10 @@ export function ProfileNavigation({ navigateTo(routeMyAccountCashout.url({})); return; } + case "conversion": { + navigateTo(routeConversionConfig.url({})); + return; + } default: assertUnreachable(op); } @@ -88,9 +96,14 @@ export function ProfileNavigation({ <i18n.Translate>Credentials</i18n.Translate> </option> {config.allow_conversion ? ( - <option value="cashouts" selected={current == "cashouts"}> - <i18n.Translate>Cashouts</i18n.Translate> - </option> + <Fragment> + <option value="cashouts" selected={current == "cashouts"}> + <i18n.Translate>Cashouts</i18n.Translate> + </option> + <option value="conversion" selected={current == "cashouts"}> + <i18n.Translate>Conversion</i18n.Translate> + </option> + </Fragment> ) : undefined} </select> </div> @@ -165,6 +178,23 @@ export function ProfileNavigation({ ></span> </a> ) : undefined} + {config.allow_conversion && isAdminUser ? ( + <a + name="conversion config" + href={routeConversionConfig.url({})} + data-selected={current == "conversion"} + class="rounded-r-lg text-gray-500 hover:text-gray-700 data-[selected=true]:text-gray-900 group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-center text-sm font-medium hover:bg-gray-50 focus:z-10" + > + <span> + <i18n.Translate>Conversion</i18n.Translate> + </span> + <span + aria-hidden="true" + data-selected={current == "conversion"} + class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5" + ></span> + </a> + ) : undefined} </nav> </div> </div> diff --git a/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx b/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx index 14f4acdb8..fe64778dd 100644 --- a/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx +++ b/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx @@ -31,6 +31,7 @@ interface Props { routeMyAccountPassword: RouteDefinition; routeMyAccountCashout: RouteDefinition; routeCreateCashout: RouteDefinition; + routeConversionConfig:RouteDefinition; } export function CashoutListForAccount({ @@ -41,6 +42,7 @@ export function CashoutListForAccount({ routeMyAccountCashout, routeMyAccountDelete, routeMyAccountDetails, + routeConversionConfig, routeMyAccountPassword, routeClose, }: Props): VNode { @@ -61,6 +63,7 @@ export function CashoutListForAccount({ routeMyAccountDelete={routeMyAccountDelete} routeMyAccountDetails={routeMyAccountDetails} routeMyAccountPassword={routeMyAccountPassword} + routeConversionConfig={routeConversionConfig} /> ) : ( <h1 class="text-base font-semibold leading-6 text-gray-900"> diff --git a/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx index db76e83d9..6aad8997a 100644 --- a/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx +++ b/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx @@ -51,6 +51,7 @@ export function ShowAccountDetails({ routeMyAccountDetails, routeHere, routeMyAccountPassword, + routeConversionConfig, }: { routeClose: RouteDefinition; routeHere: RouteDefinition<{ account: string }>; @@ -58,6 +59,7 @@ export function ShowAccountDetails({ routeMyAccountDelete: RouteDefinition; routeMyAccountPassword: RouteDefinition; routeMyAccountCashout: RouteDefinition; + routeConversionConfig: RouteDefinition; onUpdateSuccess: () => void; onAuthorizationRequired: () => void; account: string; @@ -184,6 +186,7 @@ export function ShowAccountDetails({ <ProfileNavigation current="details" routeMyAccountCashout={routeMyAccountCashout} routeMyAccountDelete={routeMyAccountDelete} + routeConversionConfig={routeConversionConfig} routeMyAccountDetails={routeMyAccountDetails} routeMyAccountPassword={routeMyAccountPassword} /> diff --git a/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx index dfa0adf17..305f041ec 100644 --- a/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx +++ b/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx @@ -45,6 +45,7 @@ export function UpdateAccountPassword({ routeMyAccountDelete, routeMyAccountDetails, routeMyAccountPassword, + routeConversionConfig, focus, routeHere, }: { @@ -54,6 +55,7 @@ export function UpdateAccountPassword({ routeMyAccountDelete: RouteDefinition; routeMyAccountPassword: RouteDefinition; routeMyAccountCashout: RouteDefinition; + routeConversionConfig: RouteDefinition; focus?: boolean; onAuthorizationRequired: () => void; onUpdateSuccess: () => void; @@ -152,6 +154,7 @@ export function UpdateAccountPassword({ routeMyAccountDelete={routeMyAccountDelete} routeMyAccountDetails={routeMyAccountDetails} routeMyAccountPassword={routeMyAccountPassword} + routeConversionConfig={routeConversionConfig} /> ) : ( <h1 class="text-base font-semibold leading-6 text-gray-900"> diff --git a/packages/demobank-ui/src/pages/admin/AccountList.tsx b/packages/demobank-ui/src/pages/admin/AccountList.tsx index d8c129507..811c3e37a 100644 --- a/packages/demobank-ui/src/pages/admin/AccountList.tsx +++ b/packages/demobank-ui/src/pages/admin/AccountList.tsx @@ -62,8 +62,10 @@ export function AccountList({ } } + const onGoStart = result.isFirstPage ? undefined : result.loadFirst + const onGoNext = result.isLastPage ? undefined : result.loadNext - const { accounts } = result.data.body; + const accounts = result.result; return ( <Fragment> <div class="px-4 sm:px-6 lg:px-8 mt-4"> @@ -93,7 +95,9 @@ export function AccountList({ <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> {!accounts.length ? ( - <div></div> + <div> + {/* FIXME: ADDD empty list */} + </div> ) : ( <table class="min-w-full divide-y divide-gray-300"> <thead> @@ -208,6 +212,30 @@ export function AccountList({ </table> )} </div> + <nav + class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6 rounded-lg" + aria-label="Pagination" + > + <div class="flex flex-1 justify-between sm:justify-end"> + <button + name="first page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!onGoStart} + onClick={onGoStart} + > + <i18n.Translate>First page</i18n.Translate> + </button> + <button + name="next page" + class="relative disabled:bg-gray-100 disabled:text-gray-500 ml-3 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + disabled={!onGoNext} + onClick={onGoNext} + > + <i18n.Translate>Next</i18n.Translate> + </button> + </div> + </nav> + </div> </div> </div> diff --git a/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx index 8e353b5e7..e09164ffb 100644 --- a/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx +++ b/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx @@ -55,18 +55,6 @@ export function CreateNewAccount({ async function doCreate() { if (!submitAccount || !token) return; await handleError(async () => { - // const account: TalerCorebankApi.RegisterAccountRequest = { - // cashout_payto_uri: submitAccount.cashout_payto_uri, - // challenge_contact_data: submitAccount.challenge_contact_data, - // internal_payto_uri: submitAccount.internal_payto_uri, - // debit_threshold: submitAccount.debit_threshold, - // is_public: submitAccount.is_public, - // is_taler_exchange: submitAccount.is_taler_exchange, - // name: submitAccount.name, - // username: submitAccount.username, - // password: getRandomPassword(), - // }; - const resp = await api.createAccount(token, submitAccount); if (resp.type === "ok") { notifyInfo( |