new login token

This commit is contained in:
Sebastian 2023-09-11 15:07:55 -03:00
parent e2422b68eb
commit 8c20f4b279
No known key found for this signature in database
GPG Key ID: 173909D1A5F66069
29 changed files with 697 additions and 551 deletions

View File

@ -41,7 +41,8 @@ import {
import { ConfigContextProvider } from "./context/config.js"; import { ConfigContextProvider } from "./context/config.js";
import { useBackendConfig } from "./hooks/backend.js"; import { useBackendConfig } from "./hooks/backend.js";
import { strings } from "./i18n/strings.js"; import { strings } from "./i18n/strings.js";
import LoginPage from "./paths/login/index.js"; import { ConnectionPage, LoginPage } from "./paths/login/index.js";
import { LoginToken } from "./declaration.js";
export function Application(): VNode { export function Application(): VNode {
return ( return (
@ -59,25 +60,20 @@ export function Application(): VNode {
* @returns * @returns
*/ */
function ApplicationStatusRoutes(): VNode { function ApplicationStatusRoutes(): VNode {
const { url, updateLoginStatus, triedToLog } = useBackendContext(); const { url: backendURL, updateToken, changeBackend } = useBackendContext();
const result = useBackendConfig(); const result = useBackendConfig();
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const updateLoginInfoAndGoToRoot = (url: string, token?: string) => {
updateLoginStatus(url, token);
route("/");
};
const { currency, version } = result.ok const { currency, version } = result.ok
? result.data ? result.data
: { currency: "unknown", version: "unknown" }; : { currency: "unknown", version: "unknown" };
const ctx = useMemo(() => ({ currency, version }), [currency, version]); const ctx = useMemo(() => ({ currency, version }), [currency, version]);
if (!triedToLog) { if (!backendURL) {
return ( return (
<Fragment> <Fragment>
<NotConnectedAppMenu title="Welcome!" /> <NotConnectedAppMenu title="Welcome!" />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment> </Fragment>
); );
} }
@ -91,7 +87,7 @@ function ApplicationStatusRoutes(): VNode {
return ( return (
<Fragment> <Fragment>
<NotConnectedAppMenu title="Login" /> <NotConnectedAppMenu title="Login" />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment> </Fragment>
); );
} }
@ -109,7 +105,7 @@ function ApplicationStatusRoutes(): VNode {
description: `Check your url`, description: `Check your url`,
}} }}
/> />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment> </Fragment>
); );
} }
@ -120,10 +116,10 @@ function ApplicationStatusRoutes(): VNode {
notification={{ notification={{
message: i18n.str`Server response with an error code`, message: i18n.str`Server response with an error code`,
type: "ERROR", type: "ERROR",
description: i18n.str`Got message ${result.message} from ${result.info?.url}`, description: i18n.str`Got message "${result.message}" from ${result.info?.url}`,
}} }}
/> />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment>; </Fragment>;
} }
if (result.type === ErrorType.UNREADABLE) { if (result.type === ErrorType.UNREADABLE) {
@ -133,10 +129,10 @@ function ApplicationStatusRoutes(): VNode {
notification={{ notification={{
message: i18n.str`Response from server is unreadable, http status: ${result.status}`, message: i18n.str`Response from server is unreadable, http status: ${result.status}`,
type: "ERROR", type: "ERROR",
description: i18n.str`Got message ${result.message} from ${result.info?.url}`, description: i18n.str`Got message "${result.message}" from ${result.info?.url}`,
}} }}
/> />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment>; </Fragment>;
} }
return ( return (
@ -146,10 +142,10 @@ function ApplicationStatusRoutes(): VNode {
notification={{ notification={{
message: i18n.str`Unexpected Error`, message: i18n.str`Unexpected Error`,
type: "ERROR", type: "ERROR",
description: i18n.str`Got message ${result.message} from ${result.info?.url}`, description: i18n.str`Got message "${result.message}" from ${result.info?.url}`,
}} }}
/> />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment> </Fragment>
); );
} }
@ -168,7 +164,7 @@ function ApplicationStatusRoutes(): VNode {
description: i18n.str`Merchant backend server version ${result.data.version} is not compatible with the supported version ${SUPPORTED_VERSION}`, description: i18n.str`Merchant backend server version ${result.data.version} is not compatible with the supported version ${SUPPORTED_VERSION}`,
}} }}
/> />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment> </Fragment>
} }

View File

@ -18,22 +18,23 @@
* *
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { HttpStatusCode } from "@gnu-taler/taler-util";
import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser"; import { ErrorType, useTranslationContext } from "@gnu-taler/web-util/browser";
import { createHashHistory } from "history"; import { createHashHistory } from "history";
import { Fragment, h, VNode } from "preact"; import { Fragment, VNode, h } from "preact";
import { Router, Route, route } from "preact-router"; import { Route, Router, route } from "preact-router";
import { useEffect, useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { InstanceRoutes } from "./InstanceRoutes.js";
import { import {
NotificationCard,
NotYetReadyAppMenu, NotYetReadyAppMenu,
NotificationCard,
} from "./components/menu/index.js"; } from "./components/menu/index.js";
import { useBackendContext } from "./context/backend.js"; import { useBackendContext } from "./context/backend.js";
import { LoginToken } from "./declaration.js";
import { useBackendInstancesTestForAdmin } from "./hooks/backend.js"; import { useBackendInstancesTestForAdmin } from "./hooks/backend.js";
import { InstanceRoutes } from "./InstanceRoutes.js"; import { ConnectionPage, LoginPage } from "./paths/login/index.js";
import LoginPage from "./paths/login/index.js";
import { INSTANCE_ID_LOOKUP } from "./utils/constants.js";
import { HttpStatusCode } from "@gnu-taler/taler-util";
import { Settings } from "./paths/settings/index.js"; import { Settings } from "./paths/settings/index.js";
import { INSTANCE_ID_LOOKUP } from "./utils/constants.js";
/** /**
* Check if admin against /management/instances * Check if admin against /management/instances
@ -41,15 +42,14 @@ import { Settings } from "./paths/settings/index.js";
*/ */
export function ApplicationReadyRoutes(): VNode { export function ApplicationReadyRoutes(): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const { url: backendURL, changeBackend } = useBackendContext()
const [unauthorized, setUnauthorized] = useState(false) const [unauthorized, setUnauthorized] = useState(false)
const { const {
url: backendURL, updateToken,
updateLoginStatus: updateLoginStatus2,
} = useBackendContext(); } = useBackendContext();
function updateLoginStatus(url: string, token: string | undefined) { function updateLoginStatus(token: LoginToken | undefined) {
console.log("updateing", url, token) updateToken(token)
updateLoginStatus2(url, token)
setUnauthorized(false) setUnauthorized(false)
} }
@ -59,15 +59,15 @@ export function ApplicationReadyRoutes(): VNode {
route("/"); route("/");
}; };
const [showSettings, setShowSettings] = useState(false) const [showSettings, setShowSettings] = useState(false)
// useEffect(() => { const unauthorizedAdmin = !result.loading
// setUnauthorized(FF) && !result.ok
// }, [FF]) && result.type === ErrorType.CLIENT
const unauthorizedAdmin = !result.loading && !result.ok && result.type === ErrorType.CLIENT && result.status === HttpStatusCode.Unauthorized && result.status === HttpStatusCode.Unauthorized;
if (showSettings) { if (showSettings) {
return <Fragment> return <Fragment>
<NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} /> <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="UI Settings" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} />
<Settings /> <Settings onClose={() => setShowSettings(false)} />
</Fragment> </Fragment>
} }
@ -100,7 +100,7 @@ export function ApplicationReadyRoutes(): VNode {
type: "ERROR", type: "ERROR",
}} }}
/> />
<LoginPage onConfirm={updateLoginStatus} /> <ConnectionPage onConfirm={changeBackend} />
</Fragment> </Fragment>
); );
} }
@ -108,14 +108,13 @@ export function ApplicationReadyRoutes(): VNode {
instanceNameByBackendURL = match[1]; instanceNameByBackendURL = match[1];
} }
console.log(unauthorized, unauthorizedAdmin)
if (unauthorized || unauthorizedAdmin) { if (unauthorized || unauthorizedAdmin) {
return <Fragment> return <Fragment>
<NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Login" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} /> <NotYetReadyAppMenu onShowSettings={() => setShowSettings(true)} title="Login" onLogout={clearTokenAndGoToRoot} isPasswordOk={false} />
<NotificationCard <NotificationCard
notification={{ notification={{
message: i18n.str`Access denied`, message: i18n.str`Access denied`,
description: i18n.str`Check your token is valid`, description: i18n.str`Check your token is valid 1`,
type: "ERROR", type: "ERROR",
}} }}
/> />
@ -132,7 +131,6 @@ export function ApplicationReadyRoutes(): VNode {
admin={admin} admin={admin}
onUnauthorized={() => setUnauthorized(true)} onUnauthorized={() => setUnauthorized(true)}
onLoginPass={() => { onLoginPass={() => {
console.log("ahora si")
setUnauthorized(false) setUnauthorized(false)
}} }}
instanceNameByBackendURL={instanceNameByBackendURL} instanceNameByBackendURL={instanceNameByBackendURL}

View File

@ -35,7 +35,7 @@ import { InstanceContextProvider } from "./context/instance.js";
import { import {
useBackendDefaultToken, useBackendDefaultToken,
useBackendInstanceToken, useBackendInstanceToken,
useLocalStorage, useSimpleLocalStorage,
} from "./hooks/index.js"; } from "./hooks/index.js";
import { useInstanceKYCDetails } from "./hooks/instance.js"; import { useInstanceKYCDetails } from "./hooks/instance.js";
import InstanceCreatePage from "./paths/admin/create/index.js"; import InstanceCreatePage from "./paths/admin/create/index.js";
@ -71,10 +71,10 @@ import InstanceUpdatePage, {
AdminUpdate as InstanceAdminUpdatePage, AdminUpdate as InstanceAdminUpdatePage,
Props as InstanceUpdatePageProps, Props as InstanceUpdatePageProps,
} from "./paths/instance/update/index.js"; } from "./paths/instance/update/index.js";
import LoginPage from "./paths/login/index.js"; import { LoginPage } from "./paths/login/index.js";
import NotFoundPage from "./paths/notfound/index.js"; import NotFoundPage from "./paths/notfound/index.js";
import { Notification } from "./utils/types.js"; import { Notification } from "./utils/types.js";
import { MerchantBackend } from "./declaration.js"; import { LoginToken, MerchantBackend } from "./declaration.js";
import { Settings } from "./paths/settings/index.js"; import { Settings } from "./paths/settings/index.js";
import { dateFormatForSettings, useSettings } from "./hooks/useSettings.js"; import { dateFormatForSettings, useSettings } from "./hooks/useSettings.js";
@ -143,7 +143,7 @@ export function InstanceRoutes({
id, id,
admin, admin,
path, path,
onUnauthorized, // onUnauthorized,
onLoginPass, onLoginPass,
setInstanceName, setInstanceName,
}: Props): VNode { }: Props): VNode {
@ -155,7 +155,7 @@ export function InstanceRoutes({
const [globalNotification, setGlobalNotification] = const [globalNotification, setGlobalNotification] =
useState<GlobalNotifState>(undefined); useState<GlobalNotifState>(undefined);
const changeToken = (token?: string) => { const changeToken = (token?: LoginToken) => {
if (admin) { if (admin) {
updateToken(token); updateToken(token);
} else { } else {
@ -201,14 +201,17 @@ export function InstanceRoutes({
// const LoginPageAccessDeniend = onUnauthorized // const LoginPageAccessDeniend = onUnauthorized
const LoginPageAccessDenied = () => { const LoginPageAccessDenied = () => {
onUnauthorized() return <Fragment>
return <NotificationCard <NotificationCard
notification={{ notification={{
message: i18n.str`Access denied`, message: i18n.str`Access denied`,
description: i18n.str`Redirecting to login page.`, description: i18n.str`Redirecting to login page.`,
type: "ERROR", type: "ERROR",
}} }}
/> />
<LoginPage onConfirm={changeToken} />
</Fragment>
} }
function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<any>) { function IfAdminCreateDefaultOr<T>(Next: FunctionComponent<any>) {
@ -687,9 +690,7 @@ function AdminInstanceUpdatePage({
...rest ...rest
}: { id: string } & InstanceUpdatePageProps): VNode { }: { id: string } & InstanceUpdatePageProps): VNode {
const [token, changeToken] = useBackendInstanceToken(id); const [token, changeToken] = useBackendInstanceToken(id);
const { updateLoginStatus: changeBackend } = useBackendContext(); const updateLoginStatus = (token?: LoginToken): void => {
const updateLoginStatus = (url: string, token?: string): void => {
changeBackend(url);
changeToken(token); changeToken(token);
}; };
const value = useMemo( const value = useMemo(
@ -752,7 +753,7 @@ function KycBanner(): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const [settings] = useSettings(); const [settings] = useSettings();
const today = format(new Date(), dateFormatForSettings(settings)); const today = format(new Date(), dateFormatForSettings(settings));
const [lastHide, setLastHide] = useLocalStorage("kyc-last-hide"); const [lastHide, setLastHide] = useSimpleLocalStorage("kyc-last-hide");
const hasBeenHidden = today === lastHide; const hasBeenHidden = today === lastHide;
const needsToBeShown = kycStatus.ok && kycStatus.data.type === "redirect"; const needsToBeShown = kycStatus.ok && kycStatus.data.type === "redirect";
if (hasBeenHidden || !needsToBeShown) return <Fragment />; if (hasBeenHidden || !needsToBeShown) return <Fragment />;

View File

@ -1,244 +0,0 @@
/*
This file is part of GNU Taler
(C) 2021-2023 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/>
*/
/**
*
* @author Sebastian Javier Marchano (sebasjm)
*/
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { ComponentChildren, h, VNode } from "preact";
import { useState } from "preact/hooks";
import { useBackendContext } from "../../context/backend.js";
import { useInstanceContext } from "../../context/instance.js";
import { useCredentialsChecker } from "../../hooks/backend.js";
import { Notification } from "../../utils/types.js";
interface Props {
withMessage?: Notification;
onConfirm: (backend: string, token?: string) => void;
}
function getTokenValuePart(t: string): string {
if (!t) return t;
const match = /secret-token:(.*)/.exec(t);
if (!match || !match[1]) return "";
return match[1];
}
function normalizeToken(r: string): string {
return `secret-token:${r}`;
}
function cleanUp(s: string): string {
let result = s;
if (result.indexOf("webui/") !== -1) {
result = result.substring(0, result.indexOf("webui/"));
}
return result;
}
export function LoginModal({ onConfirm, withMessage }: Props): VNode {
const { url: backendUrl, token: baseToken } = useBackendContext();
const { admin, token: instanceToken, id } = useInstanceContext();
const testLogin = useCredentialsChecker();
const currentToken = getTokenValuePart(
(!admin ? baseToken : instanceToken) ?? "",
);
const [token, setToken] = useState(currentToken);
const [url, setURL] = useState(cleanUp(backendUrl));
const { i18n } = useTranslationContext();
if (admin && id !== "default") {
//admin trying to access another instance
return (<div class="columns is-centered" style={{ margin: "auto" }}>
<div class="column is-two-thirds ">
<div class="modal-card" style={{ width: "100%", margin: 0 }}>
<header
class="modal-card-head"
style={{ border: "1px solid", borderBottom: 0 }}
>
<p class="modal-card-title">{i18n.str`Login required`}</p>
</header>
<section
class="modal-card-body"
style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }}
>
<p>
<i18n.Translate>Need the access token for the instance.</i18n.Translate>
</p>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">
<i18n.Translate>Access Token</i18n.Translate>
</label>
</div>
<div class="field-body">
<div class="field">
<p class="control is-expanded">
<input
class="input"
type="password"
placeholder={"current access token"}
name="token"
onKeyPress={(e) =>
e.keyCode === 13
? onConfirm(url, normalizeToken(token))
: null
}
value={token}
onInput={(e): void => setToken(e?.currentTarget.value)}
/>
</p>
</div>
</div>
</div>
</section>
<footer
class="modal-card-foot "
style={{
justifyContent: "flex-end",
border: "1px solid",
borderTop: 0,
}}
>
<AsyncButton
onClick={async () => {
const secretToken = normalizeToken(token);
const { valid, cause } = await testLogin(`${url}/instances/${id}`, secretToken);
if (valid) {
onConfirm(url, secretToken);
} else {
onConfirm(url);
}
}}
>
<i18n.Translate>Confirm</i18n.Translate>
</AsyncButton>
</footer>
</div>
</div>
</div>)
}
return (
<div class="columns is-centered" style={{ margin: "auto" }}>
<div class="column is-two-thirds ">
<div class="modal-card" style={{ width: "100%", margin: 0 }}>
<header
class="modal-card-head"
style={{ border: "1px solid", borderBottom: 0 }}
>
<p class="modal-card-title">{i18n.str`Login required`}</p>
</header>
<section
class="modal-card-body"
style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }}
>
<i18n.Translate>Please enter your access token.</i18n.Translate>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">URL</label>
</div>
<div class="field-body">
<div class="field">
<p class="control is-expanded">
<input
class="input"
type="text"
placeholder="set new url"
name="id"
value={url}
onKeyPress={(e) =>
e.keyCode === 13
? onConfirm(url, normalizeToken(token))
: null
}
onInput={(e): void => setURL(e?.currentTarget.value)}
/>
</p>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">
<i18n.Translate>Access Token</i18n.Translate>
</label>
</div>
<div class="field-body">
<div class="field">
<p class="control is-expanded">
<input
class="input"
type="password"
placeholder={"current access token"}
name="token"
onKeyPress={(e) =>
e.keyCode === 13
? onConfirm(url, normalizeToken(token))
: null
}
value={token}
onInput={(e): void => setToken(e?.currentTarget.value)}
/>
</p>
</div>
</div>
</div>
</section>
<footer
class="modal-card-foot "
style={{
justifyContent: "flex-end",
border: "1px solid",
borderTop: 0,
}}
>
<AsyncButton
onClick={async () => {
const secretToken = normalizeToken(token);
const { valid, cause } = await testLogin(url, secretToken);
if (valid) {
onConfirm(url, secretToken);
} else {
onConfirm(url);
}
}}
>
<i18n.Translate>Confirm</i18n.Translate>
</AsyncButton>
</footer>
</div>
</div>
</div>
);
}
function AsyncButton({ onClick, children }: { onClick: () => Promise<void>, children: ComponentChildren }): VNode {
const [running, setRunning] = useState(false)
return <button class="button is-info" disabled={running} onClick={() => {
setRunning(true)
onClick().then(() => {
setRunning(false)
}).catch(() => {
setRunning(false)
})
}}>
{children}
</button>
}

View File

@ -40,13 +40,13 @@ export function DefaultInstanceFormFields({
showId: boolean; showId: boolean;
}): VNode { }): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext(); const { url: backendURL } = useBackendContext()
return ( return (
<Fragment> <Fragment>
{showId && ( {showId && (
<InputWithAddon<Entity> <InputWithAddon<Entity>
name="id" name="id"
addonBefore={`${backend.url}/instances/`} addonBefore={`${backendURL}/instances/`}
readonly={readonlyId} readonly={readonlyId}
label={i18n.str`Identifier`} label={i18n.str`Identifier`}
tooltip={i18n.str`Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.`} tooltip={i18n.str`Name of the instance in URLs. The 'default' instance is special in that it is used to administer other instances.`}

View File

@ -25,7 +25,6 @@ import { useBackendContext } from "../../context/backend.js";
import { useConfigContext } from "../../context/config.js"; import { useConfigContext } from "../../context/config.js";
import { useInstanceKYCDetails } from "../../hooks/instance.js"; import { useInstanceKYCDetails } from "../../hooks/instance.js";
import { LangSelector } from "./LangSelector.js"; import { LangSelector } from "./LangSelector.js";
import { useCredentialsChecker } from "../../hooks/backend.js";
const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined;
const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined;
@ -50,7 +49,7 @@ export function Sidebar({
isPasswordOk isPasswordOk
}: Props): VNode { }: Props): VNode {
const config = useConfigContext(); const config = useConfigContext();
const backend = useBackendContext(); const { url: backendURL } = useBackendContext()
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const kycStatus = useInstanceKYCDetails(); const kycStatus = useInstanceKYCDetails();
const needKYC = kycStatus.ok && kycStatus.data.type === "redirect"; const needKYC = kycStatus.ok && kycStatus.data.type === "redirect";
@ -230,7 +229,7 @@ export function Sidebar({
<i class="mdi mdi-web" /> <i class="mdi mdi-web" />
</span> </span>
<span class="menu-item-label"> <span class="menu-item-label">
{new URL(backend.url).hostname} {new URL(backendURL).hostname}
</span> </span>
</div> </div>
</li> </li>

View File

@ -114,7 +114,7 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {
onSubscribe(hasErrors ? undefined : submit); onSubscribe(hasErrors ? undefined : submit);
}, [submit, hasErrors]); }, [submit, hasErrors]);
const backend = useBackendContext(); const { url: backendURL } = useBackendContext()
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
return ( return (
@ -128,7 +128,7 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {
{alreadyExist ? undefined : ( {alreadyExist ? undefined : (
<InputWithAddon<Entity> <InputWithAddon<Entity>
name="product_id" name="product_id"
addonBefore={`${backend.url}/product/`} addonBefore={`${backendURL}/product/`}
label={i18n.str`ID`} label={i18n.str`ID`}
tooltip={i18n.str`product identification to use in URLs (for internal use only)`} tooltip={i18n.str`product identification to use in URLs (for internal use only)`}
/> />

View File

@ -21,7 +21,7 @@
import * as tests from "@gnu-taler/web-util/testing"; import * as tests from "@gnu-taler/web-util/testing";
import { ComponentChildren, h, VNode } from "preact"; import { ComponentChildren, h, VNode } from "preact";
import { MerchantBackend } from "../declaration.js"; import { AccessToken, MerchantBackend } from "../declaration.js";
import { import {
useAdminAPI, useAdminAPI,
useInstanceAPI, useInstanceAPI,
@ -64,7 +64,7 @@ describe("backend context api ", () => {
} as MerchantBackend.Instances.QueryInstancesResponse, } as MerchantBackend.Instances.QueryInstancesResponse,
}); });
management.setNewToken("another_token"); management.setNewToken("another_token" as AccessToken);
}, },
({ instance, management, admin }) => { ({ instance, management, admin }) => {
expect(env.assertJustExpectedRequestWereMade()).deep.eq({ expect(env.assertJustExpectedRequestWereMade()).deep.eq({
@ -113,7 +113,7 @@ describe("backend context api ", () => {
name: "instance_name", name: "instance_name",
} as MerchantBackend.Instances.QueryInstancesResponse, } as MerchantBackend.Instances.QueryInstancesResponse,
}); });
instance.setNewToken("another_token"); instance.setNewToken("another_token" as AccessToken);
}, },
({ instance, management, admin }) => { ({ instance, management, admin }) => {
expect(env.assertJustExpectedRequestWereMade()).deep.eq({ expect(env.assertJustExpectedRequestWereMade()).deep.eq({

View File

@ -20,90 +20,46 @@
*/ */
import { createContext, h, VNode } from "preact"; import { createContext, h, VNode } from "preact";
import { useCallback, useContext, useState } from "preact/hooks"; import { useContext } from "preact/hooks";
import { LoginToken } from "../declaration.js";
import { useBackendDefaultToken, useBackendURL } from "../hooks/index.js"; import { useBackendDefaultToken, useBackendURL } from "../hooks/index.js";
interface BackendContextType { interface BackendContextType {
url: string; url: string,
token?: string; token?: LoginToken;
triedToLog: boolean; updateToken: (token: LoginToken | undefined) => void;
resetBackend: () => void; changeBackend: (url: string) => void;
// clearAllTokens: () => void;
// addTokenCleaner: (c: () => void) => void;
updateLoginStatus: (url: string, token?: string) => void;
updateToken: (token?: string) => void;
} }
const BackendContext = createContext<BackendContextType>({ const BackendContext = createContext<BackendContextType>({
url: "", url: "",
token: undefined, token: undefined,
triedToLog: false,
resetBackend: () => null,
// clearAllTokens: () => null,
// addTokenCleaner: () => null,
updateLoginStatus: () => null,
updateToken: () => null, updateToken: () => null,
changeBackend: () => null,
}); });
function useBackendContextState( function useBackendContextState(
defaultUrl?: string, defaultUrl?: string,
initialToken?: string,
): BackendContextType { ): BackendContextType {
const [url, triedToLog, changeBackend, resetBackend] = const [url, changeBackend] = useBackendURL(defaultUrl);
useBackendURL(defaultUrl); const [token, updateToken] = useBackendDefaultToken();
const [token, _updateToken] = useBackendDefaultToken(initialToken);
const updateToken = (t?: string) => {
_updateToken(t);
};
// const tokenCleaner = useCallback(() => {
// updateToken(undefined);
// }, []);
// const [cleaners, setCleaners] = useState([tokenCleaner]);
// const addTokenCleaner = (c: () => void) => setCleaners((cs) => [...cs, c]);
// const addTokenCleanerMemo = useCallback(
// (c: () => void) => {
// addTokenCleaner(c);
// },
// [tokenCleaner],
// );
// const clearAllTokens = () => {
// cleaners.forEach((c) => c());
// for (let i = 0; i < localStorage.length; i++) {
// const k = localStorage.key(i);
// if (k && /^backend-token/.test(k)) localStorage.removeItem(k);
// }
// resetBackend();
// };
const updateLoginStatus = (url: string, token?: string) => {
changeBackend(url);
updateToken(token);
};
return { return {
url, url,
token, token,
triedToLog,
updateLoginStatus,
resetBackend,
// clearAllTokens,
updateToken, updateToken,
// addTokenCleaner: addTokenCleanerMemo, changeBackend
}; };
} }
export const BackendContextProvider = ({ export const BackendContextProvider = ({
children, children,
defaultUrl, defaultUrl,
initialToken,
}: { }: {
children: any; children: any;
defaultUrl?: string; defaultUrl?: string;
initialToken?: string;
}): VNode => { }): VNode => {
const value = useBackendContextState(defaultUrl, initialToken); const value = useBackendContextState(defaultUrl);
return h(BackendContext.Provider, { value, children }); return h(BackendContext.Provider, { value, children });
}; };

View File

@ -21,12 +21,13 @@
import { createContext } from "preact"; import { createContext } from "preact";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { LoginToken } from "../declaration.js";
interface Type { interface Type {
id: string; id: string;
token?: string; token?: LoginToken;
admin?: boolean; admin?: boolean;
changeToken: (t?: string) => void; changeToken: (t?: LoginToken) => void;
} }
const Context = createContext<Type>({} as any); const Context = createContext<Type>({} as any);

View File

@ -107,6 +107,16 @@ interface RegexAccountRestriction {
// human hints. // human hints.
human_hint_i18n?: { [lang_tag: string]: string }; human_hint_i18n?: { [lang_tag: string]: string };
} }
interface LoginToken {
token: string,
expiration: Timestamp,
}
// token used to get loginToken
// must forget after used
declare const __ac_token: unique symbol;
type AccessToken = string & {
[__ac_token]: true;
};
export namespace ExchangeBackend { export namespace ExchangeBackend {
interface WireResponse { interface WireResponse {
@ -491,6 +501,35 @@ export namespace MerchantBackend {
}; };
} }
// DELETE /private/instances/$INSTANCE // DELETE /private/instances/$INSTANCE
interface LoginTokenRequest {
// Scope of the token (which kinds of operations it will allow)
scope: "readonly" | "write";
// Server may impose its own upper bound
// on the token validity duration
duration?: RelativeTime;
// Can this token be refreshed?
// Defaults to false.
refreshable?: boolean;
}
interface LoginTokenSuccessResponse {
// The login token that can be used to access resources
// that are in scope for some time. Must be prefixed
// with "Bearer " when used in the "Authorization" HTTP header.
// Will already begin with the RFC 8959 prefix.
token: string;
// Scope of the token (which kinds of operations it will allow)
scope: "readonly" | "write";
// Server may impose its own upper bound
// on the token validity duration
expiration: Timestamp;
// Can this token be refreshed?
refreshable: boolean;
}
} }
namespace KYC { namespace KYC {

View File

@ -19,19 +19,21 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { useSWRConfig } from "swr"; import { AbsoluteTime, HttpStatusCode } from "@gnu-taler/taler-util";
import { MerchantBackend } from "../declaration.js";
import { useBackendContext } from "../context/backend.js";
import { useCallback, useEffect, useState } from "preact/hooks";
import { useInstanceContext } from "../context/instance.js";
import { import {
ErrorType, ErrorType,
HttpError,
HttpResponse, HttpResponse,
HttpResponseOk, HttpResponseOk,
RequestError, RequestError,
RequestOptions, RequestOptions,
useApiContext,
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { useApiContext } from "@gnu-taler/web-util/browser"; import { useCallback, useEffect, useState } from "preact/hooks";
import { useSWRConfig } from "swr";
import { useBackendContext } from "../context/backend.js";
import { useInstanceContext } from "../context/instance.js";
import { AccessToken, LoginToken, MerchantBackend, Timestamp } from "../declaration.js";
export function useMatchMutate(): ( export function useMatchMutate(): (
@ -85,6 +87,9 @@ export function useBackendInstancesTestForAdmin(): HttpResponse<
return result; return result;
} }
const CHECK_CONFIG_INTERVAL_OK = 5 * 60 * 1000;
const CHECK_CONFIG_INTERVAL_FAIL = 2 * 1000;
export function useBackendConfig(): HttpResponse< export function useBackendConfig(): HttpResponse<
MerchantBackend.VersionResponse, MerchantBackend.VersionResponse,
RequestError<MerchantBackend.ErrorDetail> RequestError<MerchantBackend.ErrorDetail>
@ -92,18 +97,33 @@ export function useBackendConfig(): HttpResponse<
const { request } = useBackendBaseRequest(); const { request } = useBackendBaseRequest();
type Type = MerchantBackend.VersionResponse; type Type = MerchantBackend.VersionResponse;
type State = { data: HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>>, timer: number }
const [result, setResult] = useState< const [result, setResult] = useState<State>({ data: { loading: true }, timer: 0 });
HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>>
>({ loading: true });
useEffect(() => { useEffect(() => {
if (result.timer) {
clearTimeout(result.timer)
}
function tryConfig(): void {
request<Type>(`/config`) request<Type>(`/config`)
.then((data) => setResult(data)) .then((data) => {
.catch((error) => setResult(error)); const timer: any = setTimeout(() => {
tryConfig()
}, CHECK_CONFIG_INTERVAL_OK)
setResult({ data, timer })
})
.catch((error) => {
const timer: any = setTimeout(() => {
tryConfig()
}, CHECK_CONFIG_INTERVAL_FAIL)
const data = error.cause
setResult({ data, timer })
});
}
tryConfig()
}, [request]); }, [request]);
return result; return result.data;
} }
interface useBackendInstanceRequestType { interface useBackendInstanceRequestType {
@ -149,32 +169,86 @@ interface useBackendBaseRequestType {
} }
type YesOrNo = "yes" | "no"; type YesOrNo = "yes" | "no";
type LoginResult = {
valid: true;
token: string;
expiration: Timestamp;
} | {
valid: false;
cause: HttpError<{}>;
}
export function useCredentialsChecker() { export function useCredentialsChecker() {
const { request } = useApiContext(); const { request } = useApiContext();
//check against instance details endpoint //check against instance details endpoint
//while merchant backend doesn't have a login endpoint //while merchant backend doesn't have a login endpoint
async function testLogin( async function requestNewLoginToken(
instance: string, baseUrl: string,
token: string, token: AccessToken,
): Promise<{ ): Promise<LoginResult> {
valid: boolean; const data: MerchantBackend.Instances.LoginTokenRequest = {
cause?: ErrorType; scope: "write",
}> { duration: {
d_us: "forever"
},
refreshable: true,
}
try { try {
const response = await request(instance, `/private/`, { const response = await request<MerchantBackend.Instances.LoginTokenSuccessResponse>(baseUrl, `/private/token`, {
method: "POST",
token, token,
data
}); });
return { valid: true }; return { valid: true, token: response.data.token, expiration: response.data.expiration };
} catch (error) { } catch (error) {
if (error instanceof RequestError) { if (error instanceof RequestError) {
return { valid: false, cause: error.cause.type }; return { valid: false, cause: error.cause };
} }
return { valid: false, cause: ErrorType.UNEXPECTED }; return {
valid: false, cause: {
type: ErrorType.UNEXPECTED,
loading: false,
info: {
hasToken: true,
status: 0,
options: {},
url: `/private/token`,
payload: {}
},
exception: error,
message: (error instanceof Error ? error.message : "unpexepected error")
} }
}; };
return testLogin }
};
async function refreshLoginToken(
baseUrl: string,
token: LoginToken
): Promise<LoginResult> {
if (AbsoluteTime.isExpired(AbsoluteTime.fromProtocolTimestamp(token.expiration))) {
return {
valid: false, cause: {
type: ErrorType.CLIENT,
status: HttpStatusCode.Unauthorized,
message: "login token expired, login again.",
info: {
hasToken: true,
status: 401,
options: {},
url: `/private/token`,
payload: {}
},
payload: {}
},
}
}
return requestNewLoginToken(baseUrl, token.token as AccessToken)
}
return { requestNewLoginToken, refreshLoginToken }
} }
/** /**
@ -183,15 +257,20 @@ export function useCredentialsChecker() {
* @returns request handler to * @returns request handler to
*/ */
export function useBackendBaseRequest(): useBackendBaseRequestType { export function useBackendBaseRequest(): useBackendBaseRequestType {
const { url: backend, token } = useBackendContext(); const { url: backend, token: loginToken } = useBackendContext();
const { request: requestHandler } = useApiContext(); const { request: requestHandler } = useApiContext();
const token = loginToken?.token;
const request = useCallback( const request = useCallback(
function requestImpl<T>( function requestImpl<T>(
endpoint: string, endpoint: string,
options: RequestOptions = {}, options: RequestOptions = {},
): Promise<HttpResponseOk<T>> { ): Promise<HttpResponseOk<T>> {
return requestHandler<T>(backend, endpoint, { token, ...options }); return requestHandler<T>(backend, endpoint, { token, ...options }).then(res => {
return res
}).catch(err => {
throw err
});
}, },
[backend, token], [backend, token],
); );
@ -204,10 +283,12 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType {
const { token: instanceToken, id, admin } = useInstanceContext(); const { token: instanceToken, id, admin } = useInstanceContext();
const { request: requestHandler } = useApiContext(); const { request: requestHandler } = useApiContext();
const { baseUrl, token } = !admin const { baseUrl, token: loginToken } = !admin
? { baseUrl: rootBackendUrl, token: rootToken } ? { baseUrl: rootBackendUrl, token: rootToken }
: { baseUrl: `${rootBackendUrl}/instances/${id}`, token: instanceToken }; : { baseUrl: `${rootBackendUrl}/instances/${id}`, token: instanceToken };
const token = loginToken?.token;
const request = useCallback( const request = useCallback(
function requestImpl<T>( function requestImpl<T>(
endpoint: string, endpoint: string,

View File

@ -19,9 +19,11 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { StateUpdater, useCallback, useEffect, useState } from "preact/hooks"; import { buildCodecForObject, codecForMap, codecForString, codecForTimestamp } from "@gnu-taler/taler-util";
import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser";
import { StateUpdater, useEffect, useState } from "preact/hooks";
import { LoginToken } from "../declaration.js";
import { ValueOrFunction } from "../utils/types.js"; import { ValueOrFunction } from "../utils/types.js";
import { useMemoryStorage } from "@gnu-taler/web-util/browser";
import { useMatchMutate } from "./backend.js"; import { useMatchMutate } from "./backend.js";
const calculateRootPath = () => { const calculateRootPath = () => {
@ -32,53 +34,55 @@ const calculateRootPath = () => {
return rootPath; return rootPath;
}; };
const loginTokenCodec = buildCodecForObject<LoginToken>()
.property("token", codecForString())
.property("expiration", codecForTimestamp)
.build("loginToken")
const TOKENS_KEY = buildStorageKey("backend-token", codecForMap(loginTokenCodec));
export function useBackendURL( export function useBackendURL(
url?: string, url?: string,
): [string, boolean, StateUpdater<string>, () => void] { ): [string, StateUpdater<string>] {
const [value, setter] = useNotNullLocalStorage( const [value, setter] = useSimpleLocalStorage(
"backend-url", "backend-url",
url || calculateRootPath(), url || calculateRootPath(),
); );
const [triedToLog, setTriedToLog] = useLocalStorage("tried-login");
const checkedSetter = (v: ValueOrFunction<string>) => { const checkedSetter = (v: ValueOrFunction<string>) => {
setTriedToLog("yes"); return setter((p) => (v instanceof Function ? v(p ?? "") : v).replace(/\/$/, ""));
return setter((p) => (v instanceof Function ? v(p) : v).replace(/\/$/, ""));
}; };
const resetBackend = () => { return [value!, checkedSetter];
setTriedToLog(undefined);
};
return [value, !!triedToLog, checkedSetter, resetBackend];
} }
export function useBackendDefaultToken( export function useBackendDefaultToken(
initialValue?: string, ): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] {
): [string | undefined, ((d: string | undefined) => void)] { const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {})
// uncomment for testing
initialValue = "secret-token:secret" as string | undefined const tokenOfDefaultInstance = tokenMap["default"]
const { update: setToken, value: token, reset } = useMemoryStorage(`backend-token`, initialValue)
const clearCache = useMatchMutate() const clearCache = useMatchMutate()
useEffect(() => { useEffect(() => {
clearCache() clearCache()
}, [token]) }, [tokenOfDefaultInstance])
function updateToken( function updateToken(
value: (string | undefined) value: (LoginToken | undefined)
): void { ): void {
if (value === undefined) { if (value === undefined) {
reset() reset()
} else { } else {
setToken(value) const res = { ...tokenMap, "default": value }
setToken(res)
} }
} }
return [token, updateToken]; return [tokenMap["default"], updateToken];
} }
export function useBackendInstanceToken( export function useBackendInstanceToken(
id: string, id: string,
): [string | undefined, ((d: string | undefined) => void)] { ): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] {
const { update: setToken, value: token, reset } = useMemoryStorage(`backend-token-${id}`) const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {})
const [defaultToken, defaultSetToken] = useBackendDefaultToken(); const [defaultToken, defaultSetToken] = useBackendDefaultToken();
// instance named 'default' use the default token // instance named 'default' use the default token
@ -86,16 +90,17 @@ export function useBackendInstanceToken(
return [defaultToken, defaultSetToken]; return [defaultToken, defaultSetToken];
} }
function updateToken( function updateToken(
value: (string | undefined) value: (LoginToken | undefined)
): void { ): void {
if (value === undefined) { if (value === undefined) {
reset() reset()
} else { } else {
setToken(value) const res = { ...tokenMap, [id]: value }
setToken(res)
} }
} }
return [token, updateToken]; return [tokenMap[id], updateToken];
} }
export function useLang(initial?: string): [string, StateUpdater<string>] { export function useLang(initial?: string): [string, StateUpdater<string>] {
@ -104,10 +109,10 @@ export function useLang(initial?: string): [string, StateUpdater<string>] {
? navigator.language || (navigator as any).userLanguage ? navigator.language || (navigator as any).userLanguage
: undefined; : undefined;
const defaultLang = (browserLang || initial || "en").substring(0, 2); const defaultLang = (browserLang || initial || "en").substring(0, 2);
return useNotNullLocalStorage("lang-preference", defaultLang); return useSimpleLocalStorage("lang-preference", defaultLang) as [string, StateUpdater<string>];
} }
export function useLocalStorage( export function useSimpleLocalStorage(
key: string, key: string,
initialValue?: string, initialValue?: string,
): [string | undefined, StateUpdater<string | undefined>] { ): [string | undefined, StateUpdater<string | undefined>] {
@ -137,28 +142,3 @@ export function useLocalStorage(
return [storedValue, setValue]; return [storedValue, setValue];
} }
export function useNotNullLocalStorage(
key: string,
initialValue: string,
): [string, StateUpdater<string>] {
const [storedValue, setStoredValue] = useState<string>((): string => {
return typeof window !== "undefined"
? window.localStorage.getItem(key) || initialValue
: initialValue;
});
const setValue = (value: string | ((val: string) => string)) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
if (typeof window !== "undefined") {
if (!valueToStore) {
window.localStorage.removeItem(key);
} else {
window.localStorage.setItem(key, valueToStore);
}
}
};
return [storedValue, setValue];
}

View File

@ -21,7 +21,7 @@
import * as tests from "@gnu-taler/web-util/testing"; import * as tests from "@gnu-taler/web-util/testing";
import { expect } from "chai"; import { expect } from "chai";
import { MerchantBackend } from "../declaration.js"; import { AccessToken, MerchantBackend } from "../declaration.js";
import { import {
useAdminAPI, useAdminAPI,
useBackendInstances, useBackendInstances,
@ -158,7 +158,7 @@ describe("instance api interaction with details", () => {
}, },
} as MerchantBackend.Instances.QueryInstancesResponse, } as MerchantBackend.Instances.QueryInstancesResponse,
}); });
api.setNewToken("secret"); api.setNewToken("secret" as AccessToken);
}, },
({ query, api }) => { ({ query, api }) => {
expect(env.assertJustExpectedRequestWereMade()).deep.eq({ expect(env.assertJustExpectedRequestWereMade()).deep.eq({

View File

@ -19,10 +19,11 @@ import {
RequestError, RequestError,
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { useBackendContext } from "../context/backend.js"; import { useBackendContext } from "../context/backend.js";
import { MerchantBackend } from "../declaration.js"; import { AccessToken, MerchantBackend } from "../declaration.js";
import { import {
useBackendBaseRequest, useBackendBaseRequest,
useBackendInstanceRequest, useBackendInstanceRequest,
useCredentialsChecker,
useMatchMutate, useMatchMutate,
} from "./backend.js"; } from "./backend.js";
@ -36,7 +37,7 @@ interface InstanceAPI {
) => Promise<void>; ) => Promise<void>;
deleteInstance: () => Promise<void>; deleteInstance: () => Promise<void>;
clearToken: () => Promise<void>; clearToken: () => Promise<void>;
setNewToken: (token: string) => Promise<void>; setNewToken: (token: AccessToken) => Promise<void>;
} }
export function useAdminAPI(): AdminAPI { export function useAdminAPI(): AdminAPI {
@ -86,8 +87,10 @@ export interface AdminAPI {
export function useManagementAPI(instanceId: string): InstanceAPI { export function useManagementAPI(instanceId: string): InstanceAPI {
const mutateAll = useMatchMutate(); const mutateAll = useMatchMutate();
const { url: backendURL } = useBackendContext()
const { updateToken } = useBackendContext(); const { updateToken } = useBackendContext();
const { request } = useBackendBaseRequest(); const { request } = useBackendBaseRequest();
const { requestNewLoginToken } = useCredentialsChecker()
const updateInstance = async ( const updateInstance = async (
instance: MerchantBackend.Instances.InstanceReconfigurationMessage, instance: MerchantBackend.Instances.InstanceReconfigurationMessage,
@ -117,13 +120,20 @@ export function useManagementAPI(instanceId: string): InstanceAPI {
mutateAll(/\/management\/instances/); mutateAll(/\/management\/instances/);
}; };
const setNewToken = async (newToken: string): Promise<void> => { const setNewToken = async (newToken: AccessToken): Promise<void> => {
await request(`/management/instances/${instanceId}/auth`, { await request(`/management/instances/${instanceId}/auth`, {
method: "POST", method: "POST",
data: { method: "token", token: newToken }, data: { method: "token", token: newToken },
}); });
updateToken(newToken); const resp = await requestNewLoginToken(backendURL, newToken)
if (resp.valid) {
const { token, expiration } = resp
updateToken({ token, expiration });
} else {
updateToken(undefined)
}
mutateAll(/\/management\/instances/); mutateAll(/\/management\/instances/);
}; };
@ -132,12 +142,13 @@ export function useManagementAPI(instanceId: string): InstanceAPI {
export function useInstanceAPI(): InstanceAPI { export function useInstanceAPI(): InstanceAPI {
const { mutate } = useSWRConfig(); const { mutate } = useSWRConfig();
const { url: backendURL, updateToken } = useBackendContext()
const { const {
url: baseUrl,
token: adminToken, token: adminToken,
updateLoginStatus,
} = useBackendContext(); } = useBackendContext();
const { request } = useBackendInstanceRequest(); const { request } = useBackendInstanceRequest();
const { requestNewLoginToken } = useCredentialsChecker()
const updateInstance = async ( const updateInstance = async (
instance: MerchantBackend.Instances.InstanceReconfigurationMessage, instance: MerchantBackend.Instances.InstanceReconfigurationMessage,
@ -147,7 +158,7 @@ export function useInstanceAPI(): InstanceAPI {
data: instance, data: instance,
}); });
if (adminToken) mutate(["/private/instances", adminToken, baseUrl], null); if (adminToken) mutate(["/private/instances", adminToken, backendURL], null);
mutate([`/private/`], null); mutate([`/private/`], null);
}; };
@ -157,7 +168,7 @@ export function useInstanceAPI(): InstanceAPI {
// token: adminToken, // token: adminToken,
}); });
if (adminToken) mutate(["/private/instances", adminToken, baseUrl], null); if (adminToken) mutate(["/private/instances", adminToken, backendURL], null);
mutate([`/private/`], null); mutate([`/private/`], null);
}; };
@ -170,13 +181,20 @@ export function useInstanceAPI(): InstanceAPI {
mutate([`/private/`], null); mutate([`/private/`], null);
}; };
const setNewToken = async (newToken: string): Promise<void> => { const setNewToken = async (newToken: AccessToken): Promise<void> => {
await request(`/private/auth`, { await request(`/private/auth`, {
method: "POST", method: "POST",
data: { method: "token", token: newToken }, data: { method: "token", token: newToken },
}); });
updateLoginStatus(baseUrl, newToken); const resp = await requestNewLoginToken(backendURL, newToken)
if (resp.valid) {
const { token, expiration } = resp
updateToken({ token, expiration });
} else {
updateToken(undefined)
}
mutate([`/private/`], null); mutate([`/private/`], null);
}; };

View File

@ -90,10 +90,7 @@ export class ApiMockEnvironment extends MockEnvironment {
const SC: any = SWRConfig; const SC: any = SWRConfig;
return ( return (
<BackendContextProvider <BackendContextProvider defaultUrl="http://backend">
defaultUrl="http://backend"
initialToken={undefined}
>
<InstanceContextProvider <InstanceContextProvider
value={{ value={{
token: undefined, token: undefined,

View File

@ -24,15 +24,6 @@ import {
codecForString, codecForString,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
function parse_json_or_undefined<T>(str: string | undefined): T | undefined {
if (str === undefined) return undefined;
try {
return JSON.parse(str);
} catch {
return undefined;
}
}
export interface Settings { export interface Settings {
advanceOrderMode: boolean; advanceOrderMode: boolean;
dateFormat: "ymd" | "dmy" | "mdy"; dateFormat: "ymd" | "dmy" | "mdy";

View File

@ -22,7 +22,7 @@
import { AmountJson, Amounts, stringifyRefundUri } from "@gnu-taler/taler-util"; import { AmountJson, Amounts, stringifyRefundUri } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { format, formatDistance } from "date-fns"; import { format, formatDistance } from "date-fns";
import { Fragment, h, VNode } from "preact"; import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { FormProvider } from "../../../../components/form/FormProvider.js"; import { FormProvider } from "../../../../components/form/FormProvider.js";
import { Input } from "../../../../components/form/Input.js"; import { Input } from "../../../../components/form/Input.js";
@ -35,10 +35,10 @@ import { TextField } from "../../../../components/form/TextField.js";
import { ProductList } from "../../../../components/product/ProductList.js"; import { ProductList } from "../../../../components/product/ProductList.js";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
import { mergeRefunds } from "../../../../utils/amount.js"; import { mergeRefunds } from "../../../../utils/amount.js";
import { RefundModal } from "../list/Table.js"; import { RefundModal } from "../list/Table.js";
import { Event, Timeline } from "./Timeline.js"; import { Event, Timeline } from "./Timeline.js";
import { dateFormatForSettings, datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
type Entity = MerchantBackend.Orders.MerchantOrderStatusResponse; type Entity = MerchantBackend.Orders.MerchantOrderStatusResponse;
type CT = MerchantBackend.ContractTerms; type CT = MerchantBackend.ContractTerms;
@ -416,9 +416,9 @@ function PaidPage({
}) })
const [value, valueHandler] = useState<Partial<Paid>>(order); const [value, valueHandler] = useState<Partial<Paid>>(order);
const { url } = useBackendContext(); const { url: backendURL } = useBackendContext()
const refundurl = stringifyRefundUri({ const refundurl = stringifyRefundUri({
merchantBaseUrl: url, merchantBaseUrl: backendURL,
orderId: order.contract_terms.order_id orderId: order.contract_terms.order_id
}) })
const refundable = const refundable =

View File

@ -13,12 +13,12 @@
You should have received a copy of the GNU General Public License along with 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/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { stringifyRewardUri } from "@gnu-taler/taler-util";
import { format } from "date-fns"; import { format } from "date-fns";
import { Fragment, h, VNode } from "preact"; import { Fragment, h, VNode } from "preact";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js"; import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
import { stringifyRewardUri } from "@gnu-taler/taler-util";
type Entity = MerchantBackend.Rewards.RewardDetails; type Entity = MerchantBackend.Rewards.RewardDetails;
@ -29,9 +29,9 @@ interface Props {
} }
export function RewardInfo({ id: merchantRewardId, amount, entity }: Props): VNode { export function RewardInfo({ id: merchantRewardId, amount, entity }: Props): VNode {
const { url: merchantBaseUrl } = useBackendContext(); const { url: backendURL } = useBackendContext()
const [settings] = useSettings(); const [settings] = useSettings();
const rewardURL = stringifyRewardUri({ merchantBaseUrl, merchantRewardId }) const rewardURL = stringifyRewardUri({ merchantBaseUrl: backendURL, merchantRewardId })
return ( return (
<Fragment> <Fragment>
<div class="field is-horizontal"> <div class="field is-horizontal">

View File

@ -35,16 +35,12 @@ import { Input } from "../../../../components/form/Input.js";
import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js";
import { InputDuration } from "../../../../components/form/InputDuration.js"; import { InputDuration } from "../../../../components/form/InputDuration.js";
import { InputNumber } from "../../../../components/form/InputNumber.js"; import { InputNumber } from "../../../../components/form/InputNumber.js";
import { InputSearchOnList } from "../../../../components/form/InputSearchOnList.js";
import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { useInstanceContext } from "../../../../context/instance.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import {
isBase32RFC3548Charset
} from "../../../../utils/crypto.js";
import { undefinedIfEmpty } from "../../../../utils/table.js";
import { InputSearchOnList } from "../../../../components/form/InputSearchOnList.js";
import { useInstanceOtpDevices } from "../../../../hooks/otp.js"; import { useInstanceOtpDevices } from "../../../../hooks/otp.js";
import { undefinedIfEmpty } from "../../../../utils/table.js";
type Entity = MerchantBackend.Template.TemplateAddDetails; type Entity = MerchantBackend.Template.TemplateAddDetails;
@ -55,7 +51,7 @@ interface Props {
export function CreatePage({ onCreate, onBack }: Props): VNode { export function CreatePage({ onCreate, onBack }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext(); const { url: backendURL } = useBackendContext()
const devices = useInstanceOtpDevices() const devices = useInstanceOtpDevices()
const [state, setState] = useState<Partial<Entity>>({ const [state, setState] = useState<Partial<Entity>>({
@ -128,7 +124,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
> >
<InputWithAddon<Entity> <InputWithAddon<Entity>
name="template_id" name="template_id"
help={`${backend.url}/templates/${state.template_id ?? ""}`} help={`${backendURL}/templates/${state.template_id ?? ""}`}
label={i18n.str`Identifier`} label={i18n.str`Identifier`}
tooltip={i18n.str`Name of the template in URLs.`} tooltip={i18n.str`Name of the template in URLs.`}
/> />

View File

@ -19,8 +19,9 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { HttpError, useTranslationContext } from "@gnu-taler/web-util/browser"; import { stringifyPayTemplateUri } from "@gnu-taler/taler-util";
import { h, VNode } from "preact"; import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { QR } from "../../../../components/exception/QR.js"; import { QR } from "../../../../components/exception/QR.js";
import { import {
@ -29,14 +30,10 @@ import {
} from "../../../../components/form/FormProvider.js"; } from "../../../../components/form/FormProvider.js";
import { Input } from "../../../../components/form/Input.js"; import { Input } from "../../../../components/form/Input.js";
import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js";
import { ConfirmModal } from "../../../../components/modal/index.js";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { useConfigContext } from "../../../../context/config.js"; import { useConfigContext } from "../../../../context/config.js";
import { useInstanceContext } from "../../../../context/instance.js"; import { useInstanceContext } from "../../../../context/instance.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { stringifyPayTemplateUri } from "@gnu-taler/taler-util";
import { useOtpDeviceDetails } from "../../../../hooks/otp.js";
import { Loading } from "../../../../components/exception/loading.js";
type Entity = MerchantBackend.Template.UsingTemplateDetails; type Entity = MerchantBackend.Template.UsingTemplateDetails;
@ -48,7 +45,7 @@ interface Props {
export function QrPage({ contract, id: templateId, onBack }: Props): VNode { export function QrPage({ contract, id: templateId, onBack }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const { url: backendUrl } = useBackendContext(); const { url: backendURL } = useBackendContext()
const { id: instanceId } = useInstanceContext(); const { id: instanceId } = useInstanceContext();
const config = useConfigContext(); const config = useConfigContext();
@ -75,7 +72,7 @@ export function QrPage({ contract, id: templateId, onBack }: Props): VNode {
templateParams.summary = state.summary ?? "" templateParams.summary = state.summary ?? ""
} }
const merchantBaseUrl = new URL(backendUrl).href; const merchantBaseUrl = new URL(backendURL).href;
const payTemplateUri = stringifyPayTemplateUri({ const payTemplateUri = stringifyPayTemplateUri({
merchantBaseUrl, merchantBaseUrl,
@ -84,7 +81,7 @@ export function QrPage({ contract, id: templateId, onBack }: Props): VNode {
}) })
const issuer = encodeURIComponent( const issuer = encodeURIComponent(
`${new URL(backendUrl).host}/${instanceId}`, `${new URL(backendURL).host}/${instanceId}`,
); );
return ( return (

View File

@ -24,7 +24,7 @@ import {
MerchantTemplateContractDetails, MerchantTemplateContractDetails,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact"; import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { AsyncButton } from "../../../../components/exception/AsyncButton.js"; import { AsyncButton } from "../../../../components/exception/AsyncButton.js";
import { import {
@ -35,17 +35,10 @@ import { Input } from "../../../../components/form/Input.js";
import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js";
import { InputDuration } from "../../../../components/form/InputDuration.js"; import { InputDuration } from "../../../../components/form/InputDuration.js";
import { InputNumber } from "../../../../components/form/InputNumber.js"; import { InputNumber } from "../../../../components/form/InputNumber.js";
import { InputSelector } from "../../../../components/form/InputSelector.js";
import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
import { useBackendContext } from "../../../../context/backend.js"; import { useBackendContext } from "../../../../context/backend.js";
import { MerchantBackend, WithId } from "../../../../declaration.js"; import { MerchantBackend, WithId } from "../../../../declaration.js";
import {
isBase32RFC3548Charset,
randomBase32Key,
} from "../../../../utils/crypto.js";
import { undefinedIfEmpty } from "../../../../utils/table.js"; import { undefinedIfEmpty } from "../../../../utils/table.js";
import { QR } from "../../../../components/exception/QR.js";
import { useInstanceContext } from "../../../../context/instance.js";
type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId; type Entity = MerchantBackend.Template.TemplatePatchDetails & WithId;
@ -55,12 +48,9 @@ interface Props {
template: Entity; template: Entity;
} }
const algorithms = [0, 1, 2];
const algorithmsNames = ["off", "30s 8d TOTP-SHA1", "30s 8d eTOTP-SHA1"];
export function UpdatePage({ template, onUpdate, onBack }: Props): VNode { export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext(); const { url: backendURL } = useBackendContext()
const [state, setState] = useState<Partial<Entity>>(template); const [state, setState] = useState<Partial<Entity>>(template);
@ -115,7 +105,7 @@ export function UpdatePage({ template, onUpdate, onBack }: Props): VNode {
<div class="level-left"> <div class="level-left">
<div class="level-item"> <div class="level-item">
<span class="is-size-4"> <span class="is-size-4">
{backend.url}/templates/{template.id} {backendURL}/templates/{template.id}
</span> </span>
</div> </div>
</div> </div>

View File

@ -26,12 +26,13 @@ import { AsyncButton } from "../../../components/exception/AsyncButton.js";
import { FormProvider } from "../../../components/form/FormProvider.js"; import { FormProvider } from "../../../components/form/FormProvider.js";
import { Input } from "../../../components/form/Input.js"; import { Input } from "../../../components/form/Input.js";
import { useInstanceContext } from "../../../context/instance.js"; import { useInstanceContext } from "../../../context/instance.js";
import { AccessToken } from "../../../declaration.js";
interface Props { interface Props {
instanceId: string; instanceId: string;
currentToken: string | undefined; currentToken: string | undefined;
onClearToken: () => void; onClearToken: () => void;
onNewToken: (s: string) => void; onNewToken: (s: AccessToken) => void;
onBack?: () => void; onBack?: () => void;
} }
@ -71,7 +72,8 @@ export function DetailPage({ instanceId, currentToken: oldToken, onBack, onNewTo
async function submitForm() { async function submitForm() {
if (hasErrors) return; if (hasErrors) return;
onNewToken(form.new_token as any) const nt = `secret-token:${form.new_token}` as AccessToken;
onNewToken(nt)
} }
return ( return (

View File

@ -17,7 +17,7 @@ import { HttpStatusCode } from "@gnu-taler/taler-util";
import { ErrorType, HttpError, useTranslationContext } from "@gnu-taler/web-util/browser"; import { ErrorType, HttpError, useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact"; import { Fragment, VNode, h } from "preact";
import { Loading } from "../../../components/exception/loading.js"; import { Loading } from "../../../components/exception/loading.js";
import { MerchantBackend } from "../../../declaration.js"; import { AccessToken, MerchantBackend } from "../../../declaration.js";
import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance.js"; import { useInstanceAPI, useInstanceDetails } from "../../../hooks/instance.js";
import { DetailPage } from "./DetailPage.js"; import { DetailPage } from "./DetailPage.js";
import { useInstanceContext } from "../../../context/instance.js"; import { useInstanceContext } from "../../../context/instance.js";
@ -49,13 +49,13 @@ export default function Token({
const { token: instanceToken, id, admin } = useInstanceContext(); const { token: instanceToken, id, admin } = useInstanceContext();
const currentToken = !admin ? rootToken : instanceToken const currentToken = !admin ? rootToken : instanceToken
const hasPrefix = currentToken !== undefined && currentToken.startsWith(PREFIX) const hasPrefix = currentToken !== undefined && currentToken.token.startsWith(PREFIX)
return ( return (
<Fragment> <Fragment>
<NotificationCard notification={notif} /> <NotificationCard notification={notif} />
<DetailPage <DetailPage
instanceId={id} instanceId={id}
currentToken={hasPrefix ? currentToken.substring(PREFIX.length) : currentToken} currentToken={hasPrefix ? currentToken.token.substring(PREFIX.length) : currentToken?.token}
onClearToken={async (): Promise<void> => { onClearToken={async (): Promise<void> => {
try { try {
await clearToken(); await clearToken();
@ -72,7 +72,7 @@ export default function Token({
}} }}
onNewToken={async (newToken): Promise<void> => { onNewToken={async (newToken): Promise<void> => {
try { try {
await setNewToken(`secret-token:${newToken}`); await setNewToken(newToken);
onChange(); onChange();
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {

View File

@ -13,18 +13,19 @@
You should have received a copy of the GNU General Public License along with 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/> GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/ */
import { HttpStatusCode } from "@gnu-taler/taler-util";
import { import {
ErrorType, ErrorType,
HttpError, HttpError,
HttpResponse, HttpResponse,
useTranslationContext, useTranslationContext,
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact"; import { Fragment, VNode, h } from "preact";
import { useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { Loading } from "../../../components/exception/loading.js"; import { Loading } from "../../../components/exception/loading.js";
import { NotificationCard } from "../../../components/menu/index.js"; import { NotificationCard } from "../../../components/menu/index.js";
import { useInstanceContext } from "../../../context/instance.js"; import { useInstanceContext } from "../../../context/instance.js";
import { MerchantBackend } from "../../../declaration.js"; import { AccessToken, MerchantBackend } from "../../../declaration.js";
import { import {
useInstanceAPI, useInstanceAPI,
useInstanceDetails, useInstanceDetails,
@ -33,7 +34,6 @@ import {
} from "../../../hooks/instance.js"; } from "../../../hooks/instance.js";
import { Notification } from "../../../utils/types.js"; import { Notification } from "../../../utils/types.js";
import { UpdatePage } from "./UpdatePage.js"; import { UpdatePage } from "./UpdatePage.js";
import { HttpStatusCode } from "@gnu-taler/taler-util";
export interface Props { export interface Props {
onBack: () => void; onBack: () => void;
@ -73,10 +73,9 @@ function CommonUpdate(
MerchantBackend.ErrorDetail MerchantBackend.ErrorDetail
>, >,
updateInstance: any, updateInstance: any,
clearToken: any, clearToken: () => Promise<void>,
setNewToken: any, setNewToken: (t: AccessToken) => Promise<void>,
): VNode { ): VNode {
const { changeToken } = useInstanceContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined); const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
@ -119,11 +118,8 @@ function CommonUpdate(
d: MerchantBackend.Instances.InstanceAuthConfigurationMessage, d: MerchantBackend.Instances.InstanceAuthConfigurationMessage,
): Promise<void> => { ): Promise<void> => {
const apiCall = const apiCall =
d.method === "external" ? clearToken() : setNewToken(d.token!); d.method === "external" ? clearToken() : setNewToken(d.token! as AccessToken);
return apiCall return apiCall.then(onConfirm).catch(onUpdateError);
.then(() => changeToken(d.token))
.then(onConfirm)
.catch(onUpdateError);
}} }}
/> />
</Fragment> </Fragment>

View File

@ -18,9 +18,9 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact"; import { Fragment, VNode, h } from "preact";
import { QR } from "../../../../components/exception/QR.js"; import { QR } from "../../../../components/exception/QR.js";
import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js"; import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js";
import { useBackendContext } from "../../../../context/backend.js";
import { useInstanceContext } from "../../../../context/instance.js"; import { useInstanceContext } from "../../../../context/instance.js";
import { MerchantBackend } from "../../../../declaration.js"; import { MerchantBackend } from "../../../../declaration.js";
import { useBackendContext } from "../../../../context/backend.js";
type Entity = MerchantBackend.OTP.OtpDeviceAddDetails; type Entity = MerchantBackend.OTP.OtpDeviceAddDetails;
@ -38,9 +38,9 @@ export function CreatedSuccessfully({
onConfirm, onConfirm,
}: Props): VNode { }: Props): VNode {
const { i18n } = useTranslationContext(); const { i18n } = useTranslationContext();
const backend = useBackendContext(); const { url: backendURL } = useBackendContext()
const { id: instanceId } = useInstanceContext(); const { id: instanceId } = useInstanceContext();
const issuer = new URL(backend.url).hostname; const issuer = new URL(backendURL).hostname;
const qrText = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key}`; const qrText = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key}`;
const qrTextSafe = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key.substring(0, 6)}...`; const qrTextSafe = `otpauth://totp/${instanceId}/${entity.otp_device_id}?issuer=${issuer}&algorithm=SHA1&digits=8&period=30&secret=${entity.otp_key.substring(0, 6)}...`;

View File

@ -18,12 +18,301 @@
* *
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { h, VNode } from "preact";
import { LoginModal } from "../../components/exception/login.js"; import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { ComponentChildren, h, VNode } from "preact";
import { useCallback, useEffect, useState } from "preact/hooks";
import { useBackendContext } from "../../context/backend.js";
import { useInstanceContext } from "../../context/instance.js";
import { AccessToken, LoginToken } from "../../declaration.js";
import { useCredentialsChecker } from "../../hooks/backend.js";
import { useBackendURL } from "../../hooks/index.js";
interface Props { interface Props {
onConfirm: (url: string, token?: string) => void; onConfirm: (token: LoginToken | undefined) => void;
} }
export default function LoginPage({ onConfirm }: Props): VNode {
return <LoginModal onConfirm={onConfirm} />; function getTokenValuePart(t: string): string {
if (!t) return t;
const match = /secret-token:(.*)/.exec(t);
if (!match || !match[1]) return "";
return match[1];
}
function normalizeToken(r: string): AccessToken {
return `secret-token:${r}` as AccessToken;
}
function cleanUp(s: string): string {
let result = s;
if (result.indexOf("webui/") !== -1) {
result = result.substring(0, result.indexOf("webui/"));
}
return result;
}
export function LoginPage({ onConfirm }: Props): VNode {
const { url: backendURL, changeBackend } = useBackendContext();
const { admin, id } = useInstanceContext();
const { requestNewLoginToken } = useCredentialsChecker();
const [token, setToken] = useState("");
const { i18n } = useTranslationContext();
const doLogin = useCallback(async function doLoginImpl() {
const secretToken = normalizeToken(token);
const baseUrl = id === undefined ? backendURL : `${backendURL}/instances/${id}`
const result = await requestNewLoginToken(baseUrl, secretToken);
if (result.valid) {
const { token, expiration } = result
onConfirm({ token, expiration });
} else {
onConfirm(undefined);
}
}, [backendURL, id, token])
async function changeServer() {
changeBackend("")
}
console.log(admin, id)
if (admin && id !== "default") {
//admin trying to access another instance
return (<div class="columns is-centered" style={{ margin: "auto" }}>
<div class="column is-two-thirds ">
<div class="modal-card" style={{ width: "100%", margin: 0 }}>
<header
class="modal-card-head"
style={{ border: "1px solid", borderBottom: 0 }}
>
<p class="modal-card-title">{i18n.str`Login required`}</p>
</header>
<section
class="modal-card-body"
style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }}
>
<p>
<i18n.Translate>Need the access token for the instance.</i18n.Translate>
</p>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">
<i18n.Translate>Access Token</i18n.Translate>
</label>
</div>
<div class="field-body">
<div class="field">
<p class="control is-expanded">
<input
class="input"
type="password"
placeholder={"current access token"}
name="token"
onKeyPress={(e) =>
e.keyCode === 13
? doLogin()
: null
}
value={token}
onInput={(e): void => setToken(e?.currentTarget.value)}
/>
</p>
</div>
</div>
</div>
</section>
<footer
class="modal-card-foot "
style={{
justifyContent: "flex-end",
border: "1px solid",
borderTop: 0,
}}
>
<AsyncButton
onClick={doLogin}
>
<i18n.Translate>Confirm</i18n.Translate>
</AsyncButton>
</footer>
</div>
</div>
</div>)
}
return (
<div class="columns is-centered" style={{ margin: "auto" }}>
<div class="column is-two-thirds ">
<div class="modal-card" style={{ width: "100%", margin: 0 }}>
<header
class="modal-card-head"
style={{ border: "1px solid", borderBottom: 0 }}
>
<p class="modal-card-title">{i18n.str`Login required`}</p>
</header>
<section
class="modal-card-body"
style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }}
>
<i18n.Translate>Please enter your access token.</i18n.Translate>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">URL</label>
</div>
<div class="field-body">
<div class="field">
<p class="control is-expanded">
<input
class="input"
type="text"
placeholder="set new url"
name="id"
value={backendURL}
disabled
readOnly
/>
</p>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">
<i18n.Translate>Access Token</i18n.Translate>
</label>
</div>
<div class="field-body">
<div class="field">
<p class="control is-expanded">
<input
class="input"
type="password"
placeholder={"current access token"}
name="token"
onKeyPress={(e) =>
e.keyCode === 13
? doLogin()
: null
}
value={token}
onInput={(e): void => setToken(e?.currentTarget.value)}
/>
</p>
</div>
</div>
</div>
</section>
<footer
class="modal-card-foot "
style={{
justifyContent: "space-between",
border: "1px solid",
borderTop: 0,
}}
>
<AsyncButton
onClick={changeServer}
>
<i18n.Translate>Change server</i18n.Translate>
</AsyncButton>
<AsyncButton
type="is-info"
onClick={doLogin}
>
<i18n.Translate>Confirm</i18n.Translate>
</AsyncButton>
</footer>
</div>
</div>
</div>
);
}
function AsyncButton({ onClick, disabled, type = "", children }: { type?: string, disabled?: boolean, onClick: () => Promise<void>, children: ComponentChildren }): VNode {
const [running, setRunning] = useState(false)
return <button class={"button " + type} disabled={disabled || running} onClick={() => {
setRunning(true)
onClick().then(() => {
setRunning(false)
}).catch(() => {
setRunning(false)
})
}}>
{children}
</button>
}
export function ConnectionPage({ onConfirm }: { onConfirm: (s: string) => void }): VNode {
const { url: backendURL } = useBackendContext()
const [url, setURL] = useState(cleanUp(backendURL));
const { i18n } = useTranslationContext();
async function doConnect() {
onConfirm(url)
}
return (
<div class="columns is-centered" style={{ margin: "auto" }}>
<div class="column is-two-thirds ">
<div class="modal-card" style={{ width: "100%", margin: 0 }}>
<header
class="modal-card-head"
style={{ border: "1px solid", borderBottom: 0 }}
>
<p class="modal-card-title">{i18n.str`Connect to backend`}</p>
</header>
<section
class="modal-card-body"
style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }}
>
<i18n.Translate>Location of the backend server</i18n.Translate>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">URL</label>
</div>
<div class="field-body">
<div class="field">
<p class="control is-expanded">
<input
class="input"
type="text"
placeholder="set new url"
name="id"
value={url ?? ""}
onKeyPress={(e) =>
e.keyCode === 13
? doConnect()
: null
}
onInput={(e): void => setURL(e?.currentTarget.value)}
/>
</p>
</div>
</div>
</div>
</section>
<footer
class="modal-card-foot "
style={{
justifyContent: "flex-end",
border: "1px solid",
borderTop: 0,
}}
>
<AsyncButton
disabled={backendURL === url}
onClick={doConnect}
>
<i18n.Translate>Try again</i18n.Translate>
</AsyncButton>
</footer>
</div>
</div>
</div>
);
} }

View File

@ -13,7 +13,7 @@ function getBrowserLang(): string | undefined {
return undefined; return undefined;
} }
export function Settings(): VNode { export function Settings({ onClose }: { onClose?: () => void }): VNode {
const { i18n } = useTranslationContext() const { i18n } = useTranslationContext()
const borwserLang = getBrowserLang() const borwserLang = getBrowserLang()
const { update } = useLang() const { update } = useLang()
@ -94,11 +94,19 @@ export function Settings(): VNode {
/> />
</FormProvider> </FormProvider>
</div> </div>
</div> </div>
<div class="column" /> <div class="column" />
</div> </div>
</section > </section >
{onClose &&
<section class="section is-main-section">
<button
class="button"
onClick={onClose}
>
<i18n.Translate>Close</i18n.Translate>
</button>
</section>
}
</div > </div >
} }

View File

@ -25,6 +25,8 @@ export enum ErrorType {
UNEXPECTED, UNEXPECTED,
} }
/** /**
* *
* @param baseUrl URL where the service is located * @param baseUrl URL where the service is located
@ -60,10 +62,27 @@ export async function defaultRequestHandler<T>(
const requestPreventCache = options.preventCache ?? false; const requestPreventCache = options.preventCache ?? false;
const requestPreventCors = options.preventCors ?? false; const requestPreventCors = options.preventCors ?? false;
const _url = new URL(`${baseUrl}${endpoint}`); const validURL = validateURL(baseUrl, endpoint);
if (!validURL) {
const error: HttpResponseUnexpectedError = {
info: {
url: `${baseUrl}${endpoint}`,
payload: {},
hasToken: !!options.token,
status: 0,
options,
},
type: ErrorType.UNEXPECTED,
exception: undefined,
loading: false,
message: `invalid URL: "${validURL}"`,
};
throw new RequestError(error)
}
Object.entries(requestParams).forEach(([key, value]) => { Object.entries(requestParams).forEach(([key, value]) => {
_url.searchParams.set(key, String(value)); validURL.searchParams.set(key, String(value));
}); });
let payload: BodyInit | undefined = undefined; let payload: BodyInit | undefined = undefined;
@ -77,7 +96,20 @@ export async function defaultRequestHandler<T>(
} else if (typeof requestBody === "object") { } else if (typeof requestBody === "object") {
payload = JSON.stringify(requestBody); payload = JSON.stringify(requestBody);
} else { } else {
throw Error("unsupported request body type"); const error: HttpResponseUnexpectedError = {
info: {
url: validURL.href,
payload: {},
hasToken: !!options.token,
status: 0,
options,
},
type: ErrorType.UNEXPECTED,
exception: undefined,
loading: false,
message: `unsupported request body type: "${typeof requestBody}"`,
};
throw new RequestError(error)
} }
} }
@ -88,7 +120,7 @@ export async function defaultRequestHandler<T>(
let response; let response;
try { try {
response = await fetch(_url.href, { response = await fetch(validURL.href, {
headers: requestHeaders, headers: requestHeaders,
method: requestMethod, method: requestMethod,
credentials: "omit", credentials: "omit",
@ -100,15 +132,29 @@ export async function defaultRequestHandler<T>(
} catch (ex) { } catch (ex) {
const info: RequestInfo = { const info: RequestInfo = {
payload, payload,
url: _url.href, url: validURL.href,
hasToken: !!options.token, hasToken: !!options.token,
status: 0, status: 0,
options, options,
}; };
if (ex instanceof Error) {
if (ex.message === "HTTP_REQUEST_TIMEOUT") {
const error: HttpRequestTimeoutError = { const error: HttpRequestTimeoutError = {
info, info,
type: ErrorType.TIMEOUT, type: ErrorType.TIMEOUT,
message: "Request timeout", message: "request timeout",
};
throw new RequestError(error);
}
}
const error: HttpResponseUnexpectedError = {
info,
type: ErrorType.UNEXPECTED,
exception: ex,
loading: false,
message: (ex instanceof Error ? ex.message : ""),
}; };
throw new RequestError(error); throw new RequestError(error);
} }
@ -124,7 +170,7 @@ export async function defaultRequestHandler<T>(
if (response.ok) { if (response.ok) {
const result = await buildRequestOk<T>( const result = await buildRequestOk<T>(
response, response,
_url.href, validURL.href,
payload, payload,
!!options.token, !!options.token,
options, options,
@ -133,7 +179,7 @@ export async function defaultRequestHandler<T>(
} else { } else {
const dataTxt = await response.text(); const dataTxt = await response.text();
const error = buildRequestFailed( const error = buildRequestFailed(
_url.href, validURL.href,
dataTxt, dataTxt,
response.status, response.status,
payload, payload,
@ -377,3 +423,12 @@ export function buildRequestFailed<ErrorDetail>(
return error; return error;
} }
} }
function validateURL(baseUrl: string, endpoint: string): URL | undefined {
try {
return new URL(`${baseUrl}${endpoint}`)
} catch (ex) {
return undefined
}
}