commit 242669f6ae6599dfc8b1dfe07e2354208ee75b0c
parent f9fd5471809cd33f3b96eb84d4cdb2918db2919a
Author: Sebastian <sebasjm@taler-systems.com>
Date: Tue, 2 Jun 2026 12:01:51 -0300
show copy button in the notification bar
Diffstat:
3 files changed, 83 insertions(+), 104 deletions(-)
diff --git a/packages/merchant-backoffice-ui/src/paths/login/index.tsx b/packages/merchant-backoffice-ui/src/paths/login/index.tsx
@@ -20,25 +20,34 @@
*/
import {
+ asPassword,
assertUnreachable,
Duration,
HttpStatusCode,
LoginTokenRequest,
LoginTokenScope,
+ Password,
TranslatedString,
} from "@gnu-taler/taler-util";
import {
Button,
+ undefinedIfEmpty,
useNotificationContext,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { Fragment, h, VNode } from "preact";
import { useState } from "preact/hooks";
-import { FormProvider } from "../../components/form/FormProvider.js";
-import { doAutoFocus } from "../../components/form/Input.js";
+import {
+ FormErrors,
+ FormProvider,
+} from "../../components/form/FormProvider.js";
+import { doAutoFocus, Input } from "../../components/form/Input.js";
import { Tooltip } from "../../components/Tooltip.js";
import { useMerchantChallengeHandlerContext } from "../../context/challenge.js";
import { useSessionContext } from "../../context/session.js";
+import { useMemo } from "preact/hooks";
+import { InputPassword } from "../../components/form/InputPassword.js";
+import { InputWithAddon } from "../../components/form/InputWithAddon.js";
const TALER_SCREEN_ID = 79;
@@ -47,6 +56,11 @@ interface Props {
focus?: boolean;
}
+type Form = {
+ username: string;
+ password: string;
+};
+
export const TEMP_TEST_TOKEN = (description: TranslatedString) =>
({
scope: LoginTokenScope.All,
@@ -62,32 +76,47 @@ export const FOREVER_REFRESHABLE_TOKEN = (description: TranslatedString) =>
}) as LoginTokenRequest;
export function LoginPage({ showCreateAccount, focus }: Props): VNode {
- const [password, setPassword] = useState("");
-
const { state, lib, logIn, getInstanceForUsername, config } =
useSessionContext();
- const [username, setUsername] = useState(
- showCreateAccount ? "" : state.instance,
- );
const { i18n } = useTranslationContext();
- const [hidePassword, setHidePassword] = useState(true);
+
+ const defaultUsername = showCreateAccount ? undefined : state.instance;
+
+ const [value, valueHandler] = useState<Partial<Form>>({
+ username: defaultUsername,
+ });
+
+ // const [opFailed, setOpFailed] = useState<"bad-username" | "bad-password">();
+
+ const errors = undefinedIfEmpty<FormErrors<Form>>({
+ username: !value.username
+ ? i18n.str`Required`
+ : // : opFailed === "bad-username"
+ // ? i18n.str`wrong username`
+ undefined,
+ password: !value.password
+ ? i18n.str`Required`
+ : // : opFailed === "bad-password"
+ // ? i18n.str`wrong password`
+ undefined,
+ });
const { actionHandler, showError } = useNotificationContext();
const mfa = useMerchantChallengeHandlerContext();
-
+ const pwd = useMemo(() => asPassword(value.password!), [value.password]);
const login = actionHandler(
/*login*/
- async (ct, usr: string, pwd: string, challengeIds?: string[]) => {
+ async (ct, usr: string, pwd: Password, challengeIds?: string[]) => {
const api = getInstanceForUsername(usr);
const resp = await api.createAccessToken(
usr,
- pwd,
+ pwd.__value,
FOREVER_REFRESHABLE_TOKEN(i18n.str`Logged in`),
{ challengeIds },
);
return resp;
},
- !username || !password ? undefined : [username, password],
+ !value.username || !value.password ? undefined : [value.username, pwd],
);
login.onSuccess = (success, usr) => {
logIn(usr, success.access_token);
@@ -104,8 +133,12 @@ export function LoginPage({ showCreateAccount, focus }: Props): VNode {
);
return undefined;
case HttpStatusCode.Unauthorized:
+ // setOpFailed("bad-password");
+ // return undefined;
return i18n.str`Wrong password.`;
case HttpStatusCode.NotFound:
+ // setOpFailed("bad-username");
+ // return undefined;
return i18n.str`The account doesn't exist.`;
default:
assertUnreachable(fail);
@@ -124,7 +157,12 @@ export function LoginPage({ showCreateAccount, focus }: Props): VNode {
<i18n.Translate>Login required</i18n.Translate>
</p>
</header>
- <form>
+ <FormProvider<Form>
+ name="login"
+ object={value}
+ errors={errors}
+ valueHandler={valueHandler}
+ >
<section
class="modal-card-body"
style={{
@@ -135,76 +173,18 @@ export function LoginPage({ showCreateAccount, focus }: Props): VNode {
overflow: "hidden",
}}
>
- <div class="field is-horizontal">
- <div class="field-label is-normal">
- <label class="label">
- <i18n.Translate>Username</i18n.Translate>
- <Tooltip text={i18n.str`Instance name.`}>
- <i class="icon mdi mdi-information" />
- </Tooltip>
- </label>
- </div>
- <div class="field-body">
- <div class="field">
- <p class="control is-expanded">
- <input
- class="input"
- type="text"
- ref={focus ? doAutoFocus : undefined}
- // placeholder={i18n.str`instance name`}
- name="username"
- autoComplete="username"
- value={username}
- onInput={(e): void =>
- setUsername(e?.currentTarget.value)
- }
- />
- </p>
- </div>
- </div>
- </div>
- <div class="field is-horizontal">
- <div class="field-label is-normal">
- <label class="label">
- <i18n.Translate>Password</i18n.Translate>
- <Tooltip text={i18n.str`Instance password.`}>
- <i class="icon mdi mdi-information" />
- </Tooltip>
- </label>
- </div>
- <div class="field-body">
- <div class="field has-addons">
- <p class="control is-expanded">
- <input
- class="input"
- type={hidePassword ? "password" : "text"}
- // placeholder={i18n.str`current password`}
- name="token"
- autoComplete="current-password"
- value={password}
- onInput={(e): void =>
- setPassword(e?.currentTarget.value)
- }
- />
- </p>
- <div
- class="control"
- style={{ cursor: "pointer" }}
- onClick={() => {
- setHidePassword((h) => !h);
- }}
- >
- <a class="button is-static point">
- {hidePassword ? (
- <i class="icon mdi mdi-eye-off" />
- ) : (
- <i class="icon mdi mdi-eye" />
- )}
- </a>
- </div>
- </div>
- </div>
- </div>
+ <InputWithAddon<Form>
+ name="username"
+ label={i18n.str`Username`}
+ tooltip={i18n.str`Instance name.`}
+ autoComplete="username"
+ />
+ <InputPassword<Form>
+ name="password"
+ label={i18n.str`Password`}
+ tooltip={i18n.str`Instance password.`}
+ autoComplete="current-password"
+ />
</section>
<footer
class="modal-card-foot "
@@ -219,12 +199,12 @@ export function LoginPage({ showCreateAccount, focus }: Props): VNode {
) : (
<a
href={
- !username || username === "admin"
+ !value.username || value.username === "admin"
? undefined
- : `#/account/reset/${username}`
+ : `#/account/reset/${value.username}`
}
class="button "
- disabled={!username || username === "admin"}
+ disabled={!value.username || value.username === "admin"}
>
<i18n.Translate>Forgot password</i18n.Translate>
</a>
@@ -233,7 +213,7 @@ export function LoginPage({ showCreateAccount, focus }: Props): VNode {
<i18n.Translate>Confirm</i18n.Translate>
</Button>
</footer>
- </form>
+ </FormProvider>
</div>
</div>
{!showCreateAccount ? undefined : (
diff --git a/packages/web-util/src/components/CopyButton.tsx b/packages/web-util/src/components/CopyButton.tsx
@@ -1,4 +1,5 @@
import { ComponentChildren, h, VNode } from "preact";
+import { CSSProperties } from "preact/compat";
import { useEffect, useState } from "preact/hooks";
export function CopyIcon(): VNode {
@@ -9,7 +10,7 @@ export function CopyIcon(): VNode {
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
- class="w-6 h-6"
+ style={{ width: 24, height: 24 }}
>
<path
stroke-linecap="round"
@@ -28,7 +29,7 @@ export function CopiedIcon(): VNode {
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
- class="w-6 h-6"
+ style={{ width: 24, height: 24 }}
>
<path
stroke-linecap="round"
@@ -41,11 +42,13 @@ export function CopiedIcon(): VNode {
export function CopyButton({
class: clazz,
+ style,
children,
getContent,
}: {
children?: ComponentChildren;
class: string;
+ style?: CSSProperties;
getContent: () => string;
}): VNode {
const [copied, setCopied] = useState(false);
@@ -73,6 +76,7 @@ export function CopyButton({
return (
<button
class={clazz}
+ style={style}
onClick={(e) => {
e.preventDefault();
copyText();
@@ -84,7 +88,7 @@ export function CopyButton({
);
}
return (
- <button class={clazz} disabled>
+ <button class={clazz} style={style} disabled>
<CopiedIcon />
{children}
</button>
diff --git a/packages/web-util/src/components/NotificationBanner.tsx b/packages/web-util/src/components/NotificationBanner.tsx
@@ -2,6 +2,7 @@ import { Fragment, h, VNode } from "preact";
import { useRef, useState } from "preact/compat";
import { Duration } from "../../../taler-util/src/time.js";
import {
+ CopyButton,
Notification,
useCommonPreferences,
useNotificationContext,
@@ -130,20 +131,15 @@ export function ToastBannerBulma(): VNode {
<div class="message-header">
<p>{msg.title}</p>
<div>
- <button
- class="copy "
- aria-label="copy"
- onClick={(e) => {
- e.preventDefault();
-
- navigator.clipboard.writeText(
- fromNodeToText(divHtml.current),
- );
- }}
+ <CopyButton
+ class="button"
+ style={{ padding: 8 }}
+ getContent={() => fromNodeToText(divHtml.current)}
/>
<button
class="delete "
aria-label="close"
+ style={{ margin: 8 }}
onClick={() => notification.acknowledge()}
/>
</div>
@@ -167,11 +163,11 @@ export function ToastBannerBulma(): VNode {
<i18n.Translate>show more info</i18n.Translate>
</a>
)}
- {/* {showDebugInfo && msg.debug && ( */}
+ {msg.debug && (
<pre
class="whitespace-break-spaces text-black"
style={{
- // display: showDebugInfo ? "block" : "none",
+ display: showDebugInfo ? "block" : "none",
}}
>
{JSON.stringify(
@@ -183,7 +179,7 @@ export function ToastBannerBulma(): VNode {
2,
)}
</pre>
- {/* )} */}
+ )}
</div>
)}
</article>
@@ -243,4 +239,3 @@ function fromNodeToText(node: ChildNode | undefined) {
}
return result;
}
-