diff options
Diffstat (limited to 'packages')
33 files changed, 1526 insertions, 1002 deletions
diff --git a/packages/demobank-ui/src/assets/lang.svg b/packages/demobank-ui/src/assets/lang.svg new file mode 100644 index 000000000..dd72ce65e --- /dev/null +++ b/packages/demobank-ui/src/assets/lang.svg @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 2411.2 2794" style="enable-background:new 0 0 2411.2 2794;" xml:space="preserve"> +<style type="text/css"> + .st0{fill:#FFFFFF;} + .st1{fill-rule:evenodd;clip-rule:evenodd;} + .st2{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;} +</style> +<g id="Layer_2"> +</g> +<g id="Layer_x5F_1_x5F_1"> + <g> + <polygon points="1204.6,359.2 271.8,30 271.8,2060.1 1204.6,1758.3 "/> + <polygon class="st0" points="1182.2,358.1 2150.6,29 2150.6,2059 1182.2,1757.3 "/> + <polygon class="st0" points="30,2415.4 1182.2,2031.4 1182.2,357.9 30,742 "/> + <polygon points="1707.2,2440.7 1870.5,2709.4 1956.6,2459.8 "/> + <g> + <path d="M421.7,934.8c-6.1-6,8,49.1,27.6,68.9c34.8,35.1,61.9,39.6,76.4,40.2c32,1.3,71.5-8,94.9-17.8 + c22.7-9.7,62.4-30,77.5-59.6c3.2-6.3,11.9-17,6.4-43.2c-4.2-20.2-17-27.3-32.7-26.2c-15.7,1.1-63.2,13.7-86.1,20.8 + c-23,7-70.3,21.4-90.9,25.8C474.3,948.2,429,941.7,421.7,934.8z"/> + <path d="M1003.1,1593.7c-9.1-3.3-196.9-81.1-223.6-93.9c-21.8-10.5-75.2-33.1-100.4-43.3c70.8-109.2,115.5-191.6,121.5-204.1 + c11-23,86-169.6,87.7-178.7c1.7-9.1,3.8-42.9,2.2-51c-1.7-8.2-29.1,7.6-66.4,20.2c-37.4,12.6-108.4,58.8-135.8,64.6 + c-27.5,5.7-115.5,39.1-160.5,54c-45,14.9-130.2,40.9-165.2,50.4c-35.1,9.5-65.7,10.2-85.3,16.2c0,0,2.6,27.5,7.8,35.7 + c5.2,8.2,23.7,28.4,45.3,34.1c21.6,5.7,57.3,3.4,73.6-0.3c16.3-3.8,44.4-17.5,48.2-23.6c3.8-6.1-2-24.9,4.5-30.6 + c6.5-5.6,92.2-25.7,124.6-35.4c32.4-10,156.3-52.6,173.1-50.5c-5.3,17.7-105,215.1-137.1,274c-32.1,58.9-218.6,318-258.3,363.6 + c-30.1,34.7-103.2,123.5-128.5,143.6c6.4,1.8,51.6-2.1,59.9-7.2c51.3-31.6,136.9-138.1,164.4-170.5 + c81.9-96,153.8-196.8,210.8-283.4h0.1c11.1,4.6,100.9,77.8,124.4,94c23.4,16.2,115.9,67.8,136,76.4c20,8.7,97.1,44.2,100.3,32.2 + C1029.4,1668,1012.2,1597.1,1003.1,1593.7z"/> + </g> + <path class="st1" d="M569,2572c18,11,35,20,54,29c38,19,81,39,122,54c56,21,112,38,168,51c31,7,65,13,98,18c3,0,92,11,110,11h90 + c35-3,68-5,103-10c28-4,59-9,89-16c22-5,45-10,67-17c21-6,45-14,68-22c15-5,31-12,47-18c13-6,29-13,44-19c18-8,39-19,59-29 + c16-8,34-18,51-28c13-7,43-30,59-30c18,0,30,16,30,30c0,29-39,38-57,51c-19,13-42,23-62,34c-40,21-81,39-120,54 + c-51,19-107,37-157,49c-19,4-38,9-57,12c-10,2-114,18-143,18h-132c-35-3-72-7-107-12c-31-5-64-11-95-18c-24-5-50-12-73-19 + c-40-11-79-25-117-40c-69-26-141-60-209-105c-12-8-13-16-13-25c0-15,11-29,29-29C531,2546,563,2569,569,2572z"/> + <path class="st1" d="M1151,2009L61,2372V764l1090-363V2009z M1212,354v1680c-1,5-3,10-7,15c-2,3-6,7-9,8c-25,10-1151,388-1166,388 + c-12,0-23-8-29-21c0-1-1-2-1-4V739c2-5,3-12,7-16c8-11,22-13,31-16c17-6,1126-378,1142-378C1190,329,1212,336,1212,354z"/> + <path class="st1" d="M2120,2017l-907-282V380l907-308V2017z M2181,32v2023c-1,23-17,33-32,33c-13,0-107-32-123-37 + c-126-39-253-78-378-117c-28-9-57-18-84-27c-24-7-50-15-74-23c-107-33-216-66-323-102c-4-1-14-15-14-18V351c2-5,4-11,9-15 + c8-9,351-123,486-168c36-13,487-168,501-168C2167,0,2181,13,2181,32z"/> + <polygon points="2411.2,2440.7 1199.5,2054.5 1204.6,373.2 2411.2,757.2 "/> + <g> + <path class="st2" d="M1800.3,1124.6L1681.4,1412l218.6,66.3L1800.3,1124.6z M1729,853.2l156.1,47.3l284.4,1025l-160.3-48.7 + l-57.6-210.4L1620.2,1566l-71.3,171.4l-160.4-48.7L1729,853.2z"/> + </g> + </g> +</g> +</svg> diff --git a/packages/demobank-ui/src/components/Cashouts/views.tsx b/packages/demobank-ui/src/components/Cashouts/views.tsx index 0602f507e..32fe0aa9e 100644 --- a/packages/demobank-ui/src/components/Cashouts/views.tsx +++ b/packages/demobank-ui/src/components/Cashouts/views.tsx @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { h, VNode } from "preact"; +import { Fragment, h, VNode } from "preact"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { State } from "./index.js"; import { format } from "date-fns"; @@ -33,55 +33,118 @@ export function LoadingUriView({ error }: State.LoadingUriError): VNode { export function ReadyView({ cashouts, onSelected }: State.Ready): VNode { const { i18n } = useTranslationContext(); - if (!cashouts.length) { - return ( - <div> - <i18n.Translate>No cashout at the moment</i18n.Translate> - </div> - ); - } + if (!cashouts.length) return <div /> + const txByDate = cashouts.reduce((prev, cur) => { + const d = cur.creation_time.t_s === "never" + ? "" + : format(cur.creation_time.t_s * 1000, "dd/MM/yyyy") + if (!prev[d]) { + prev[d] = [] + } + prev[d].push(cur) + return prev + }, {} as Record<string, typeof cashouts>) return ( - <div class="results"> - <table class="pure-table pure-table-striped"> - <thead> - <tr> - <th>{i18n.str`Created`}</th> - <th>{i18n.str`Confirmed`}</th> - <th>{i18n.str`Total debit`}</th> - <th>{i18n.str`Total credit`}</th> - <th>{i18n.str`Status`}</th> - <th>{i18n.str`Subject`}</th> - </tr> - </thead> - <tbody> - {cashouts.map((item, idx) => { - return ( - <tr key={idx}> - <td>{item.creation_time.t_s === "never" ? i18n.str`never` : format(item.creation_time.t_s, "dd/MM/yyyy HH:mm:ss")}</td> - <td> - {item.confirmation_time + <div class="px-4 mt-4"> + <div class="sm:flex sm:items-center"> + <div class="sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"><i18n.Translate>Latest cashouts</i18n.Translate></h1> + </div> + </div> + <div class="-mx-4 mt-5 ring-1 ring-gray-300 sm:mx-0 rounded-lg min-w-fit bg-white"> + <table class="min-w-full divide-y divide-gray-300"> + <thead> + <tr> + <th scope="col" class=" pl-2 py-3.5 text-left text-sm font-semibold text-gray-900">{i18n.str`Created`}</th> + <th scope="col" class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900">{i18n.str`Confirmed`}</th> + <th scope="col" class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900">{i18n.str`Total debit`}</th> + <th scope="col" class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900">{i18n.str`Total credit`}</th> + <th scope="col" class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900">{i18n.str`Status`}</th> + <th scope="col" class="hidden sm:table-cell pl-2 py-3.5 text-left text-sm font-semibold text-gray-900">{i18n.str`Subject`}</th> + </tr> + </thead> + <tbody> + {Object.entries(txByDate).map(([date, txs], idx) => { + return <Fragment key={idx}> + <tr class="border-t border-gray-200"> + <th colSpan={4} scope="colgroup" class="bg-gray-50 py-2 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-3"> + {date} + </th> + </tr> + {txs.map(item => { + const creationTime = item.creation_time.t_s === "never" ? "" : format(item.creation_time.t_s * 1000, "HH:mm:ss") + const confirmationTime = item.confirmation_time ? item.confirmation_time.t_s === "never" ? i18n.str`never` : format(item.confirmation_time.t_s, "dd/MM/yyyy HH:mm:ss") - : "-"} - </td> - <td><RenderAmount value={Amounts.parseOrThrow(item.amount_debit)} /></td> - <td><RenderAmount value={Amounts.parseOrThrow(item.amount_credit)} /></td> - <td>{item.status}</td> - <td> - <a - href="#" - onClick={(e) => { - e.preventDefault(); - onSelected(item.id); - }} - > - {item.subject} - </a> - </td> - </tr> - ); - })} - </tbody> - </table> + : "-" + return (<tr key={idx} class="border-b border-gray-200 last:border-none"> + + <td class="relative py-2 pl-2 pr-2 text-sm "> + <div class="font-medium text-gray-900">{creationTime}</div> + {/* <dl class="font-normal sm:hidden"> + <dt class="sr-only sm:hidden"><i18n.Translate>Amount</i18n.Translate></dt> + <dd class="mt-1 truncate text-gray-700"> + {item.negative ? i18n.str`sent` : i18n.str`received`} {item.amount ? ( + <span data-negative={item.negative ? "true" : "false"} class="data-[negative=false]:text-green-600 data-[negative=true]:text-red-600"> + <RenderAmount value={item.amount} /> + </span> + ) : ( + <span style={{ color: "grey" }}><{i18n.str`invalid value`}></span> + )}</dd> + + <dt class="sr-only sm:hidden"><i18n.Translate>Counterpart</i18n.Translate></dt> + <dd class="mt-1 truncate text-gray-500 sm:hidden"> + {item.negative ? i18n.str`to` : i18n.str`from`} {item.counterpart} + </dd> + <dd class="mt-1 text-gray-500 sm:hidden" > + <pre class="break-words w-56 whitespace-break-spaces p-2 rounded-md mx-auto my-2 bg-gray-100"> + {item.subject} + </pre> + </dd> + </dl> */} + </td> + <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500">{confirmationTime}</td> + <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-red-600"><RenderAmount value={Amounts.parseOrThrow(item.amount_debit)} /></td> + <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-green-600"><RenderAmount value={Amounts.parseOrThrow(item.amount_credit)} /></td> + + <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500">{item.status}</td> + <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 break-all min-w-md"> + <a href="#" onClick={(e) => { + e.preventDefault(); + onSelected(item.id); + }}> + {item.subject} + </a> + </td> + </tr>) + })} + </Fragment> + + })} + </tbody> + + </table> + + {/* <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 + 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={!onPrev} + onClick={onPrev} + > + <i18n.Translate>First page</i18n.Translate> + </button> + <button + 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={!onNext} + onClick={onNext} + > + <i18n.Translate>Next</i18n.Translate> + </button> + </div> + </nav> */} + </div> </div> ); + // } + } diff --git a/packages/demobank-ui/src/components/CopyButton.tsx b/packages/demobank-ui/src/components/CopyButton.tsx index b36de770e..ca1ceaa8a 100644 --- a/packages/demobank-ui/src/components/CopyButton.tsx +++ b/packages/demobank-ui/src/components/CopyButton.tsx @@ -5,31 +5,21 @@ import { useEffect, useState } from "preact/hooks"; export function CopyIcon(): VNode { return ( - <svg height="16" viewBox="0 0 16 16" width="16" stroke="currentColor" strokeWidth="1.5"> - <path - fill-rule="evenodd" - d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 010 1.5h-1.5a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-1.5a.75.75 0 011.5 0v1.5A1.75 1.75 0 019.25 16h-7.5A1.75 1.75 0 010 14.25v-7.5z" - /> - <path - fill-rule="evenodd" - d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0114.25 11h-7.5A1.75 1.75 0 015 9.25v-7.5zm1.75-.25a.25.25 0 00-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 00.25-.25v-7.5a.25.25 0 00-.25-.25h-7.5z" - /> + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> + <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" /> </svg> ) }; export function CopiedIcon(): VNode { return ( - <svg height="16" viewBox="0 0 16 16" width="16" stroke="currentColor" strokeWidth="1.5"> - <path - fill-rule="evenodd" - d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z" - /> + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> + <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /> </svg> ) }; -export function CopyButton({ getContent }: { getContent: () => string }): VNode { +export function CopyButton({ class: clazz, getContent }: { class: string, getContent: () => string }): VNode { const [copied, setCopied] = useState(false); function copyText(): void { navigator.clipboard.writeText(getContent() || ""); @@ -45,16 +35,14 @@ export function CopyButton({ getContent }: { getContent: () => string }): VNode if (!copied) { return ( - <button class="text-white" onClick={copyText} style={{ width: 16, height: 16, fontSize: "initial" }}> + <button class={clazz} onClick={copyText} > <CopyIcon /> </button> ); } return ( - <div class="text-white" content="Copied" style={{ display: "inline-block" }}> - <button disabled style={{ width: 16, height: 16, fontSize: "initial" }}> - <CopiedIcon /> - </button> - </div> + <button class={clazz} disabled> + <CopiedIcon /> + </button> ); -}
\ No newline at end of file +} diff --git a/packages/demobank-ui/src/components/LangSelector.tsx b/packages/demobank-ui/src/components/LangSelector.tsx index c1d0f64ef..7cf0300df 100644 --- a/packages/demobank-ui/src/components/LangSelector.tsx +++ b/packages/demobank-ui/src/components/LangSelector.tsx @@ -23,6 +23,7 @@ import { Fragment, h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { strings as messages } from "../i18n/strings.js"; +import langIcon from "../assets/lang.svg"; type LangsNames = { [P in keyof typeof messages]: string; @@ -69,7 +70,7 @@ export function LangSelector(): VNode { setHidden((h) => !h); }}> <span class="flex items-center"> - <img src="https://taler.net/images/languageicon.svg" alt="" class="h-5 w-5 flex-shrink-0 rounded-full" /> + <img src={langIcon} alt="" class="h-5 w-5 flex-shrink-0 rounded-full" /> <span class="ml-3 block truncate">{getLangName(lang)}</span> </span> <span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> diff --git a/packages/demobank-ui/src/components/Routing.tsx b/packages/demobank-ui/src/components/Routing.tsx index 04cf96190..1d587fe32 100644 --- a/packages/demobank-ui/src/components/Routing.tsx +++ b/packages/demobank-ui/src/components/Routing.tsx @@ -19,19 +19,26 @@ import { createHashHistory } from "history"; import { Fragment, VNode, h } from "preact"; import { Route, Router, route } from "preact-router"; import { useEffect } from "preact/hooks"; -import { useBackendContext } from "../context/backend.js"; +import { useBackendState } from "../hooks/backend.js"; import { BankFrame } from "../pages/BankFrame.js"; import { HomePage, WithdrawalOperationPage } from "../pages/HomePage.js"; import { LoginForm } from "../pages/LoginForm.js"; import { PublicHistoriesPage } from "../pages/PublicHistoriesPage.js"; import { RegistrationPage } from "../pages/RegistrationPage.js"; -import { AdminHome } from "../pages/admin/Home.js"; -import { BusinessAccount } from "../pages/business/Home.js"; +import { AdminHome } from "../pages/admin/AdminHome.js"; +import { CreateCashout } from "../pages/business/CreateCashout.js"; import { bankUiSettings } from "../settings.js"; +import { ShowAccountDetails } from "../pages/ShowAccountDetails.js"; +import { UpdateAccountPassword } from "../pages/UpdateAccountPassword.js"; +import { RemoveAccount } from "../pages/admin/RemoveAccount.js"; +import { CreateNewAccount } from "../pages/admin/CreateNewAccount.js"; +import { CashoutListForAccount } from "../pages/admin/CashoutListForAccount.js"; +import { ShowCashoutDetails } from "../pages/business/ShowCashoutDetails.js"; +import { WireTransfer } from "../pages/admin/Account.js"; export function Routing(): VNode { const history = createHashHistory(); - const backend = useBackendContext(); + const backend = useBackendState(); const { i18n } = useTranslationContext(); if (backend.state.status === "loggedOut") { @@ -90,7 +97,7 @@ export function Routing(): VNode { const { isUserAdministrator, username } = backend.state return ( - <BankFrame account={backend.state.username}> + <BankFrame account={username}> <Router history={history}> <Route path="/operation/:wopid" @@ -107,6 +114,167 @@ export function Routing(): VNode { path="/public-accounts" component={() => <PublicHistoriesPage />} /> + + <Route + path="/new-account" + component={() => <CreateNewAccount + onCancel={() => { + route("/account") + }} + onCreateSuccess={() => { + route("/account") + }} + />} + /> + + <Route + path="/profile/:account/details" + component={({ account }: { account: string }) => ( + <ShowAccountDetails + account={account} + onUpdateSuccess={() => { + route("/account") + }} + onClear={() => { + route("/account") + }} + /> + )} + /> + + <Route + path="/profile/:account/change-password" + component={({ account }: { account: string }) => ( + <UpdateAccountPassword + focus + account={account} + onUpdateSuccess={() => { + route("/account") + }} + onCancel={() => { + route("/account") + }} + /> + )} + /> + <Route + path="/profile/:account/delete" + component={({ account }: { account: string }) => ( + <RemoveAccount + account={account} + onUpdateSuccess={() => { + route("/account") + }} + onCancel={() => { + route("/account") + }} + /> + )} + /> + + <Route + path="/profile/:account/cashouts" + component={({ account }: { account: string }) => ( + <CashoutListForAccount + account={account} + onSelected={(cid) => { + route(`/cashout/${cid}`) + }} + onClose={() => { + route("/account") + }} + /> + )} + /> + + <Route + path="/my-profile" + component={() => ( + <ShowAccountDetails + account={username} + onUpdateSuccess={() => { + route("/account") + }} + onClear={() => { + route("/account") + }} + /> + )} + /> + <Route + path="/my-password" + component={() => ( + <UpdateAccountPassword + focus + account={username} + onUpdateSuccess={() => { + route("/account") + }} + onCancel={() => { + route("/account") + }} + /> + )} + /> + + <Route + path="/my-cashouts" + component={() => ( + <CashoutListForAccount + account={username} + onSelected={(cid) => { + route(`/cashout/${cid}`) + }} + onClose={() => { + route("/account"); + }} + /> + )} + /> + + <Route + path="/new-cashout" + component={() => ( + <CreateCashout + account={username} + onComplete={(cid) => { + route(`/cashout/${cid}`); + }} + onCancel={() => { + route("/account"); + }} + /> + )} + /> + + <Route + path="/cashout/:cid" + component={({ cid }: { cid: string }) => ( + <ShowCashoutDetails + id={cid} + onCancel={() => { + route("/account"); + }} + /> + )} + /> + + + <Route + path="/wire-transfer/:dest" + component={({ dest }: { dest: string }) => ( + <WireTransfer + toAccount={dest} + onCancel={() => { + route("/account") + }} + onSuccess={() => { + route("/account") + }} + /> + )} + /> + <Route path="/account" component={() => { @@ -115,6 +283,22 @@ export function Routing(): VNode { onRegister={() => { route("/register"); }} + onCreateAccount={() => { + route("/new-account") + }} + onShowAccountDetails={(aid) => { + route(`/profile/${aid}/details`) + }} + onRemoveAccount={(aid) => { + route(`/profile/${aid}/delete`) + }} + onShowCashoutForAccount={(aid) => { + route(`/profile/${aid}/cashouts`) + }} + onUpdateAccountPassword={(aid) => { + route(`/profile/${aid}/change-password`) + + }} />; } else { return <HomePage @@ -132,20 +316,6 @@ export function Routing(): VNode { } }} /> - <Route - path="/business" - component={() => ( - <BusinessAccount - account={username} - onClose={() => { - route("/account"); - }} - onRegister={() => { - route("/register"); - }} - /> - )} - /> <Route default component={Redirect} to="/account" /> </Router> </BankFrame> diff --git a/packages/demobank-ui/src/components/Transactions/views.tsx b/packages/demobank-ui/src/components/Transactions/views.tsx index 5cdb47a0c..47daf8963 100644 --- a/packages/demobank-ui/src/components/Transactions/views.tsx +++ b/packages/demobank-ui/src/components/Transactions/views.tsx @@ -86,11 +86,13 @@ export function ReadyView({ transactions, onNext, onPrev }: State.Ready): VNode <dt class="sr-only sm:hidden"><i18n.Translate>Counterpart</i18n.Translate></dt> <dd class="mt-1 truncate text-gray-500 sm:hidden"> - {item.negative ? i18n.str`to` : i18n.str`from`} {item.counterpart} + {item.negative ? i18n.str`to` : i18n.str`from`} <a href={`#/wire-transfer/${item.counterpart}`} class="text-indigo-600 hover:text-indigo-900"> + {item.counterpart} + </a> </dd> <dd class="mt-1 text-gray-500 sm:hidden" > <pre class="break-words w-56 whitespace-break-spaces p-2 rounded-md mx-auto my-2 bg-gray-100"> - {item.subject} + {item.subject} </pre> </dd> </dl> @@ -102,7 +104,11 @@ export function ReadyView({ transactions, onNext, onPrev }: State.Ready): VNode <span style={{ color: "grey" }}><{i18n.str`invalid value`}></span> )} </td> - <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500">{item.counterpart}</td> + <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500"> + <a href={`#/wire-transfer/${item.counterpart}`} class="text-indigo-600 hover:text-indigo-900"> + {item.counterpart} + </a> + </td> <td class="hidden sm:table-cell px-3 py-3.5 text-sm text-gray-500 break-all min-w-md">{item.subject}</td> </tr>) })} diff --git a/packages/demobank-ui/src/components/app.tsx b/packages/demobank-ui/src/components/app.tsx index beb24da57..55e1178fe 100644 --- a/packages/demobank-ui/src/components/app.tsx +++ b/packages/demobank-ui/src/components/app.tsx @@ -27,6 +27,7 @@ import { BankCoreApiProvider } from "../context/config.js"; import { strings } from "../i18n/strings.js"; import { bankUiSettings } from "../settings.js"; import { Routing } from "./Routing.js"; +import { BankFrame } from "../pages/BankFrame.js"; const WITH_LOCAL_STORAGE_CACHE = false; const App: FunctionalComponent = () => { @@ -34,7 +35,7 @@ const App: FunctionalComponent = () => { return ( <TranslationProvider source={strings}> <BackendStateProvider> - <BankCoreApiProvider baseUrl={baseUrl}> + <BankCoreApiProvider baseUrl={baseUrl} frameOnError={BankFrame}> <SWRConfig value={{ provider: WITH_LOCAL_STORAGE_CACHE diff --git a/packages/demobank-ui/src/context/config.ts b/packages/demobank-ui/src/context/config.ts index 013d8922e..a31d914b8 100644 --- a/packages/demobank-ui/src/context/config.ts +++ b/packages/demobank-ui/src/context/config.ts @@ -16,7 +16,7 @@ import { TalerCorebankApi, TalerCoreBankHttpClient, TalerError } from "@gnu-taler/taler-util"; import { BrowserHttpLib, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { ComponentChildren, createContext, h, VNode } from "preact"; +import { ComponentChildren, createContext, FunctionComponent, h, VNode } from "preact"; import { useContext, useEffect, useState } from "preact/hooks"; import { ErrorLoading } from "../components/ErrorLoading.js"; @@ -43,9 +43,11 @@ export type ConfigResult = undefined export const BankCoreApiProvider = ({ baseUrl, children, + frameOnError, }: { baseUrl: string, children: ComponentChildren; + frameOnError: FunctionComponent<{ children: ComponentChildren }>, }): VNode => { const [checked, setChecked] = useState<ConfigResult>() const { i18n } = useTranslationContext(); @@ -68,13 +70,13 @@ export const BankCoreApiProvider = ({ }, []); if (checked === undefined) { - return h("div", {}, "loading...") + return h(frameOnError, { children: h("div", {}, "loading...") }) } if (checked.type === "error") { - return h(ErrorLoading, { error: checked.error, showDetail: true }) + return h(frameOnError, { children: h(ErrorLoading, { error: checked.error, showDetail: true }) }) } if (checked.type === "incompatible") { - return h("div", {}, i18n.str`the bank backend is not supported. supported version "${checked.supported}", server version "${checked.result.version}"`) + return h(frameOnError, { children: h("div", {}, i18n.str`the bank backend is not supported. supported version "${checked.supported}", server version "${checked.result.version}"`) }) } const value: Type = { url, config: checked.config, api diff --git a/packages/demobank-ui/src/hooks/access.ts b/packages/demobank-ui/src/hooks/access.ts index 7023b8803..da9812ffa 100644 --- a/packages/demobank-ui/src/hooks/access.ts +++ b/packages/demobank-ui/src/hooks/access.ts @@ -61,7 +61,7 @@ export function useWithdrawalDetails(wid: string) { // const { state: credentials } = useBackendState(); const { api } = useBankCoreApiContext(); - async function fetcher(wid: string) { + async function fetcher([wid]: [string]) { return await api.getWithdrawalById(wid) } @@ -114,7 +114,7 @@ export function usePublicAccounts(initial?: number) { const [offset, setOffset] = useState<number | undefined>(initial); const { api } = useBankCoreApiContext(); - async function fetcher(txid: number | undefined) { + async function fetcher([txid]: [number | undefined]) { return await api.getPublicAccounts({ limit: MAX_RESULT_SIZE, offset: txid ? String(txid) : undefined, @@ -124,16 +124,16 @@ export function usePublicAccounts(initial?: number) { const { data, error } = useSWR<TalerCoreBankResultByMethod<"getPublicAccounts">, TalerHttpError>( [offset, "getPublicAccounts"], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, - }); + refreshInterval: 0, + refreshWhenHidden: false, + revalidateOnFocus: false, + revalidateOnReconnect: false, + refreshWhenOffline: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); const isLastPage = data && data.body.public_accounts.length < PAGE_SIZE; diff --git a/packages/demobank-ui/src/hooks/circuit.ts b/packages/demobank-ui/src/hooks/circuit.ts index 0f7af5fe5..06e068d6d 100644 --- a/packages/demobank-ui/src/hooks/circuit.ts +++ b/packages/demobank-ui/src/hooks/circuit.ts @@ -15,16 +15,15 @@ */ import { useState } from "preact/hooks"; -import { useBackendContext } from "../context/backend.js"; import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js"; import { useBackendState } from "./backend.js"; -// FIX default import https://github.com/microsoft/TypeScript/issues/49189 import { AccessToken, AmountJson, Amounts, OperationOk, TalerCoreBankResultByMethod, TalerCorebankApi, TalerError, TalerHttpError } from "@gnu-taler/taler-util"; import _useSWR, { SWRHook } from "swr"; import { useBankCoreApiContext } from "../context/config.js"; import { assertUnreachable } from "../pages/HomePage.js"; +// FIX default import https://github.com/microsoft/TypeScript/issues/49189 const useSWR = _useSWR as unknown as SWRHook; export type TransferCalculation = { @@ -44,12 +43,8 @@ type CashoutEstimators = { }; export function useEstimator(): CashoutEstimators { - const { state } = useBackendContext(); + const { state } = useBackendState(); const { api } = useBankCoreApiContext(); - const creds = - state.status !== "loggedIn" - ? undefined - : state.token; return { estimateByCredit: async (amount, fee, rate) => { const resp = await api.getCashoutRate({ @@ -101,13 +96,14 @@ export function useEstimator(): CashoutEstimators { } export function useRatiosAndFeeConfig() { - const { api } = useBankCoreApiContext(); + const { api, config } = useBankCoreApiContext(); + function fetcher() { return api.getConversionRates() } const { data, error } = useSWR<TalerCoreBankResultByMethod<"getConversionRates">, TalerHttpError>( - [, "getConversionRates"], fetcher, { + !config.have_cashout || !config.fiat_currency ? false : [, "getConversionRates"], fetcher, { refreshInterval: 60 * 1000, refreshWhenHidden: false, revalidateOnFocus: false, diff --git a/packages/demobank-ui/src/pages.ts b/packages/demobank-ui/src/pages.ts new file mode 100644 index 000000000..c78240a02 --- /dev/null +++ b/packages/demobank-ui/src/pages.ts @@ -0,0 +1,44 @@ +import { WithdrawalOperationPage } from "./pages/HomePage.js"; +import { PageEntry, pageDefinition } from "./route.js"; + +// const operationById: PageEntry<{ operationId: string }> = { +// url: pageDefinition("#/operation/:operationId"), +// view: WithdrawalOperationPage, +// }; + + +// const home: PageEntry = { +// url: "#/", +// view: Home, +// }; +// const cases: PageEntry = { +// url: "#/cases", +// view: Cases, +// }; + +// const newFormEntry: PageEntry<{ account?: string; type?: string }> = { +// url: pageDefinition("#/account/:account/new/:type?"), +// view: NewFormEntry, +// }; + +// const settings: PageEntry = { +// url: "#/settings", +// view: Settings, +// }; +// const officer: PageEntry = { +// url: "#/officer", +// view: Officer, +// }; +// const welcome: PageEntry<{ asd?: string; name?: string }> = { +// url: pageDefinition("#/welcome/:name?"), +// view: Welcome, +// }; +// const form: PageEntry<{ number?: string }> = { +// url: pageDefinition("#/form/:number?"), +// view: AntiMoneyLaunderingForm, +// }; + +export const Pages = { + // operationById, + +}; 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> + ); +} diff --git a/packages/demobank-ui/src/route.ts b/packages/demobank-ui/src/route.ts new file mode 100644 index 000000000..d54f9be83 --- /dev/null +++ b/packages/demobank-ui/src/route.ts @@ -0,0 +1,167 @@ +import { createHashHistory } from "history"; +import { h as create, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +const history = createHashHistory(); + +type PageDefinition<DynamicPart extends Record<string, string>> = { + pattern: string; + (params: DynamicPart): string; +}; + +function replaceAll( + pattern: string, + vars: Record<string, string>, + values: Record<string, string>, +): string { + let result = pattern; + for (const v in vars) { + result = result.replace(vars[v], !values[v] ? "" : values[v]); + } + return result; +} + +export function pageDefinition<T extends Record<string, string>>( + pattern: string, +): PageDefinition<T> { + const patternParams = pattern.match(/(:[\w?]*)/g); + if (!patternParams) + throw Error( + `page definition pattern ${pattern} doesn't have any parameter`, + ); + + const vars = patternParams.reduce((prev, cur) => { + const pName = cur.match(/(\w+)/g); + + //skip things like :? in the path pattern + if (!pName || !pName[0]) return prev; + const name = pName[0]; + return { ...prev, [name]: cur }; + }, {} as Record<string, string>); + + const f = (values: T): string => replaceAll(pattern, vars, values); + f.pattern = pattern; + return f; +} + +export type PageEntry<T = unknown> = T extends Record<string, string> + ? { + url: PageDefinition<T>; + view: (props: T) => VNode; + } + : T extends unknown + ? { + url: string; + view: (props: {}) => VNode; + } + : never; + +export function Router({ + pageList, + onNotFound, +}: { + pageList: Array<PageEntry<any>>; + onNotFound: () => VNode; +}): VNode { + const current = useCurrentLocation(pageList); + if (current !== undefined) { + return create(current.page.view, current.values); + } + return onNotFound(); +} + +type Location = { + page: PageEntry<any>; + path: string; + values: Record<string, string>; +}; +export function useCurrentLocation(pageList: Array<PageEntry<any>>) { + const [currentLocation, setCurrentLocation] = useState<Location>(); + /** + * Search path in the pageList + * get the values from the path found + * add params from searchParams + * + * @param path + * @param params + */ + function doSync(path: string, params: URLSearchParams) { + let result: typeof currentLocation; + for (let idx = 0; idx < pageList.length; idx++) { + const page = pageList[idx]; + if (typeof page.url === "string") { + if (page.url === path) { + const values: Record<string, string> = {}; + params.forEach((v, k) => { + values[k] = v; + }); + result = { page, values, path }; + break; + } + } else { + const values = doestUrlMatchToRoute(path, page.url.pattern); + if (values !== undefined) { + params.forEach((v, k) => { + values[k] = v; + }); + result = { page, values, path }; + break; + } + } + } + setCurrentLocation(result); + } + useEffect(() => { + doSync(window.location.hash, new URLSearchParams(window.location.search)); + return history.listen(() => { + doSync(window.location.hash, new URLSearchParams(window.location.search)); + }); + }, []); + return currentLocation; +} + +function doestUrlMatchToRoute( + url: string, + route: string, +): undefined | Record<string, string> { + const paramsPattern = /(?:\?([^#]*))?$/; + // const paramsPattern = /(?:\?([^#]*))?(#.*)?$/; + const params = url.match(paramsPattern); + const urlWithoutParams = url.replace(paramsPattern, ""); + + const result: Record<string, string> = {}; + if (params && params[1]) { + const paramList = params[1].split("&"); + for (let i = 0; i < paramList.length; i++) { + const idx = paramList[i].indexOf("="); + const name = paramList[i].substring(0, idx); + const value = paramList[i].substring(idx + 1); + result[decodeURIComponent(name)] = decodeURIComponent(value); + } + } + const urlSeg = urlWithoutParams.split("/"); + const routeSeg = route.split("/"); + let max = Math.max(urlSeg.length, routeSeg.length); + for (let i = 0; i < max; i++) { + if (routeSeg[i] && routeSeg[i].charAt(0) === ":") { + const param = routeSeg[i].replace(/(^:|[+*?]+$)/g, ""); + + const flags = (routeSeg[i].match(/[+*?]+$/) || EMPTY)[0] || ""; + const plus = ~flags.indexOf("+"); + const star = ~flags.indexOf("*"); + const val = urlSeg[i] || ""; + + if (!val && !star && (flags.indexOf("?") < 0 || plus)) { + return undefined; + } + result[param] = decodeURIComponent(val); + if (plus || star) { + result[param] = urlSeg.slice(i).map(decodeURIComponent).join("/"); + break; + } + } else if (routeSeg[i] !== urlSeg[i]) { + return undefined; + } + } + return result; +} +const EMPTY: Record<string, string> = {}; |