diff options
Diffstat (limited to 'packages/demobank-ui/src/pages')
21 files changed, 921 insertions, 883 deletions
diff --git a/packages/demobank-ui/src/pages/AccountPage/views.tsx b/packages/demobank-ui/src/pages/AccountPage/views.tsx index 0604001e3..00643ec3e 100644 --- a/packages/demobank-ui/src/pages/AccountPage/views.tsx +++ b/packages/demobank-ui/src/pages/AccountPage/views.tsx @@ -54,40 +54,11 @@ function ShowDemoInfo(): VNode { } export function ReadyView({ account, limit, goToBusinessAccount, goToConfirmOperation }: State.Ready): VNode<{}> { - const { i18n } = useTranslationContext(); return <Fragment> - <MaybeBusinessButton account={account} onClick={goToBusinessAccount} /> - <ShowDemoInfo /> - <PaymentOptions limit={limit} goToConfirmOperation={goToConfirmOperation} /> <Transactions account={account} /> </Fragment>; } -function MaybeBusinessButton({ - account, - onClick, -}: { - account: string; - onClick: () => void; -}): VNode { - const { i18n } = useTranslationContext(); - return <Fragment /> - // const result = useBusinessAccountDetails(account); - // if (!result.ok) return <Fragment />; - // return ( - // <div class="w-full flex justify-end"> - // <button - // 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={(e) => { - // e.preventDefault() - // onClick() - // }} - // > - // <i18n.Translate>Business Profile</i18n.Translate> - // </button> - // </div> - // ); -} diff --git a/packages/demobank-ui/src/pages/BankFrame.tsx b/packages/demobank-ui/src/pages/BankFrame.tsx index 96ce9c317..c0babd0c9 100644 --- a/packages/demobank-ui/src/pages/BankFrame.tsx +++ b/packages/demobank-ui/src/pages/BankFrame.tsx @@ -23,8 +23,8 @@ import { Attention } from "../components/Attention.js"; import { CopyButton } from "../components/CopyButton.js"; import { LangSelector } from "../components/LangSelector.js"; import { Loading } from "../components/Loading.js"; -import { useBackendContext } from "../context/backend.js"; import { useAccountDetails } from "../hooks/access.js"; +import { useBackendState } from "../hooks/backend.js"; import { getAllBooleanSettings, getLabelForSetting, useSettings } from "../hooks/settings.js"; import { bankUiSettings } from "../settings.js"; import { RenderAmount } from "./PaytoWireTransferForm.js"; @@ -49,7 +49,7 @@ export function BankFrame({ children: ComponentChildren; }): VNode { const { i18n } = useTranslationContext(); - const backend = useBackendContext(); + const backend = useBackendState(); const [settings, updateSettings] = useSettings(); const [open, setOpen] = useState(false) @@ -80,9 +80,9 @@ export function BankFrame({ return (<div class="min-h-full flex flex-col m-0" style="min-height: 100vh;"> <div class="bg-indigo-600 pb-32"> <nav class=""> - <div class="mx-auto max-w-7xl px-2 sm:px-4 lg:px-8"> + <div class="mx-auto max-w-7xl px-2 "> <div class="relative flex h-16 items-center justify-between "> - <div class="flex items-center px-2 lg:px-0"> + <div class="flex items-center px-2"> <div class="flex-shrink-0 bg-white rounded-lg"> <a href={bankUiSettings.iconLinkURL ?? "#"}> <img @@ -94,7 +94,7 @@ export function BankFrame({ </a> </div> {bankUiSettings.demoSites && - <div class="hidden sm:block lg:ml-10 "> + <div class="hidden sm:block ml-6 "> <div class="flex space-x-4"> {/* <!-- Current: "bg-indigo-700 text-white", Default: "text-white hover:bg-indigo-500 hover:bg-opacity-75" --> */} {bankUiSettings.demoSites.map(([name, url]) => { @@ -161,41 +161,26 @@ export function BankFrame({ <div class="relative mt-6 flex-1 px-4 sm:px-6"> <nav class="flex flex-1 flex-col" aria-label="Sidebar"> <ul role="list" class="flex flex-1 flex-col gap-y-7"> - <li> - <a href="#" - class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold" - onClick={() => { - backend.logOut(); - setOpen(false) - updateSettings("currentWithdrawalOperationId", undefined); - }} - > - <svg class="h-6 w-6 shrink-0 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"> - <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> - </svg> - <i18n.Translate>Log out</i18n.Translate> - </a> - </li> + {backend.state.status === "loggedIn" ? + <li> + <a href="#" + class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold" + onClick={() => { + backend.logOut(); + setOpen(false) + updateSettings("currentWithdrawalOperationId", undefined); + }} + > + <svg class="h-6 w-6 shrink-0 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true"> + <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> + </svg> + <i18n.Translate>Log out</i18n.Translate> + </a> + </li> + : undefined} <li> <LangSelector /> </li> - {bankUiSettings.demoSites && - <li class="sm:hidden"> - <div class="text-xs font-semibold leading-6 text-gray-400"> - <i18n.Translate>Sites</i18n.Translate> - </div> - <ul role="list" class="space-y-1"> - {bankUiSettings.demoSites.map(([name, url]) => { - return <li> - <a href={url} target="_blank" rel="noopener noreferrer" class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"> - <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border text-[0.625rem] font-medium bg-white text-gray-400 border-gray-200 group-hover:border-indigo-600 group-hover:text-indigo-600">></span> - <span class="truncate">{name}</span> - </a> - </li> - })} - </ul> - </li> - } <li> <div class="text-xs font-semibold leading-6 text-gray-400"> <i18n.Translate>Preferences</i18n.Translate> @@ -220,6 +205,23 @@ export function BankFrame({ })} </ul> </li> + {bankUiSettings.demoSites && + <li class="sm:hidden"> + <div class="text-xs font-semibold leading-6 text-gray-400"> + <i18n.Translate>Sites</i18n.Translate> + </div> + <ul role="list" class="space-y-1"> + {bankUiSettings.demoSites.map(([name, url]) => { + return <li> + <a href={url} target="_blank" rel="noopener noreferrer" class="text-gray-700 hover:text-indigo-600 hover:bg-gray-100 group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"> + <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border text-[0.625rem] font-medium bg-white text-gray-400 border-gray-200 group-hover:border-indigo-600 group-hover:text-indigo-600">></span> + <span class="truncate">{name}</span> + </a> + </li> + })} + </ul> + </li> + } </ul> </nav> </div> @@ -342,28 +344,30 @@ function Footer() { function WelcomeAccount({ account: accountName }: { account: string }): VNode { const { i18n } = useTranslationContext(); - - const result = useAccountDetails(accountName); - if (!result) { - return <Loading /> - } - if (result instanceof TalerError) { - return <div /> - } - - const payto = result.type === "fail" ? undefined : parsePaytoUri(result.body.payto_uri) - const info = !payto || !payto.isKnown ? undefined - : payto.targetType === "iban" ? { account: payto.iban, uri: stringifyPaytoUri(payto) } - : payto.targetType === "x-taler-bank" ? { account: payto.account, uri: stringifyPaytoUri(payto) } - : undefined; - - return <i18n.Translate> - Welcome, <span class="whitespace-nowrap">{accountName}</span> {info !== undefined ? - <small class="whitespace-nowrap"> - (<a href={info.uri}>{info.account}</a> <CopyButton getContent={() => info.uri} />) - </small> - : <Fragment />}! - </i18n.Translate> + return <a href="#/my-profile" class="underline underline-offset-2"> + <i18n.Translate>Welcome, <span class="whitespace-nowrap">{accountName}</span></i18n.Translate> + </a> + // const result = useAccountDetails(accountName); + // if (!result) { + // return <Loading /> + // } + // if (result instanceof TalerError) { + // return <div /> + // } + + // const payto = result.type === "fail" ? undefined : parsePaytoUri(result.body.payto_uri) + // const info = !payto || !payto.isKnown ? undefined + // : payto.targetType === "iban" ? { account: payto.iban, uri: stringifyPaytoUri(payto) } + // : payto.targetType === "x-taler-bank" ? { account: payto.account, uri: stringifyPaytoUri(payto) } + // : undefined; + + // return <i18n.Translate> + // Welcome, <span class="whitespace-nowrap">{accountName}</span> {info !== undefined ? + // <small class="whitespace-nowrap"> + // (<a href={info.uri}>{info.account}</a> <CopyButton getContent={() => info.uri} />) + // </small> + // : <Fragment />}! + // </i18n.Translate> } diff --git a/packages/demobank-ui/src/pages/LoginForm.tsx b/packages/demobank-ui/src/pages/LoginForm.tsx index 981b0f880..6d1d35288 100644 --- a/packages/demobank-ui/src/pages/LoginForm.tsx +++ b/packages/demobank-ui/src/pages/LoginForm.tsx @@ -14,24 +14,24 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { HttpStatusCode, TalerAuthentication, TranslatedString } from "@gnu-taler/taler-util"; -import { ErrorType, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { TranslatedString } from "@gnu-taler/taler-util"; +import { notify, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js"; -import { useBackendContext } from "../context/backend.js"; +import { useBankCoreApiContext } from "../context/config.js"; +import { useBackendState } from "../hooks/backend.js"; import { bankUiSettings } from "../settings.js"; import { undefinedIfEmpty, withRuntimeErrorHandling } from "../utils.js"; -import { doAutoFocus } from "./PaytoWireTransferForm.js"; -import { useBankCoreApiContext } from "../context/config.js"; import { assertUnreachable } from "./HomePage.js"; +import { doAutoFocus } from "./PaytoWireTransferForm.js"; /** * Collect and submit login data. */ export function LoginForm({ reason, onRegister }: { reason?: "not-found" | "forbidden", onRegister?: () => void }): VNode { - const backend = useBackendContext(); + const backend = useBackendState(); const currentUser = backend.state.status !== "loggedOut" ? backend.state.username : undefined const [username, setUsername] = useState<string | undefined>(currentUser); const [password, setPassword] = useState<string | undefined>(); diff --git a/packages/demobank-ui/src/pages/OperationState/state.ts b/packages/demobank-ui/src/pages/OperationState/state.ts index c9c1fa238..9e34a846b 100644 --- a/packages/demobank-ui/src/pages/OperationState/state.ts +++ b/packages/demobank-ui/src/pages/OperationState/state.ts @@ -86,10 +86,10 @@ export function useComponentState({ currency, onClose }: Props): utils.Recursive const wid = withdrawalOperationId async function doAbort() { - setBusy({}) await withRuntimeErrorHandling(i18n, async () => { const resp = await api.abortWithdrawalById(wid); if (resp.type === "ok") { + updateSettings("currentWithdrawalOperationId", undefined) onClose(); } else { switch (resp.case) { @@ -103,7 +103,6 @@ export function useComponentState({ currency, onClose }: Props): utils.Recursive } } }) - setBusy(undefined) } async function doConfirm() { @@ -220,11 +219,7 @@ export function useComponentState({ currency, onClose }: Props): utils.Recursive status: "ready", error: undefined, uri: parsedUri, - onClose: async () => { - await doAbort() - updateSettings("currentWithdrawalOperationId", undefined) - onClose() - }, + onClose: doAbort, onAbort: doAbort, } } @@ -252,11 +247,7 @@ export function useComponentState({ currency, onClose }: Props): utils.Recursive return { status: "need-confirmation", error: undefined, - onAbort: async () => { - await doAbort() - updateSettings("currentWithdrawalOperationId", undefined) - onClose() - }, + onAbort: doAbort, busy: !!busy, onConfirm: doConfirm } diff --git a/packages/demobank-ui/src/pages/PaymentOptions.tsx b/packages/demobank-ui/src/pages/PaymentOptions.tsx index c2d87d0e6..d0ee0243d 100644 --- a/packages/demobank-ui/src/pages/PaymentOptions.tsx +++ b/packages/demobank-ui/src/pages/PaymentOptions.tsx @@ -30,8 +30,8 @@ export function PaymentOptions({ limit, goToConfirmOperation }: { limit: AmountJ const { i18n } = useTranslationContext(); const [settings] = useSettings(); - const [tab, setTab] = useState<"charge-wallet" | "wire-transfer" | undefined>("wire-transfer"); - + const [tab, setTab] = useState<"charge-wallet" | "wire-transfer" | undefined>(); + console.log("patment", tab) return ( <div class="mt-2"> @@ -48,9 +48,9 @@ export function PaymentOptions({ limit, goToConfirmOperation }: { limit: AmountJ }} /> <div class="flex flex-col"> <span class="flex"> - <div class="text-4xl mr-4 my-auto">💵</div> + <div class="text-4xl mr-4 my-auto">💵</div> <span class="grow self-center text-lg text-gray-900 align-middle text-center"> - <i18n.Translate>a <b>Taler</b> wallet</i18n.Translate> + <i18n.Translate>a <b>Taler</b> wallet</i18n.Translate> </span> <svg class="self-center flex-none h-5 w-5 text-indigo-600" style={{ visibility: tab === "charge-wallet" ? "visible" : "hidden" }} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /> @@ -59,14 +59,14 @@ export function PaymentOptions({ limit, goToConfirmOperation }: { limit: AmountJ <div class="mt-1 flex items-center text-sm text-gray-500"> <i18n.Translate>Withdraw digital money into your mobile wallet or browser extension</i18n.Translate> </div> - {!!settings.currentWithdrawalOperationId && - <span class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 whitespace-pre"> - <svg class="h-1.5 w-1.5 fill-green-500" viewBox="0 0 6 6" aria-hidden="true"> - <circle cx="3" cy="3" r="3" /> - </svg> - <i18n.Translate>operation ready</i18n.Translate> - </span> - } + {!!settings.currentWithdrawalOperationId && + <span class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 whitespace-pre"> + <svg class="h-1.5 w-1.5 fill-green-500" viewBox="0 0 6 6" aria-hidden="true"> + <circle cx="3" cy="3" r="3" /> + </svg> + <i18n.Translate>operation ready</i18n.Translate> + </span> + } </div> </label> diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx index e713324c5..d859c10d7 100644 --- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx +++ b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx @@ -51,20 +51,25 @@ const logger = new Logger("PaytoWireTransferForm"); export function PaytoWireTransferForm({ focus, title, + toAccount, onSuccess, onCancel, limit, }: { title: TranslatedString, focus?: boolean; + toAccount?: string, onSuccess: () => void; onCancel: (() => void) | undefined; limit: AmountJson; }): VNode { - const [isRawPayto, setIsRawPayto] = useState(true); + const [isRawPayto, setIsRawPayto] = useState(false); const { state: credentials } = useBackendState() const { api } = useBankCoreApiContext(); - const [iban, setIban] = useState<string | undefined>(); + + const sendingToFixedAccount = toAccount !== undefined + //FIXME: support other destination that just IBAN + const [iban, setIban] = useState<string | undefined>(toAccount); const [subject, setSubject] = useState<string | undefined>(); const [amount, setAmount] = useState<string | undefined>(); @@ -163,7 +168,7 @@ export function PaytoWireTransferForm({ setAmount(undefined); setIban(undefined); setSubject(undefined); - rawPaytoInputSetter(undefined) + rawPaytoInputSetter(undefined) }) } @@ -181,9 +186,12 @@ export function PaytoWireTransferForm({ <input type="radio" name="project-type" value="Newsletter" class="sr-only" aria-labelledby="project-type-0-label" aria-describedby="project-type-0-description-0 project-type-0-description-1" onChange={() => { if (parsed && parsed.isKnown && parsed.targetType === "iban") { setIban(parsed.iban) - const amount = Amounts.parse(parsed.params["amount"]) - if (amount) { - setAmount(Amounts.stringifyValue(amount)) + const amountStr = parsed.params["amount"] + if (amountStr) { + const amount = Amounts.parse(parsed.params["amount"]) + if (amount) { + setAmount(Amounts.stringifyValue(amount)) + } } const subject = parsed.params["message"] if (subject) { @@ -201,28 +209,30 @@ export function PaytoWireTransferForm({ </span> </label> - <label class={"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + (isRawPayto ? "border-indigo-600 ring-2 ring-indigo-600" : "border-gray-300")}> - <input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" onChange={() => { - if (iban) { - const payto = buildPayto("iban", iban, undefined) - if (parsedAmount) { - payto.params["amount"] = Amounts.stringify(parsedAmount) - } - if (subject) { - payto.params["message"] = subject + {sendingToFixedAccount ? undefined : + <label class={"relative flex cursor-pointer rounded-lg border bg-white p-4 shadow-sm focus:outline-none" + (isRawPayto ? "border-indigo-600 ring-2 ring-indigo-600" : "border-gray-300")}> + <input type="radio" name="project-type" value="Existing Customers" class="sr-only" aria-labelledby="project-type-1-label" aria-describedby="project-type-1-description-0 project-type-1-description-1" onChange={() => { + if (iban) { + const payto = buildPayto("iban", iban, undefined) + if (parsedAmount) { + payto.params["amount"] = Amounts.stringify(parsedAmount) + } + if (subject) { + payto.params["message"] = subject + } + rawPaytoInputSetter(stringifyPaytoUri(payto)) } - rawPaytoInputSetter(stringifyPaytoUri(payto)) - } - setIsRawPayto(true) - }} /> - <span class="flex flex-1"> - <span class="flex flex-col"> - <span class="block text-sm font-medium text-gray-900"> - <i18n.Translate>Import payto:// URI</i18n.Translate> + setIsRawPayto(true) + }} /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span class="block text-sm font-medium text-gray-900"> + <i18n.Translate>Import payto:// URI</i18n.Translate> + </span> </span> </span> - </span> - </label> + </label> + } </div> </div> </div> @@ -244,9 +254,10 @@ export function PaytoWireTransferForm({ <input ref={focus ? doAutoFocus : undefined} type="text" - class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + class="block w-full disabled:bg-gray-200 rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" name="iban" id="iban" + disabled={sendingToFixedAccount} value={iban ?? ""} placeholder="CC0123456789" autocomplete="off" @@ -369,7 +380,7 @@ export function PaytoWireTransferForm({ export function doAutoFocus(element: HTMLElement | null) { if (element) { setTimeout(() => { - element.focus() + element.focus({ preventScroll: true }) element.scrollIntoView({ behavior: "smooth", block: "center", diff --git a/packages/demobank-ui/src/pages/ProfileNavigation.tsx b/packages/demobank-ui/src/pages/ProfileNavigation.tsx new file mode 100644 index 000000000..c061c9742 --- /dev/null +++ b/packages/demobank-ui/src/pages/ProfileNavigation.tsx @@ -0,0 +1,56 @@ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useBankCoreApiContext } from "../context/config.js"; +import { assertUnreachable } from "./HomePage.js"; + +export function ProfileNavigation({ current }: { current: "details" | "credentials" | "cashouts" }): VNode { + const { i18n } = useTranslationContext() + const { config } = useBankCoreApiContext() + return <div> + <div class="sm:hidden"> + <label for="tabs" class="sr-only"><i18n.Translate>Select a section</i18n.Translate></label> + <select id="tabs" name="tabs" class="block w-full rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500" onChange={(e) => { + const op = e.currentTarget.value as typeof current + switch (op) { + case "details": { + window.location.href = "#/my-profile"; + return; + } + case "credentials": { + window.location.href = "#/my-password"; + return; + } + case "cashouts": { + window.location.href = "#/my-cashouts"; + return; + } + default: assertUnreachable(op) + } + }}> + <option value="details" selected={current == "details"}><i18n.Translate>Details</i18n.Translate></option> + <option value="credentials" selected={current == "credentials"}><i18n.Translate>Credentials</i18n.Translate></option> + {config.have_cashout ? + <option value="cashouts" selected={current == "cashouts"}><i18n.Translate>Cashouts</i18n.Translate></option> + : undefined} + </select> + </div> + <div class="hidden sm:block"> + <nav class="isolate flex divide-x divide-gray-200 rounded-lg shadow" aria-label="Tabs"> + <a href="#/my-profile" data-selected={current == "details"} class="rounded-l-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>Details</i18n.Translate></span> + <span aria-hidden="true" data-selected={current == "details"} class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"></span> + </a> + <a href="#/my-password" data-selected={current == "credentials"} aria-current="page" class=" 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>Credentials</i18n.Translate></span> + <span aria-hidden="true" data-selected={current == "credentials"} class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"></span> + </a> + {config.have_cashout ? + <a href="#/my-cashouts" data-selected={current == "cashouts"} 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>Cashouts</span> + <span aria-hidden="true" data-selected={current == "cashouts"} class="bg-transparent data-[selected=true]:bg-indigo-500 absolute inset-x-0 bottom-0 h-0.5"></span> + </a> + : undefined} + </nav> + </div> + </div> +}
\ No newline at end of file diff --git a/packages/demobank-ui/src/pages/RegistrationPage.tsx b/packages/demobank-ui/src/pages/RegistrationPage.tsx index ce38a9fb8..3520405c5 100644 --- a/packages/demobank-ui/src/pages/RegistrationPage.tsx +++ b/packages/demobank-ui/src/pages/RegistrationPage.tsx @@ -13,21 +13,19 @@ 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 { AccessToken, HttpStatusCode, Logger, TalerError, TranslatedString } from "@gnu-taler/taler-util"; +import { AccessToken, Logger, TranslatedString } from "@gnu-taler/taler-util"; import { - RequestError, notify, - notifyError, - useTranslationContext, + useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; -import { useBackendContext } from "../context/backend.js"; -import { bankUiSettings } from "../settings.js"; -import { buildRequestErrorMessage, undefinedIfEmpty, withRuntimeErrorHandling } from "../utils.js"; import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js"; -import { getRandomPassword, getRandomUsername } from "./rnd.js"; import { useBankCoreApiContext } from "../context/config.js"; +import { useBackendState } from "../hooks/backend.js"; +import { bankUiSettings } from "../settings.js"; +import { undefinedIfEmpty, withRuntimeErrorHandling } from "../utils.js"; +import { getRandomPassword, getRandomUsername } from "./rnd.js"; const logger = new Logger("RegistrationPage"); @@ -55,7 +53,7 @@ export const EMAIL_REGEX = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/; * Collect and submit registration data. */ function RegistrationForm({ onComplete, onCancel }: { onComplete: () => void, onCancel: () => void }): VNode { - const backend = useBackendContext(); + const backend = useBackendState(); const [username, setUsername] = useState<string | undefined>(); const [name, setName] = useState<string | undefined>(); const [password, setPassword] = useState<string | undefined>(); diff --git a/packages/demobank-ui/src/pages/ShowAccountDetails.tsx b/packages/demobank-ui/src/pages/ShowAccountDetails.tsx index c65b90503..21724474a 100644 --- a/packages/demobank-ui/src/pages/ShowAccountDetails.tsx +++ b/packages/demobank-ui/src/pages/ShowAccountDetails.tsx @@ -1,25 +1,24 @@ -import { HttpStatusCode, TalerCorebankApi, TalerError, TranslatedString } from "@gnu-taler/taler-util"; -import { HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { VNode, h } from "preact"; +import { TalerCorebankApi, TalerError, TranslatedString } from "@gnu-taler/taler-util"; +import { notify, notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { ErrorLoading } from "../components/ErrorLoading.js"; import { Loading } from "../components/Loading.js"; import { useBankCoreApiContext } from "../context/config.js"; import { useAccountDetails } from "../hooks/access.js"; import { useBackendState } from "../hooks/backend.js"; -import { buildRequestErrorMessage, undefinedIfEmpty, withRuntimeErrorHandling } from "../utils.js"; +import { undefinedIfEmpty, withRuntimeErrorHandling } from "../utils.js"; import { assertUnreachable } from "./HomePage.js"; import { LoginForm } from "./LoginForm.js"; import { AccountForm } from "./admin/AccountForm.js"; +import { ProfileNavigation } from "./ProfileNavigation.js"; export function ShowAccountDetails({ account, onClear, onUpdateSuccess, - onChangePassword, }: { onClear?: () => void; - onChangePassword: () => void; onUpdateSuccess: () => void; account: string; }): VNode { @@ -27,6 +26,8 @@ export function ShowAccountDetails({ const { state: credentials } = useBackendState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials const { api } = useBankCoreApiContext() + const accountIsTheCurrentUser = credentials.status === "loggedIn" ? + credentials.username === account : false const [update, setUpdate] = useState(false); const [submitAccount, setSubmitAccount] = useState<TalerCorebankApi.AccountData | undefined>(); @@ -47,11 +48,7 @@ export function ShowAccountDetails({ } async function doUpdate() { - if (!update) { - setUpdate(true); - return; - } - if (!submitAccount || !creds) return; + if (!update || !submitAccount || !creds) return; await withRuntimeErrorHandling(i18n, async () => { const resp = await api.updateAccount(creds, { cashout_address: submitAccount.cashout_payto_uri, @@ -62,8 +59,9 @@ export function ShowAccountDetails({ is_exchange: false, name: submitAccount.name, }); - + if (resp.type === "ok") { + notifyInfo(i18n.str`Account updated`); onUpdateSuccess(); } else { switch (resp.case) { @@ -87,21 +85,23 @@ export function ShowAccountDetails({ } return ( - <div> + <Fragment> + {accountIsTheCurrentUser ? + <ProfileNavigation current="details" /> + : + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Account "{account}"</i18n.Translate> + </h1> + + } + <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"> <div class="px-4 sm:px-0"> <h2 class="text-base font-semibold leading-7 text-gray-900"> - {update ? - <i18n.Translate>Update account</i18n.Translate> - : - <i18n.Translate>Account details</i18n.Translate> - } - </h2> - <div class="mt-4"> <div class="flex items-center justify-between"> <span class="flex flex-grow flex-col"> - <span class="text-sm text-black font-medium leading-6 " id="availability-label"> - <i18n.Translate>change the account details</i18n.Translate> + <span class="text-sm text-black font-semibold leading-6 " id="availability-label"> + <i18n.Translate>Change details</i18n.Translate> </span> </span> <button type="button" data-enabled={!update} class="bg-indigo-600 data-[enabled=true]:bg-gray-200 relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer rounded-full ring-2 border-gray-600 transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description" @@ -111,69 +111,36 @@ export function ShowAccountDetails({ <span aria-hidden="true" data-enabled={!update} class="translate-x-5 data-[enabled=true]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span> </button> </div> - </div> - + </h2> </div> + <AccountForm + focus={update} + username={account} template={result.body} purpose={update ? "update" : "show"} onChange={(a) => setSubmitAccount(a)} > - - </AccountForm> - - <p class="buttons-account"> - <div - style={{ - display: "flex", - justifyContent: "space-between", - flexFlow: "wrap-reverse", - }} - > - <div> - {onClear ? ( - <input - class="pure-button" - type="submit" - value={i18n.str`Close`} - onClick={async (e) => { - e.preventDefault(); - onClear(); - }} - /> - ) : undefined} - </div> - <div style={{ display: "flex" }}> - <div> - <input - id="select-exchange" - class="pure-button pure-button-primary content" - disabled={update && !submitAccount} - type="submit" - value={i18n.str`Change password`} - onClick={async (e) => { - e.preventDefault(); - onChangePassword(); - }} - /> - </div> - <div> - <input - id="select-exchange" - class="pure-button pure-button-primary content" - disabled={update && !submitAccount} - type="submit" - value={update ? i18n.str`Confirm` : i18n.str`Update`} - onClick={async (e) => { - e.preventDefault(); - doUpdate() - }} - /> - </div> - </div> + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + {onClear ? + <button type="button" class="text-sm font-semibold leading-6 text-gray-900" + onClick={onClear} + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + : <div /> + } + <button type="submit" + 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" + disabled={!update || !submitAccount} + onClick={doUpdate} + > + <i18n.Translate>Update</i18n.Translate> + </button> </div> - </p> + </AccountForm> </div> - </div> + </Fragment> ); } + diff --git a/packages/demobank-ui/src/pages/UpdateAccountPassword.tsx b/packages/demobank-ui/src/pages/UpdateAccountPassword.tsx index d82dac4b1..e3f0de8cc 100644 --- a/packages/demobank-ui/src/pages/UpdateAccountPassword.tsx +++ b/packages/demobank-ui/src/pages/UpdateAccountPassword.tsx @@ -1,16 +1,16 @@ -import { TalerError, TranslatedString } from "@gnu-taler/taler-util"; -import { HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { notify, notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { ShowInputErrorLabel } from "../components/ShowInputErrorLabel.js"; -import { buildRequestErrorMessage, undefinedIfEmpty, withRuntimeErrorHandling } from "../utils.js"; -import { doAutoFocus } from "./PaytoWireTransferForm.js"; import { useBankCoreApiContext } from "../context/config.js"; -import { assertUnreachable } from "./HomePage.js"; import { useBackendState } from "../hooks/backend.js"; +import { undefinedIfEmpty, withRuntimeErrorHandling } from "../utils.js"; +import { assertUnreachable } from "./HomePage.js"; +import { doAutoFocus } from "./PaytoWireTransferForm.js"; +import { ProfileNavigation } from "./ProfileNavigation.js"; export function UpdateAccountPassword({ - account, + account: accountName, onCancel, onUpdateSuccess, focus, @@ -22,13 +22,18 @@ export function UpdateAccountPassword({ }): VNode { const { i18n } = useTranslationContext(); const { state: credentials } = useBackendState(); - const creds = credentials.status !== "loggedIn" ? undefined : credentials + const token = credentials.status !== "loggedIn" ? undefined : credentials.token const { api } = useBankCoreApiContext(); + const [current, setCurrent] = useState<string | undefined>(); const [password, setPassword] = useState<string | undefined>(); const [repeat, setRepeat] = useState<string | undefined>(); + const accountIsTheCurrentUser = credentials.status === "loggedIn" ? + credentials.username === accountName : false + const errors = undefinedIfEmpty({ + current: !accountIsTheCurrentUser ? undefined : !current ? i18n.str`required` : undefined, password: !password ? i18n.str`required` : undefined, repeat: !repeat ? i18n.str`required` @@ -37,13 +42,16 @@ export function UpdateAccountPassword({ : undefined, }); + async function doChangePassword() { - if (!!errors || !password || !creds) return; + if (!!errors || !password || !token) return; await withRuntimeErrorHandling(i18n, async () => { - const resp = await api.updatePassword(creds, { + const resp = await api.updatePassword({ username: accountName, token }, { + // old_password: current, new_password: password, }); if (resp.type === "ok") { + notifyInfo(i18n.str`Password changed`); onUpdateSuccess(); } else { switch (resp.case) { @@ -51,6 +59,10 @@ export function UpdateAccountPassword({ type: "error", title: i18n.str`Not authorized to change the password, maybe the session is invalid.` }) + case "no-rights": return notify({ + type: "error", + title: i18n.str`This user have no right on to change the password.` + }) case "not-found": return notify({ type: "error", title: i18n.str`Account not found` @@ -62,112 +74,147 @@ export function UpdateAccountPassword({ } return ( - <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"> - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold leading-7 text-gray-900"> - <i18n.Translate>Update password for account "{account}"</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-4 py-6 sm:p-8"> - <div class="grid max-w-2xl grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6"> + <Fragment> + {accountIsTheCurrentUser ? + <ProfileNavigation current="credentials" /> : + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Account "{accountName}"</i18n.Translate> + </h1> - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="password" - > - {i18n.str`New password`} - </label> - <div class="mt-2"> - <input - ref={focus ? doAutoFocus : undefined} - type="password" - 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="password" - id="password" - data-error={!!errors?.password && password !== undefined} - value={password ?? ""} - onChange={(e) => { - setPassword(e.currentTarget.value) - }} - // placeholder="" - autocomplete="off" - /> - <ShowInputErrorLabel - message={errors?.password} - isDirty={password !== undefined} - /> - </div> - {/* <p class="mt-2 text-sm text-gray-500" > - <i18n.Translate>user </i18n.Translate> - </p> */} - </div> + } - <div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="repeat" - > - {i18n.str`Type it again`} - </label> - <div class="mt-2"> - <input - type="password" - 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="repeat" - id="repeat" - data-error={!!errors?.repeat && repeat !== undefined} - value={repeat ?? ""} - onChange={(e) => { - setRepeat(e.currentTarget.value) - }} - // placeholder="" - autocomplete="off" - /> - <ShowInputErrorLabel - message={errors?.repeat} - isDirty={repeat !== undefined} - /> + <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"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <i18n.Translate>Update password</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-4 py-6 sm:p-8"> + <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 + class="block text-sm font-medium leading-6 text-gray-900" + for="password" + > + {i18n.str`New password`} + </label> + <div class="mt-2"> + <input + ref={focus ? doAutoFocus : undefined} + type="password" + 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="password" + id="password" + data-error={!!errors?.password && password !== undefined} + value={password ?? ""} + onChange={(e) => { + setPassword(e.currentTarget.value) + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.password} + isDirty={password !== undefined} + /> + </div> </div> - <p class="mt-2 text-sm text-gray-500" > - <i18n.Translate>repeat the same password</i18n.Translate> - </p> - </div> + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="repeat" + > + {i18n.str`Type it again`} + </label> + <div class="mt-2"> + <input + type="password" + 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="repeat" + id="repeat" + data-error={!!errors?.repeat && repeat !== undefined} + value={repeat ?? ""} + onChange={(e) => { + setRepeat(e.currentTarget.value) + }} + // placeholder="" + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.repeat} + isDirty={repeat !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500" > + <i18n.Translate>repeat the same password</i18n.Translate> + </p> + </div> + {accountIsTheCurrentUser ? + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="password" + > + {i18n.str`Current password`} + </label> + <div class="mt-2"> + <input + type="password" + 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="current-password" + data-error={!!errors?.current && current !== undefined} + value={current ?? ""} + onChange={(e) => { + setCurrent(e.currentTarget.value) + }} + autocomplete="off" + /> + <ShowInputErrorLabel + message={errors?.current} + isDirty={current !== undefined} + /> + </div> + <p class="mt-2 text-sm text-gray-500" > + <i18n.Translate>your current password, for security</i18n.Translate> + </p> + </div> + : undefined} + </div> </div> - </div> - <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> - {onCancel ? - <button type="button" class="text-sm font-semibold leading-6 text-gray-900" - onClick={onCancel} + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + {onCancel ? + <button type="button" class="text-sm font-semibold leading-6 text-gray-900" + onClick={onCancel} + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + : <div /> + } + <button type="submit" + 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" + disabled={!!errors} + onClick={(e) => { + e.preventDefault() + doChangePassword() + }} > - <i18n.Translate>Cancel</i18n.Translate> + <i18n.Translate>Change</i18n.Translate> </button> - : <div /> - } - <button type="submit" - 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" - disabled={!!errors} - onClick={(e) => { - e.preventDefault() - doChangePassword() - }} - > - <i18n.Translate>Change</i18n.Translate> - </button> - </div> - </form> - </div> + </div> + </form> + </div> + </Fragment> ); }
\ No newline at end of file diff --git a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx index 7266e4de4..51edbc95f 100644 --- a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx +++ b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx @@ -30,6 +30,7 @@ import { assertUnreachable } from "./HomePage.js"; import { QrCodeSection } from "./QrCodeSection.js"; import { WithdrawalConfirmationQuestion } from "./WithdrawalConfirmationQuestion.js"; import { Attention } from "../components/Attention.js"; +import { Pages } from "../pages.js"; const logger = new Logger("WithdrawalQRCode"); diff --git a/packages/demobank-ui/src/pages/admin/Account.tsx b/packages/demobank-ui/src/pages/admin/Account.tsx index bf2fa86f0..103747414 100644 --- a/packages/demobank-ui/src/pages/admin/Account.tsx +++ b/packages/demobank-ui/src/pages/admin/Account.tsx @@ -3,15 +3,15 @@ import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { ErrorLoading } from "../../components/ErrorLoading.js"; import { Loading } from "../../components/Loading.js"; -import { useBackendContext } from "../../context/backend.js"; import { useAccountDetails } from "../../hooks/access.js"; import { assertUnreachable } from "../HomePage.js"; import { LoginForm } from "../LoginForm.js"; import { PaytoWireTransferForm } from "../PaytoWireTransferForm.js"; +import { useBackendState } from "../../hooks/backend.js"; -export function AdminAccount({ onRegister }: { onRegister: () => void }): VNode { +export function WireTransfer({ toAccount, onRegister, onCancel, onSuccess }: { onSuccess?: () => void; toAccount?: string, onCancel?: () => void, onRegister?: () => void }): VNode { const { i18n } = useTranslationContext(); - const r = useBackendContext(); + const r = useBackendState(); const account = r.state.status !== "loggedOut" ? r.state.username : "admin"; const result = useAccountDetails(account); @@ -41,11 +41,13 @@ export function AdminAccount({ onRegister }: { onRegister: () => void }): VNode return ( <PaytoWireTransferForm title={i18n.str`Make a wire transfer`} + toAccount={toAccount} limit={limit} onSuccess={() => { notifyInfo(i18n.str`Wire transfer created!`); + if (onSuccess) onSuccess() }} - onCancel={undefined} + onCancel={onCancel} /> ); } diff --git a/packages/demobank-ui/src/pages/admin/AccountForm.tsx b/packages/demobank-ui/src/pages/admin/AccountForm.tsx index 8470930bf..bce089560 100644 --- a/packages/demobank-ui/src/pages/admin/AccountForm.tsx +++ b/packages/demobank-ui/src/pages/admin/AccountForm.tsx @@ -1,16 +1,20 @@ -import { ComponentChildren, VNode, h } from "preact"; +import { ComponentChildren, Fragment, VNode, h } from "preact"; import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js"; import { PartialButDefined, RecursivePartial, WithIntermediate, undefinedIfEmpty, validateIBAN } from "../../utils.js"; import { useEffect, useRef, useState } from "preact/hooks"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { TalerCorebankApi, buildPayto, parsePaytoUri } from "@gnu-taler/taler-util"; import { doAutoFocus } from "../PaytoWireTransferForm.js"; +import { CopyButton } from "../../components/CopyButton.js"; +import { assertUnreachable } from "../HomePage.js"; const IBAN_REGEX = /^[A-Z][A-Z0-9]*$/; const EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/; +export type AccountFormData = TalerCorebankApi.AccountData & { username: string } + /** * Create valid account object to update or create * Take template as initial values for the form @@ -21,6 +25,7 @@ const REGEX_JUST_NUMBERS_REGEX = /^\+[0-9 ]*$/; */ export function AccountForm({ template, + username, purpose, onChange, focus, @@ -28,11 +33,12 @@ export function AccountForm({ }: { focus?: boolean, children: ComponentChildren, + username?: string, template: TalerCorebankApi.AccountData | undefined; - onChange: (a: TalerCorebankApi.AccountData | undefined) => void; + onChange: (a: AccountFormData | undefined) => void; purpose: "create" | "update" | "show"; }): VNode { - const initial = initializeFromTemplate(template); + const initial = initializeFromTemplate(username, template); const [form, setForm] = useState(initial); const [errors, setErrors] = useState< RecursivePartial<typeof initial> | undefined @@ -69,14 +75,8 @@ export function AccountForm({ ? i18n.str`phone number can't have other than numbers` : undefined, }), - // iban: !newForm.iban - // ? undefined //optional field - // : !IBAN_REGEX.test(newForm.iban) - // ? i18n.str`IBAN should have just uppercased letters and numbers` - // : validateIBAN(newForm.iban, i18n), name: !newForm.name ? i18n.str`required` : undefined, - - // username: !newForm.username ? i18n.str`required` : undefined, + username: !newForm.username ? i18n.str`required` : undefined, }); setErrors(errors); setForm(newForm); @@ -95,7 +95,7 @@ export function AccountForm({ <div class="px-4 py-6 sm:p-8"> <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"> + <div class="sm:col-span-5"> <label class="block text-sm font-medium leading-6 text-gray-900" for="username" @@ -105,7 +105,7 @@ export function AccountForm({ </label> <div class="mt-2"> <input - ref={focus ? doAutoFocus : undefined} + ref={focus && purpose === "create" ? doAutoFocus : undefined} type="text" class="block w-full disabled:bg-gray-100 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="username" @@ -128,7 +128,7 @@ export function AccountForm({ <p class="mt-2 text-sm text-gray-500" > <i18n.Translate>account identification in the bank</i18n.Translate> </p> - </div> */} + </div> <div class="sm:col-span-5"> <label @@ -165,27 +165,7 @@ export function AccountForm({ </div> - {purpose !== "create" && (<div class="sm:col-span-5"> - <label - class="block text-sm font-medium leading-6 text-gray-900" - for="internal-iban" - > - {i18n.str`Internal IBAN`} - </label> - <div class="mt-2"> - <input - type="text" - class="block w-full disabled:bg-gray-100 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="internal-iban" - id="internal-iban" - disabled={true} - value={form.payto_uri ?? ""} - /> - </div> - <p class="mt-2 text-sm text-gray-500" > - <i18n.Translate>international bank account number</i18n.Translate> - </p> - </div>)} + {purpose !== "create" && (<RenderPaytoDisabledField paytoURI={form.payto_uri} />)} <div class="sm:col-span-5"> <label @@ -264,6 +244,7 @@ export function AccountForm({ <div class="mt-2"> <input type="text" + ref={focus && purpose === "update" ? doAutoFocus : undefined} data-error={!!errors?.cashout_payto_uri && form.cashout_payto_uri !== undefined} class="block w-full disabled:bg-gray-100 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="cashout" @@ -294,8 +275,9 @@ export function AccountForm({ } function initializeFromTemplate( + username: string | undefined, account: TalerCorebankApi.AccountData | undefined, -): WithIntermediate<TalerCorebankApi.AccountData> { +): WithIntermediate<AccountFormData> { const emptyAccount = { cashout_payto_uri: undefined, contact_data: undefined, @@ -314,8 +296,136 @@ function initializeFromTemplate( if (typeof initial.contact_data === "undefined") { initial.contact_data = emptyContact; } - // initial.contact_data.email; + const result: WithIntermediate<AccountFormData> = initial as any // FIXME: check types + result.username = username + return initial as any; } +function RenderPaytoDisabledField({ paytoURI }: { paytoURI: string | undefined }): VNode { + const { i18n } = useTranslationContext() + const payto = parsePaytoUri(paytoURI ?? ""); + if (payto?.isKnown) { + if (payto.targetType === "iban") { + const value = payto.iban; + return <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="internal-iban" + > + {i18n.str`Internal IBAN`} + </label> + <div class="mt-2"> + <div class="flex justify-between"> + <input + type="text" + class="mr-4 w-full block-inline disabled:bg-gray-100 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="internal-iban" + id="internal-iban" + disabled={true} + value={value ?? ""} + /> + <CopyButton + class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + getContent={() => value ?? ""} + /> + </div> + </div> + <p class="mt-2 text-sm text-gray-500" > + <i18n.Translate>international bank account number</i18n.Translate> + </p> + </div> + } + if (payto.targetType === "x-taler-bank") { + const value = payto.account; + return <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="account-id" + > + {i18n.str`Account ID`} + </label> + <div class="mt-2"> + <div class="flex justify-between"> + <input + type="text" + class="mr-4 w-full block-inline disabled:bg-gray-100 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="account-id" + id="account-id" + disabled={true} + value={value ?? ""} + /> + <CopyButton + class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + getContent={() => value ?? ""} + /> + </div> + </div> + <p class="mt-2 text-sm text-gray-500" > + <i18n.Translate>internal account id</i18n.Translate> + </p> + </div> + } + if (payto.targetType === "bitcoin") { + const value = payto.targetPath; + return <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="account-id" + > + {i18n.str`Bitcoin address`} + </label> + <div class="mt-2"> + <div class="flex justify-between"> + <input + type="text" + class="mr-4 w-full block-inline disabled:bg-gray-100 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="account-id" + id="account-id" + disabled={true} + value={value ?? ""} + /> + <CopyButton + class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + getContent={() => value ?? ""} + /> + </div> + </div> + <p class="mt-2 text-sm text-gray-500" > + <i18n.Translate>bitcoin address</i18n.Translate> + </p> + </div> + } + assertUnreachable(payto) + } + + const value = paytoURI ?? "" + return <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="internal-payto" + > + {i18n.str`Internal account`} + </label> + <div class="mt-2"> + <div class="flex justify-between"> + <input + type="text" + class="mr-4 w-full block-inline disabled:bg-gray-100 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="internal-payto" + id="internal-payto" + disabled={true} + value={value ?? ""} + /> + <CopyButton + class="p-2 rounded-full text-black shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 " + getContent={() => value ?? ""} + /> + </div> + </div> + <p class="mt-2 text-sm text-gray-500" > + <i18n.Translate>generic payto URI</i18n.Translate> + </p> + </div> +} diff --git a/packages/demobank-ui/src/pages/admin/AccountList.tsx b/packages/demobank-ui/src/pages/admin/AccountList.tsx index 8a1e8294a..39b43b9b1 100644 --- a/packages/demobank-ui/src/pages/admin/AccountList.tsx +++ b/packages/demobank-ui/src/pages/admin/AccountList.tsx @@ -1,22 +1,26 @@ import { Amounts, TalerError } 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 { ErrorLoading } from "../../components/ErrorLoading.js"; import { Loading } from "../../components/Loading.js"; import { useBusinessAccounts } from "../../hooks/circuit.js"; import { assertUnreachable } from "../HomePage.js"; import { RenderAmount } from "../PaytoWireTransferForm.js"; -import { AccountAction } from "./Home.js"; +import { useBankCoreApiContext } from "../../context/config.js"; interface Props { - onAction: (type: AccountAction, account: string) => void; - account: string | undefined; onCreateAccount: () => void; + + onShowAccountDetails: (aid: string) => void; + onRemoveAccount: (aid: string) => void; + onUpdateAccountPassword: (aid: string) => void; + onShowCashoutForAccount: (aid: string) => void; } -export function AccountList({ account, onAction, onCreateAccount }: Props): VNode { +export function AccountList({ onRemoveAccount, onShowAccountDetails, onUpdateAccountPassword, onShowCashoutForAccount, onCreateAccount }: Props): VNode { const result = useBusinessAccounts(); const { i18n } = useTranslationContext(); + const { config } = useBankCoreApiContext() if (!result) { return <Loading /> @@ -74,6 +78,7 @@ export function AccountList({ account, onAction, onCreateAccount }: Props): VNod const balance = !item.balance ? undefined : Amounts.parse(item.balance.amount); + const noBalance = Amounts.isZero(item.balance.amount) const balanceIsDebit = item.balance && item.balance.credit_debit_indicator == "debit"; @@ -83,7 +88,7 @@ export function AccountList({ account, onAction, onCreateAccount }: Props): VNod <a href="#" class="text-indigo-600 hover:text-indigo-900" onClick={(e) => { e.preventDefault(); - onAction("show-details", item.username) + onShowAccountDetails(item.username) }} > {item.username} @@ -94,7 +99,7 @@ export function AccountList({ account, onAction, onCreateAccount }: Props): VNod <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> {item.name} </td> - <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500"> + <td data-negative={noBalance ? undefined : balanceIsDebit ? "true" : "false"} class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 data-[negative=false]:text-green-600 data-[negative=true]:text-red-600 "> {!balance ? ( i18n.str`unknown` ) : ( @@ -107,27 +112,34 @@ export function AccountList({ account, onAction, onCreateAccount }: Props): VNod <a href="#" class="text-indigo-600 hover:text-indigo-900" onClick={(e) => { e.preventDefault(); - onAction("update-password", item.username) + onUpdateAccountPassword(item.username) }} > change password </a> <br /> - <a href="#" class="text-indigo-600 hover:text-indigo-900" onClick={(e) => { - e.preventDefault(); - onAction("show-cashout", item.username) - }} - > - cashouts - </a> - <br /> - <a href="#" class="text-indigo-600 hover:text-indigo-900" onClick={(e) => { - e.preventDefault(); - onAction("remove-account", item.username) - }} - > - remove - </a> + {config.have_cashout ? + <Fragment> + + <a href="#" class="text-indigo-600 hover:text-indigo-900" onClick={(e) => { + e.preventDefault(); + onShowCashoutForAccount(item.username) + }} + > + cashouts + </a> + <br /> + </Fragment> + : undefined} + {noBalance ? + <a href="#" class="text-indigo-600 hover:text-indigo-900" onClick={(e) => { + e.preventDefault(); + onRemoveAccount(item.username) + }} + > + remove + </a> + : undefined} </td> </tr> })} diff --git a/packages/demobank-ui/src/pages/admin/AdminHome.tsx b/packages/demobank-ui/src/pages/admin/AdminHome.tsx new file mode 100644 index 000000000..01f9f6dbd --- /dev/null +++ b/packages/demobank-ui/src/pages/admin/AdminHome.tsx @@ -0,0 +1,32 @@ +import { Fragment, VNode, h } from "preact"; +import { Transactions } from "../../components/Transactions/index.js"; +import { WireTransfer } from "./Account.js"; +import { AccountList } from "./AccountList.js"; + +/** + * Query account information and show QR code if there is pending withdrawal + */ +interface Props { + onRegister: () => void; + + onCreateAccount: () => void; + onShowAccountDetails: (aid: string) => void; + onRemoveAccount: (aid: string) => void; + onUpdateAccountPassword: (aid: string) => void; + onShowCashoutForAccount: (aid: string) => void; +} +export function AdminHome({ onCreateAccount, onRegister, onRemoveAccount, onShowAccountDetails, onShowCashoutForAccount, onUpdateAccountPassword }: Props): VNode { + return <Fragment> + <AccountList + onCreateAccount={onCreateAccount} + onRemoveAccount={onRemoveAccount} + onShowCashoutForAccount={onShowCashoutForAccount} + onShowAccountDetails={onShowAccountDetails} + onUpdateAccountPassword={onUpdateAccountPassword} + /> + + <WireTransfer onRegister={onRegister} /> + + <Transactions account="admin" /> + </Fragment> +}
\ No newline at end of file diff --git a/packages/demobank-ui/src/pages/admin/CashoutListForAccount.tsx b/packages/demobank-ui/src/pages/admin/CashoutListForAccount.tsx new file mode 100644 index 000000000..466dc1a4b --- /dev/null +++ b/packages/demobank-ui/src/pages/admin/CashoutListForAccount.tsx @@ -0,0 +1,47 @@ +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { Cashouts } from "../../components/Cashouts/index.js"; +import { useBackendState } from "../../hooks/backend.js"; +import { ProfileNavigation } from "../ProfileNavigation.js"; + +interface Props { + account: string, + onClose: () => void, + onSelected: (cid: string) => void +} + +export function CashoutListForAccount({ account, onSelected, onClose }: Props): VNode { + const { i18n } = useTranslationContext(); + + const { state: credentials } = useBackendState(); + const token = credentials.status !== "loggedIn" ? undefined : credentials.token + + const accountIsTheCurrentUser = credentials.status === "loggedIn" ? + credentials.username === account : false + + return <Fragment> + {accountIsTheCurrentUser ? + <ProfileNavigation current="cashouts" /> + : + <h1 class="text-base font-semibold leading-6 text-gray-900"> + <i18n.Translate>Cashout for account {account}</i18n.Translate> + </h1> + } + <Cashouts + account={account} + onSelected={onSelected} + /> + <p> + <input + class="pure-button" + type="submit" + value={i18n.str`Close`} + onClick={async (e) => { + e.preventDefault(); + onClose(); + }} + /> + </p> + </Fragment> +} + diff --git a/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx b/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx index e10c3ad41..772ea6e84 100644 --- a/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx +++ b/packages/demobank-ui/src/pages/admin/CreateNewAccount.tsx @@ -1,21 +1,22 @@ import { HttpStatusCode, TalerCorebankApi, TalerError, TranslatedString } from "@gnu-taler/taler-util"; -import { RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { RequestError, notify, notifyError, notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { buildRequestErrorMessage, withRuntimeErrorHandling } from "../../utils.js"; import { getRandomPassword } from "../rnd.js"; -import { AccountForm } from "./AccountForm.js"; +import { AccountForm, AccountFormData } from "./AccountForm.js"; import { useBackendState } from "../../hooks/backend.js"; import { useBankCoreApiContext } from "../../context/config.js"; import { assertUnreachable } from "../HomePage.js"; import { mutate } from "swr"; +import { Attention } from "../../components/Attention.js"; export function CreateNewAccount({ onCancel, onCreateSuccess, }: { onCancel: () => void; - onCreateSuccess: (password: string) => void; + onCreateSuccess: () => void; }): VNode { const { i18n } = useTranslationContext(); // const { createAccount } = useAdminAccountAPI(); @@ -23,9 +24,7 @@ export function CreateNewAccount({ const token = credentials.status !== "loggedIn" ? undefined : credentials.token const { api } = useBankCoreApiContext(); - const [submitAccount, setSubmitAccount] = useState< - TalerCorebankApi.AccountData | undefined - >(); + const [submitAccount, setSubmitAccount] = useState<AccountFormData | undefined>(); async function doCreate() { if (!submitAccount || !token) return; @@ -35,14 +34,17 @@ export function CreateNewAccount({ challenge_contact_data: submitAccount.contact_data, internal_payto_uri: submitAccount.payto_uri, name: submitAccount.name, - username: "",//FIXME: not in account data + username: submitAccount.username,//FIXME: not in account data password: getRandomPassword(), }; const resp = await api.createAccount(token, account); if (resp.type === "ok") { mutate(() => true)// clean account list - onCreateSuccess(account.password); + notifyInfo( + i18n.str`Account created with password "${account.password}". The user must change the password on the next login.`, + ); + onCreateSuccess(); } else { switch (resp.case) { case "invalid-input": return notify({ @@ -75,6 +77,12 @@ export function CreateNewAccount({ }) } + if (!(credentials.status === "loggedIn" && credentials.isUserAdministrator)) { + return <Attention type="warning" title={i18n.str`Can't create accounts`} onClose={onCancel}> + <i18n.Translate>Only system admin can create accounts.</i18n.Translate> + </Attention> + } + return ( <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"> <div class="px-4 sm:px-0"> diff --git a/packages/demobank-ui/src/pages/admin/Home.tsx b/packages/demobank-ui/src/pages/admin/Home.tsx deleted file mode 100644 index 71ea8ce1b..000000000 --- a/packages/demobank-ui/src/pages/admin/Home.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; -import { useState } from "preact/hooks"; -import { Cashouts } from "../../components/Cashouts/index.js"; -import { Transactions } from "../../components/Transactions/index.js"; -import { ShowAccountDetails } from "../ShowAccountDetails.js"; -import { UpdateAccountPassword } from "../UpdateAccountPassword.js"; -import { ShowCashoutDetails } from "../business/Home.js"; -import { AdminAccount } from "./Account.js"; -import { AccountList } from "./AccountList.js"; -import { CreateNewAccount } from "./CreateNewAccount.js"; -import { RemoveAccount } from "./RemoveAccount.js"; - -/** - * Query account information and show QR code if there is pending withdrawal - */ -interface Props { - onRegister: () => void; -} -export type AccountAction = "show-details" | - "show-cashout" | - "update-password" | - "remove-account" | - "show-cashouts-details"; - -export function AdminHome({ onRegister }: Props): VNode { - const [action, setAction] = useState<{ - type: AccountAction, - account: string - } | undefined>() - - const [createAccount, setCreateAccount] = useState(false); - - const { i18n } = useTranslationContext(); - - if (action) { - switch (action.type) { - case "show-cashouts-details": return <ShowCashoutDetails - id={action.account} - onCancel={() => { - setAction(undefined); - }} - /> - case "show-cashout": return ( - <div> - <div> - <h1 class="nav welcome-text"> - <i18n.Translate>Cashout for account {action.account}</i18n.Translate> - </h1> - </div> - <Cashouts - account={action.account} - onSelected={(id) => { - setAction({ - type: "show-cashouts-details", - account: action.account - }); - }} - /> - <p> - <input - class="pure-button" - type="submit" - value={i18n.str`Close`} - onClick={async (e) => { - e.preventDefault(); - setAction(undefined); - }} - /> - </p> - </div> - ) - case "update-password": return <UpdateAccountPassword - account={action.account} - onUpdateSuccess={() => { - notifyInfo(i18n.str`Password changed`); - setAction(undefined); - }} - onCancel={() => { - setAction(undefined); - }} - /> - case "remove-account": return <RemoveAccount - account={action.account} - onUpdateSuccess={() => { - notifyInfo(i18n.str`Account removed`); - setAction(undefined); - }} - onCancel={() => { - setAction(undefined); - }} - /> - case "show-details": return <ShowAccountDetails - account={action.account} - onChangePassword={() => { - setAction({ - type: "update-password", - account: action.account, - }) - }} - onUpdateSuccess={() => { - notifyInfo(i18n.str`Account updated`); - setAction(undefined); - }} - onClear={() => { - setAction(undefined); - }} - /> - } - } - - if (createAccount) { - return ( - <CreateNewAccount - onCancel={() => setCreateAccount(false)} - onCreateSuccess={(password) => { - notifyInfo( - i18n.str`Account created with password "${password}". The user must change the password on the next login.`, - ); - setCreateAccount(false); - }} - /> - ); - } - - return ( - <Fragment> - - <AccountList - onCreateAccount={() => { - setCreateAccount(true); - }} - account={undefined} - onAction={(type, account) => setAction({ account, type })} - - /> - - <AdminAccount onRegister={onRegister} /> - - <Transactions account="admin" /> - </Fragment> - ); -}
\ No newline at end of file diff --git a/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx b/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx index 9a212ebd0..88961c2cb 100644 --- a/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx +++ b/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx @@ -1,5 +1,5 @@ import { Amounts, HttpStatusCode, TalerError, TranslatedString } from "@gnu-taler/taler-util"; -import { HttpResponsePaginated, RequestError, notify, notifyError, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { HttpResponsePaginated, RequestError, notify, notifyError, notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { Attention } from "../../components/Attention.js"; @@ -63,6 +63,7 @@ export function RemoveAccount({ await withRuntimeErrorHandling(i18n, async () => { const resp = await api.deleteAccount({ username: account, token }); if (resp.type === "ok") { + notifyInfo(i18n.str`Account removed`); onUpdateSuccess(); } else { switch (resp.case) { diff --git a/packages/demobank-ui/src/pages/business/Home.tsx b/packages/demobank-ui/src/pages/business/CreateCashout.tsx index d7beda01d..4696c899e 100644 --- a/packages/demobank-ui/src/pages/business/Home.tsx +++ b/packages/demobank-ui/src/pages/business/CreateCashout.tsx @@ -21,15 +21,12 @@ import { } from "@gnu-taler/taler-util"; import { notify, - notifyError, - notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { mutate } from "swr"; -import { Cashouts } from "../../components/Cashouts/index.js"; import { ErrorLoading } from "../../components/ErrorLoading.js"; import { Loading } from "../../components/Loading.js"; import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js"; @@ -43,114 +40,15 @@ import { } from "../../hooks/circuit.js"; import { TanChannel, - buildRequestErrorMessage, undefinedIfEmpty, - withRuntimeErrorHandling, + withRuntimeErrorHandling } from "../../utils.js"; import { LoginForm } from "../LoginForm.js"; import { InputAmount } from "../PaytoWireTransferForm.js"; -import { ShowAccountDetails } from "../ShowAccountDetails.js"; -import { UpdateAccountPassword } from "../UpdateAccountPassword.js"; +import { assertUnreachable } from "../HomePage.js"; +import { Attention } from "../../components/Attention.js"; interface Props { - account: string, - onClose: () => void; - onRegister: () => void; -} -export function BusinessAccount({ - onClose, - account, - onRegister, -}: Props): VNode { - const { i18n } = useTranslationContext(); - const [updatePassword, setUpdatePassword] = useState(false); - const [newCashout, setNewcashout] = useState(false); - const [showCashoutDetails, setShowCashoutDetails] = useState< - string | undefined - >(); - - if (newCashout) { - return ( - <CreateCashout - account={account} - onCancel={() => { - setNewcashout(false); - }} - onComplete={(id) => { - notifyInfo( - i18n.str`Cashout created. You need to confirm the operation to complete the transaction.`, - ); - setNewcashout(false); - setShowCashoutDetails(id); - }} - /> - ); - } - if (showCashoutDetails) { - return ( - <ShowCashoutDetails - id={showCashoutDetails} - onCancel={() => { - setShowCashoutDetails(undefined); - }} - /> - ); - } - if (updatePassword) { - return ( - <UpdateAccountPassword - account={account} - onUpdateSuccess={() => { - notifyInfo(i18n.str`Password changed`); - setUpdatePassword(false); - }} - onCancel={() => { - setUpdatePassword(false); - }} - /> - ); - } - return ( - <div> - <ShowAccountDetails - account={account} - onUpdateSuccess={() => { - notifyInfo(i18n.str`Account updated`); - }} - onChangePassword={() => { - setUpdatePassword(true); - }} - onClear={onClose} - /> - <section style={{ marginTop: "2em" }}> - <div class="active"> - <h3>{i18n.str`Latest cashouts`}</h3> - <Cashouts - account={account} - onSelected={(id) => { - setShowCashoutDetails(id); - }} - /> - </div> - <br /> - <div style={{ display: "flex", justifyContent: "space-between" }}> - <div /> - <input - class="pure-button pure-button-primary content" - type="submit" - value={i18n.str`New cashout`} - onClick={async (e) => { - e.preventDefault(); - setNewcashout(true); - }} - /> - </div> - </section> - </div> - ); -} - -interface PropsCashout { account: string; onComplete: (id: string) => void; onCancel: () => void; @@ -167,11 +65,11 @@ type ErrorFrom<T> = { }; -function CreateCashout({ +export function CreateCashout({ account: accountName, onComplete, onCancel, -}: PropsCashout): VNode { +}: Props): VNode { const { i18n } = useTranslationContext(); const resultRatios = useRatiosAndFeeConfig(); const resultAccount = useAccountDetails(accountName); @@ -184,6 +82,17 @@ function CreateCashout({ const { api, config } = useBankCoreApiContext() const [form, setForm] = useState<Partial<FormType>>({ isDebit: true }); + if (!config.have_cashout) { + return <Attention type="warning" title={i18n.str`Unable to create a cashout`} onClose={onCancel}> + <i18n.Translate>The bank configuration does not support cashout operations.</i18n.Translate> + </Attention> + } + if (!config.fiat_currency) { + return <Attention type="warning" title={i18n.str`Unable to create a cashout`} onClose={onCancel}> + <i18n.Translate>The bank configuration support cashout operations but there is no fiat currency.</i18n.Translate> + </Attention> + } + if (!resultAccount || !resultRatios) { return <Loading /> } @@ -207,9 +116,6 @@ function CreateCashout({ default: assertUnreachable(resultRatios.case) } } - if (!config.fiat_currency) { - return <div>cashout operations are not supported</div> - } const ratio = resultRatios.body @@ -514,223 +420,3 @@ function CreateCashout({ </div> ); } - -interface ShowCashoutProps { - id: string; - onCancel: () => void; -} -export function ShowCashoutDetails({ - id, - onCancel, -}: ShowCashoutProps): VNode { - const { i18n } = useTranslationContext(); - const { state } = useBackendState(); - const creds = state.status !== "loggedIn" ? undefined : state - const { api } = useBankCoreApiContext() - const result = useCashoutDetails(id); - const [code, setCode] = useState<string | undefined>(undefined); - - if (!result) { - return <Loading /> - } - if (result instanceof TalerError) { - return <ErrorLoading error={result} /> - } - if (result.type === "fail") { - switch (result.case) { - case "already-aborted": return <div>this cashout is already aborted</div> - default: assertUnreachable(result.case) - } - } - const errors = undefinedIfEmpty({ - code: !code ? i18n.str`required` : undefined, - }); - const isPending = String(result.body.status).toUpperCase() === "PENDING"; - return ( - <div> - <h1>Cashout details {id}</h1> - <form class="pure-form"> - <fieldset> - <label> - <i18n.Translate>Subject</i18n.Translate> - </label> - <input readOnly value={result.body.subject} /> - </fieldset> - <fieldset> - <label> - <i18n.Translate>Created</i18n.Translate> - </label> - <input readOnly value={result.body.creation_time.t_s === "never" ? i18n.str`never` : format(result.body.creation_time.t_s, "dd/MM/yyyy HH:mm:ss")} /> - </fieldset> - <fieldset> - <label> - <i18n.Translate>Confirmed</i18n.Translate> - </label> - <input readOnly value={result.body.confirmation_time === undefined ? "-" : - (result.body.confirmation_time.t_s === "never" ? - i18n.str`never` : - format(result.body.confirmation_time.t_s, "dd/MM/yyyy HH:mm:ss")) - } /> - </fieldset> - <fieldset> - <label> - <i18n.Translate>Debited</i18n.Translate> - </label> - <input readOnly value={result.body.amount_debit} /> - </fieldset> - <fieldset> - <label> - <i18n.Translate>Credit</i18n.Translate> - </label> - <input readOnly value={result.body.amount_credit} /> - </fieldset> - <fieldset> - <label> - <i18n.Translate>Status</i18n.Translate> - </label> - <input readOnly value={result.body.status} /> - </fieldset> - <fieldset> - <label> - <i18n.Translate>Destination</i18n.Translate> - </label> - <input readOnly value={result.body.credit_payto_uri} /> - </fieldset> - {isPending ? ( - <fieldset> - <label> - <i18n.Translate>Code</i18n.Translate> - </label> - <input - value={code ?? ""} - onChange={(e) => { - setCode(e.currentTarget.value); - }} - /> - <ShowInputErrorLabel - message={errors?.code} - isDirty={code !== undefined} - /> - </fieldset> - ) : undefined} - </form> - <br /> - <div style={{ display: "flex", justifyContent: "space-between" }}> - <button - class="pure-button pure-button-secondary btn-cancel" - onClick={(e) => { - e.preventDefault(); - onCancel(); - }} - > - {i18n.str`Back`} - </button> - {isPending ? ( - <div> - <button - type="submit" - class="pure-button pure-button-primary button-error" - onClick={async (e) => { - e.preventDefault(); - if (!creds) return; - await withRuntimeErrorHandling(i18n, async () => { - const resp = await api.abortCashoutById(creds, id); - if (resp.type === "ok") { - onCancel(); - } else { - switch (resp.case) { - case "not-found": return notify({ - type: "error", - title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - case "already-confirmed": return notify({ - type: "error", - title: i18n.str`Cashout was already confimed.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - default: { - assertUnreachable(resp) - } - } - } - }) - }} - > - {i18n.str`Abort`} - </button> - - <button - type="submit" - disabled={!code} - class="pure-button pure-button-primary " - onClick={async (e) => { - e.preventDefault(); - if (!creds || !code) return; - await withRuntimeErrorHandling(i18n, async () => { - const resp = await api.confirmCashoutById(creds, id, { - tan: code, - }); - if (resp.type === "ok") { - mutate(() => true)//clean cashout state - } else { - switch (resp.case) { - case "not-found": return notify({ - type: "error", - title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - case "wrong-tan-or-credential": return notify({ - type: "error", - title: i18n.str`Invalid code or credentials.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - case "cashout-address-changed": return notify({ - type: "error", - title: i18n.str`The cash-out address between the creation and the confirmation changed.`, - description: resp.detail.hint as TranslatedString, - debug: resp.detail, - }) - default: assertUnreachable(resp) - } - } - }) - }} - > - {i18n.str`Confirm`} - </button> - </div> - ) : ( - <div /> - )} - </div> - </div> - ); -} - -const MAX_AMOUNT_DIGIT = 2; -/** - * Truncate the amount of digits to display - * in the form based on the fee calculations - * - * Backend must have the same truncation - * @param a - * @returns - */ -function truncate(a: AmountJson): AmountJson { - const str = Amounts.stringify(a); - const idx = str.indexOf("."); - if (idx === -1) { - return a; - } - const truncated = str.substring(0, idx + 1 + MAX_AMOUNT_DIGIT); - return Amounts.parseOrThrow(truncated); -} - -export function assertUnreachable(x: never): never { - throw new Error("Didn't expect to get here"); -} diff --git a/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx b/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx new file mode 100644 index 000000000..a8e34e4b9 --- /dev/null +++ b/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx @@ -0,0 +1,237 @@ +/* + This file is part of GNU Taler + (C) 2022 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 { + TalerError, + TranslatedString +} from "@gnu-taler/taler-util"; +import { + notify, + useTranslationContext +} from "@gnu-taler/web-util/browser"; +import { format } from "date-fns"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { mutate } from "swr"; +import { ErrorLoading } from "../../components/ErrorLoading.js"; +import { Loading } from "../../components/Loading.js"; +import { ShowInputErrorLabel } from "../../components/ShowInputErrorLabel.js"; +import { useBankCoreApiContext } from "../../context/config.js"; +import { useBackendState } from "../../hooks/backend.js"; +import { + useCashoutDetails +} from "../../hooks/circuit.js"; +import { + undefinedIfEmpty, + withRuntimeErrorHandling +} from "../../utils.js"; +import { assertUnreachable } from "../HomePage.js"; + +interface Props { + id: string; + onCancel: () => void; +} +export function ShowCashoutDetails({ + id, + onCancel, +}: Props): VNode { + const { i18n } = useTranslationContext(); + const { state } = useBackendState(); + const creds = state.status !== "loggedIn" ? undefined : state + const { api } = useBankCoreApiContext() + const result = useCashoutDetails(id); + const [code, setCode] = useState<string | undefined>(undefined); + + if (!result) { + return <Loading /> + } + if (result instanceof TalerError) { + return <ErrorLoading error={result} /> + } + if (result.type === "fail") { + switch (result.case) { + case "already-aborted": return <div>this cashout is already aborted</div> + default: assertUnreachable(result.case) + } + } + const errors = undefinedIfEmpty({ + code: !code ? i18n.str`required` : undefined, + }); + const isPending = String(result.body.status).toUpperCase() === "PENDING"; + return ( + <div> + <h1>Cashout details {id}</h1> + <form class="pure-form"> + <fieldset> + <label> + <i18n.Translate>Subject</i18n.Translate> + </label> + <input readOnly value={result.body.subject} /> + </fieldset> + <fieldset> + <label> + <i18n.Translate>Created</i18n.Translate> + </label> + <input readOnly value={result.body.creation_time.t_s === "never" ? i18n.str`never` : format(result.body.creation_time.t_s, "dd/MM/yyyy HH:mm:ss")} /> + </fieldset> + <fieldset> + <label> + <i18n.Translate>Confirmed</i18n.Translate> + </label> + <input readOnly value={result.body.confirmation_time === undefined ? "-" : + (result.body.confirmation_time.t_s === "never" ? + i18n.str`never` : + format(result.body.confirmation_time.t_s, "dd/MM/yyyy HH:mm:ss")) + } /> + </fieldset> + <fieldset> + <label> + <i18n.Translate>Debited</i18n.Translate> + </label> + <input readOnly value={result.body.amount_debit} /> + </fieldset> + <fieldset> + <label> + <i18n.Translate>Credit</i18n.Translate> + </label> + <input readOnly value={result.body.amount_credit} /> + </fieldset> + <fieldset> + <label> + <i18n.Translate>Status</i18n.Translate> + </label> + <input readOnly value={result.body.status} /> + </fieldset> + <fieldset> + <label> + <i18n.Translate>Destination</i18n.Translate> + </label> + <input readOnly value={result.body.credit_payto_uri} /> + </fieldset> + {isPending ? ( + <fieldset> + <label> + <i18n.Translate>Code</i18n.Translate> + </label> + <input + value={code ?? ""} + onChange={(e) => { + setCode(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.code} + isDirty={code !== undefined} + /> + </fieldset> + ) : undefined} + </form> + <br /> + <div style={{ display: "flex", justifyContent: "space-between" }}> + <button + class="pure-button pure-button-secondary btn-cancel" + onClick={(e) => { + e.preventDefault(); + onCancel(); + }} + > + {i18n.str`Back`} + </button> + {isPending ? ( + <div> + <button + type="submit" + class="pure-button pure-button-primary button-error" + onClick={async (e) => { + e.preventDefault(); + if (!creds) return; + await withRuntimeErrorHandling(i18n, async () => { + const resp = await api.abortCashoutById(creds, id); + if (resp.type === "ok") { + onCancel(); + } else { + switch (resp.case) { + case "not-found": return notify({ + type: "error", + title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "already-confirmed": return notify({ + type: "error", + title: i18n.str`Cashout was already confimed.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + default: { + assertUnreachable(resp) + } + } + } + }) + }} + > + {i18n.str`Abort`} + </button> + + <button + type="submit" + disabled={!code} + class="pure-button pure-button-primary " + onClick={async (e) => { + e.preventDefault(); + if (!creds || !code) return; + await withRuntimeErrorHandling(i18n, async () => { + const resp = await api.confirmCashoutById(creds, id, { + tan: code, + }); + if (resp.type === "ok") { + mutate(() => true)//clean cashout state + } else { + switch (resp.case) { + case "not-found": return notify({ + type: "error", + title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "wrong-tan-or-credential": return notify({ + type: "error", + title: i18n.str`Invalid code or credentials.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case "cashout-address-changed": return notify({ + type: "error", + title: i18n.str`The cash-out address between the creation and the confirmation changed.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + default: assertUnreachable(resp) + } + } + }) + }} + > + {i18n.str`Confirm`} + </button> + </div> + ) : ( + <div /> + )} + </div> + </div> + ); +} |