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 { useBackendConfig } from "./hooks/backend.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 {
return (
@ -59,25 +60,20 @@ export function Application(): VNode {
* @returns
*/
function ApplicationStatusRoutes(): VNode {
const { url, updateLoginStatus, triedToLog } = useBackendContext();
const { url: backendURL, updateToken, changeBackend } = useBackendContext();
const result = useBackendConfig();
const { i18n } = useTranslationContext();
const updateLoginInfoAndGoToRoot = (url: string, token?: string) => {
updateLoginStatus(url, token);
route("/");
};
const { currency, version } = result.ok
? result.data
: { currency: "unknown", version: "unknown" };
const ctx = useMemo(() => ({ currency, version }), [currency, version]);
if (!triedToLog) {
if (!backendURL) {
return (
<Fragment>
<NotConnectedAppMenu title="Welcome!" />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} />
<ConnectionPage onConfirm={changeBackend} />
</Fragment>
);
}
@ -91,7 +87,7 @@ function ApplicationStatusRoutes(): VNode {
return (
<Fragment>
<NotConnectedAppMenu title="Login" />
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} />
<ConnectionPage onConfirm={changeBackend} />
</Fragment>
);
}
@ -109,7 +105,7 @@ function ApplicationStatusRoutes(): VNode {
description: `Check your url`,
}}
/>
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} />
<ConnectionPage onConfirm={changeBackend} />
</Fragment>
);
}
@ -120,10 +116,10 @@ function ApplicationStatusRoutes(): VNode {
notification={{
message: i18n.str`Server response with an error code`,
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>;
}
if (result.type === ErrorType.UNREADABLE) {
@ -133,10 +129,10 @@ function ApplicationStatusRoutes(): VNode {
notification={{
message: i18n.str`Response from server is unreadable, http status: ${result.status}`,
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>;
}
return (
@ -146,10 +142,10 @@ function ApplicationStatusRoutes(): VNode {
notification={{
message: i18n.str`Unexpected 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>
);
}
@ -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}`,
}}
/>
<LoginPage onConfirm={updateLoginInfoAndGoToRoot} />
<ConnectionPage onConfirm={changeBackend} />
</Fragment>
}

View File

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

View File

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

View File

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

View File

@ -114,7 +114,7 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {
onSubscribe(hasErrors ? undefined : submit);
}, [submit, hasErrors]);
const backend = useBackendContext();
const { url: backendURL } = useBackendContext()
const { i18n } = useTranslationContext();
return (
@ -128,7 +128,7 @@ export function ProductForm({ onSubscribe, initial, alreadyExist }: Props) {
{alreadyExist ? undefined : (
<InputWithAddon<Entity>
name="product_id"
addonBefore={`${backend.url}/product/`}
addonBefore={`${backendURL}/product/`}
label={i18n.str`ID`}
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 { ComponentChildren, h, VNode } from "preact";
import { MerchantBackend } from "../declaration.js";
import { AccessToken, MerchantBackend } from "../declaration.js";
import {
useAdminAPI,
useInstanceAPI,
@ -64,7 +64,7 @@ describe("backend context api ", () => {
} as MerchantBackend.Instances.QueryInstancesResponse,
});
management.setNewToken("another_token");
management.setNewToken("another_token" as AccessToken);
},
({ instance, management, admin }) => {
expect(env.assertJustExpectedRequestWereMade()).deep.eq({
@ -113,7 +113,7 @@ describe("backend context api ", () => {
name: "instance_name",
} as MerchantBackend.Instances.QueryInstancesResponse,
});
instance.setNewToken("another_token");
instance.setNewToken("another_token" as AccessToken);
},
({ instance, management, admin }) => {
expect(env.assertJustExpectedRequestWereMade()).deep.eq({

View File

@ -20,90 +20,46 @@
*/
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";
interface BackendContextType {
url: string;
token?: string;
triedToLog: boolean;
resetBackend: () => void;
// clearAllTokens: () => void;
// addTokenCleaner: (c: () => void) => void;
updateLoginStatus: (url: string, token?: string) => void;
updateToken: (token?: string) => void;
url: string,
token?: LoginToken;
updateToken: (token: LoginToken | undefined) => void;
changeBackend: (url: string) => void;
}
const BackendContext = createContext<BackendContextType>({
url: "",
token: undefined,
triedToLog: false,
resetBackend: () => null,
// clearAllTokens: () => null,
// addTokenCleaner: () => null,
updateLoginStatus: () => null,
updateToken: () => null,
changeBackend: () => null,
});
function useBackendContextState(
defaultUrl?: string,
initialToken?: string,
): BackendContextType {
const [url, triedToLog, changeBackend, resetBackend] =
useBackendURL(defaultUrl);
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);
};
const [url, changeBackend] = useBackendURL(defaultUrl);
const [token, updateToken] = useBackendDefaultToken();
return {
url,
token,
triedToLog,
updateLoginStatus,
resetBackend,
// clearAllTokens,
updateToken,
// addTokenCleaner: addTokenCleanerMemo,
changeBackend
};
}
export const BackendContextProvider = ({
children,
defaultUrl,
initialToken,
}: {
children: any;
defaultUrl?: string;
initialToken?: string;
}): VNode => {
const value = useBackendContextState(defaultUrl, initialToken);
const value = useBackendContextState(defaultUrl);
return h(BackendContext.Provider, { value, children });
};

View File

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

View File

@ -107,6 +107,16 @@ interface RegexAccountRestriction {
// human hints.
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 {
interface WireResponse {
@ -491,6 +501,35 @@ export namespace MerchantBackend {
};
}
// 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 {

View File

@ -19,19 +19,21 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
import { useSWRConfig } from "swr";
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 { AbsoluteTime, HttpStatusCode } from "@gnu-taler/taler-util";
import {
ErrorType,
HttpError,
HttpResponse,
HttpResponseOk,
RequestError,
RequestOptions,
useApiContext,
} 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(): (
@ -85,6 +87,9 @@ export function useBackendInstancesTestForAdmin(): HttpResponse<
return result;
}
const CHECK_CONFIG_INTERVAL_OK = 5 * 60 * 1000;
const CHECK_CONFIG_INTERVAL_FAIL = 2 * 1000;
export function useBackendConfig(): HttpResponse<
MerchantBackend.VersionResponse,
RequestError<MerchantBackend.ErrorDetail>
@ -92,18 +97,33 @@ export function useBackendConfig(): HttpResponse<
const { request } = useBackendBaseRequest();
type Type = MerchantBackend.VersionResponse;
const [result, setResult] = useState<
HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>>
>({ loading: true });
type State = { data: HttpResponse<Type, RequestError<MerchantBackend.ErrorDetail>>, timer: number }
const [result, setResult] = useState<State>({ data: { loading: true }, timer: 0 });
useEffect(() => {
request<Type>(`/config`)
.then((data) => setResult(data))
.catch((error) => setResult(error));
if (result.timer) {
clearTimeout(result.timer)
}
function tryConfig(): void {
request<Type>(`/config`)
.then((data) => {
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]);
return result;
return result.data;
}
interface useBackendInstanceRequestType {
@ -149,32 +169,86 @@ interface useBackendBaseRequestType {
}
type YesOrNo = "yes" | "no";
type LoginResult = {
valid: true;
token: string;
expiration: Timestamp;
} | {
valid: false;
cause: HttpError<{}>;
}
export function useCredentialsChecker() {
const { request } = useApiContext();
//check against instance details endpoint
//while merchant backend doesn't have a login endpoint
async function testLogin(
instance: string,
token: string,
): Promise<{
valid: boolean;
cause?: ErrorType;
}> {
async function requestNewLoginToken(
baseUrl: string,
token: AccessToken,
): Promise<LoginResult> {
const data: MerchantBackend.Instances.LoginTokenRequest = {
scope: "write",
duration: {
d_us: "forever"
},
refreshable: true,
}
try {
const response = await request(instance, `/private/`, {
const response = await request<MerchantBackend.Instances.LoginTokenSuccessResponse>(baseUrl, `/private/token`, {
method: "POST",
token,
data
});
return { valid: true };
return { valid: true, token: response.data.token, expiration: response.data.expiration };
} catch (error) {
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
*/
export function useBackendBaseRequest(): useBackendBaseRequestType {
const { url: backend, token } = useBackendContext();
const { url: backend, token: loginToken } = useBackendContext();
const { request: requestHandler } = useApiContext();
const token = loginToken?.token;
const request = useCallback(
function requestImpl<T>(
endpoint: string,
options: RequestOptions = {},
): 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],
);
@ -204,10 +283,12 @@ export function useBackendInstanceRequest(): useBackendInstanceRequestType {
const { token: instanceToken, id, admin } = useInstanceContext();
const { request: requestHandler } = useApiContext();
const { baseUrl, token } = !admin
const { baseUrl, token: loginToken } = !admin
? { baseUrl: rootBackendUrl, token: rootToken }
: { baseUrl: `${rootBackendUrl}/instances/${id}`, token: instanceToken };
const token = loginToken?.token;
const request = useCallback(
function requestImpl<T>(
endpoint: string,

View File

@ -19,9 +19,11 @@
* @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 { useMemoryStorage } from "@gnu-taler/web-util/browser";
import { useMatchMutate } from "./backend.js";
const calculateRootPath = () => {
@ -32,53 +34,55 @@ const calculateRootPath = () => {
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(
url?: string,
): [string, boolean, StateUpdater<string>, () => void] {
const [value, setter] = useNotNullLocalStorage(
): [string, StateUpdater<string>] {
const [value, setter] = useSimpleLocalStorage(
"backend-url",
url || calculateRootPath(),
);
const [triedToLog, setTriedToLog] = useLocalStorage("tried-login");
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 = () => {
setTriedToLog(undefined);
};
return [value, !!triedToLog, checkedSetter, resetBackend];
return [value!, checkedSetter];
}
export function useBackendDefaultToken(
initialValue?: string,
): [string | undefined, ((d: string | undefined) => void)] {
// uncomment for testing
initialValue = "secret-token:secret" as string | undefined
const { update: setToken, value: token, reset } = useMemoryStorage(`backend-token`, initialValue)
): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] {
const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {})
const tokenOfDefaultInstance = tokenMap["default"]
const clearCache = useMatchMutate()
useEffect(() => {
clearCache()
}, [token])
}, [tokenOfDefaultInstance])
function updateToken(
value: (string | undefined)
value: (LoginToken | undefined)
): void {
if (value === undefined) {
reset()
} else {
setToken(value)
const res = { ...tokenMap, "default": value }
setToken(res)
}
}
return [token, updateToken];
return [tokenMap["default"], updateToken];
}
export function useBackendInstanceToken(
id: string,
): [string | undefined, ((d: string | undefined) => void)] {
const { update: setToken, value: token, reset } = useMemoryStorage(`backend-token-${id}`)
): [LoginToken | undefined, ((d: LoginToken | undefined) => void)] {
const { update: setToken, value: tokenMap, reset } = useLocalStorage(TOKENS_KEY, {})
const [defaultToken, defaultSetToken] = useBackendDefaultToken();
// instance named 'default' use the default token
@ -86,16 +90,17 @@ export function useBackendInstanceToken(
return [defaultToken, defaultSetToken];
}
function updateToken(
value: (string | undefined)
value: (LoginToken | undefined)
): void {
if (value === undefined) {
reset()
} 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>] {
@ -104,10 +109,10 @@ export function useLang(initial?: string): [string, StateUpdater<string>] {
? navigator.language || (navigator as any).userLanguage
: undefined;
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,
initialValue?: string,
): [string | undefined, StateUpdater<string | undefined>] {
@ -137,28 +142,3 @@ export function useLocalStorage(
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 { expect } from "chai";
import { MerchantBackend } from "../declaration.js";
import { AccessToken, MerchantBackend } from "../declaration.js";
import {
useAdminAPI,
useBackendInstances,
@ -158,7 +158,7 @@ describe("instance api interaction with details", () => {
},
} as MerchantBackend.Instances.QueryInstancesResponse,
});
api.setNewToken("secret");
api.setNewToken("secret" as AccessToken);
},
({ query, api }) => {
expect(env.assertJustExpectedRequestWereMade()).deep.eq({

View File

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

View File

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

View File

@ -24,15 +24,6 @@ import {
codecForString,
} 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 {
advanceOrderMode: boolean;
dateFormat: "ymd" | "dmy" | "mdy";

View File

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

View File

@ -13,12 +13,12 @@
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 { stringifyRewardUri } from "@gnu-taler/taler-util";
import { format } from "date-fns";
import { Fragment, h, VNode } from "preact";
import { useBackendContext } from "../../../../context/backend.js";
import { MerchantBackend } from "../../../../declaration.js";
import { datetimeFormatForSettings, useSettings } from "../../../../hooks/useSettings.js";
import { stringifyRewardUri } from "@gnu-taler/taler-util";
type Entity = MerchantBackend.Rewards.RewardDetails;
@ -29,9 +29,9 @@ interface Props {
}
export function RewardInfo({ id: merchantRewardId, amount, entity }: Props): VNode {
const { url: merchantBaseUrl } = useBackendContext();
const { url: backendURL } = useBackendContext()
const [settings] = useSettings();
const rewardURL = stringifyRewardUri({ merchantBaseUrl, merchantRewardId })
const rewardURL = stringifyRewardUri({ merchantBaseUrl: backendURL, merchantRewardId })
return (
<Fragment>
<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 { InputDuration } from "../../../../components/form/InputDuration.js";
import { InputNumber } from "../../../../components/form/InputNumber.js";
import { InputSearchOnList } from "../../../../components/form/InputSearchOnList.js";
import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
import { useBackendContext } from "../../../../context/backend.js";
import { useInstanceContext } from "../../../../context/instance.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 { undefinedIfEmpty } from "../../../../utils/table.js";
type Entity = MerchantBackend.Template.TemplateAddDetails;
@ -55,7 +51,7 @@ interface Props {
export function CreatePage({ onCreate, onBack }: Props): VNode {
const { i18n } = useTranslationContext();
const backend = useBackendContext();
const { url: backendURL } = useBackendContext()
const devices = useInstanceOtpDevices()
const [state, setState] = useState<Partial<Entity>>({
@ -128,7 +124,7 @@ export function CreatePage({ onCreate, onBack }: Props): VNode {
>
<InputWithAddon<Entity>
name="template_id"
help={`${backend.url}/templates/${state.template_id ?? ""}`}
help={`${backendURL}/templates/${state.template_id ?? ""}`}
label={i18n.str`Identifier`}
tooltip={i18n.str`Name of the template in URLs.`}
/>

View File

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

View File

@ -24,7 +24,7 @@ import {
MerchantTemplateContractDetails,
} from "@gnu-taler/taler-util";
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 { AsyncButton } from "../../../../components/exception/AsyncButton.js";
import {
@ -35,17 +35,10 @@ import { Input } from "../../../../components/form/Input.js";
import { InputCurrency } from "../../../../components/form/InputCurrency.js";
import { InputDuration } from "../../../../components/form/InputDuration.js";
import { InputNumber } from "../../../../components/form/InputNumber.js";
import { InputSelector } from "../../../../components/form/InputSelector.js";
import { InputWithAddon } from "../../../../components/form/InputWithAddon.js";
import { useBackendContext } from "../../../../context/backend.js";
import { MerchantBackend, WithId } from "../../../../declaration.js";
import {
isBase32RFC3548Charset,
randomBase32Key,
} from "../../../../utils/crypto.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;
@ -55,12 +48,9 @@ interface Props {
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 {
const { i18n } = useTranslationContext();
const backend = useBackendContext();
const { url: backendURL } = useBackendContext()
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-item">
<span class="is-size-4">
{backend.url}/templates/{template.id}
{backendURL}/templates/{template.id}
</span>
</div>
</div>

View File

@ -26,12 +26,13 @@ import { AsyncButton } from "../../../components/exception/AsyncButton.js";
import { FormProvider } from "../../../components/form/FormProvider.js";
import { Input } from "../../../components/form/Input.js";
import { useInstanceContext } from "../../../context/instance.js";
import { AccessToken } from "../../../declaration.js";
interface Props {
instanceId: string;
currentToken: string | undefined;
onClearToken: () => void;
onNewToken: (s: string) => void;
onNewToken: (s: AccessToken) => void;
onBack?: () => void;
}
@ -71,7 +72,8 @@ export function DetailPage({ instanceId, currentToken: oldToken, onBack, onNewTo
async function submitForm() {
if (hasErrors) return;
onNewToken(form.new_token as any)
const nt = `secret-token:${form.new_token}` as AccessToken;
onNewToken(nt)
}
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 { Fragment, VNode, h } from "preact";
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 { DetailPage } from "./DetailPage.js";
import { useInstanceContext } from "../../../context/instance.js";
@ -49,13 +49,13 @@ export default function Token({
const { token: instanceToken, id, admin } = useInstanceContext();
const currentToken = !admin ? rootToken : instanceToken
const hasPrefix = currentToken !== undefined && currentToken.startsWith(PREFIX)
const hasPrefix = currentToken !== undefined && currentToken.token.startsWith(PREFIX)
return (
<Fragment>
<NotificationCard notification={notif} />
<DetailPage
instanceId={id}
currentToken={hasPrefix ? currentToken.substring(PREFIX.length) : currentToken}
currentToken={hasPrefix ? currentToken.token.substring(PREFIX.length) : currentToken?.token}
onClearToken={async (): Promise<void> => {
try {
await clearToken();
@ -72,7 +72,7 @@ export default function Token({
}}
onNewToken={async (newToken): Promise<void> => {
try {
await setNewToken(`secret-token:${newToken}`);
await setNewToken(newToken);
onChange();
} catch (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
GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
*/
import { HttpStatusCode } from "@gnu-taler/taler-util";
import {
ErrorType,
HttpError,
HttpResponse,
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 { Loading } from "../../../components/exception/loading.js";
import { NotificationCard } from "../../../components/menu/index.js";
import { useInstanceContext } from "../../../context/instance.js";
import { MerchantBackend } from "../../../declaration.js";
import { AccessToken, MerchantBackend } from "../../../declaration.js";
import {
useInstanceAPI,
useInstanceDetails,
@ -33,7 +34,6 @@ import {
} from "../../../hooks/instance.js";
import { Notification } from "../../../utils/types.js";
import { UpdatePage } from "./UpdatePage.js";
import { HttpStatusCode } from "@gnu-taler/taler-util";
export interface Props {
onBack: () => void;
@ -73,10 +73,9 @@ function CommonUpdate(
MerchantBackend.ErrorDetail
>,
updateInstance: any,
clearToken: any,
setNewToken: any,
clearToken: () => Promise<void>,
setNewToken: (t: AccessToken) => Promise<void>,
): VNode {
const { changeToken } = useInstanceContext();
const [notif, setNotif] = useState<Notification | undefined>(undefined);
const { i18n } = useTranslationContext();
@ -119,11 +118,8 @@ function CommonUpdate(
d: MerchantBackend.Instances.InstanceAuthConfigurationMessage,
): Promise<void> => {
const apiCall =
d.method === "external" ? clearToken() : setNewToken(d.token!);
return apiCall
.then(() => changeToken(d.token))
.then(onConfirm)
.catch(onUpdateError);
d.method === "external" ? clearToken() : setNewToken(d.token! as AccessToken);
return apiCall.then(onConfirm).catch(onUpdateError);
}}
/>
</Fragment>

View File

@ -18,9 +18,9 @@ import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { Fragment, VNode, h } from "preact";
import { QR } from "../../../../components/exception/QR.js";
import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js";
import { useBackendContext } from "../../../../context/backend.js";
import { useInstanceContext } from "../../../../context/instance.js";
import { MerchantBackend } from "../../../../declaration.js";
import { useBackendContext } from "../../../../context/backend.js";
type Entity = MerchantBackend.OTP.OtpDeviceAddDetails;
@ -38,9 +38,9 @@ export function CreatedSuccessfully({
onConfirm,
}: Props): VNode {
const { i18n } = useTranslationContext();
const backend = useBackendContext();
const { url: backendURL } = useBackendContext()
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 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)
*/
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 {
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;
}
export function Settings(): VNode {
export function Settings({ onClose }: { onClose?: () => void }): VNode {
const { i18n } = useTranslationContext()
const borwserLang = getBrowserLang()
const { update } = useLang()
@ -94,11 +94,19 @@ export function Settings(): VNode {
/>
</FormProvider>
</div>
</div>
<div class="column" />
</div>
</section>
</div>
</section >
{onClose &&
<section class="section is-main-section">
<button
class="button"
onClick={onClose}
>
<i18n.Translate>Close</i18n.Translate>
</button>
</section>
}
</div >
}

View File

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