diff options
Diffstat (limited to 'packages/bank-ui/src/pages/RegistrationPage.tsx')
-rw-r--r-- | packages/bank-ui/src/pages/RegistrationPage.tsx | 424 |
1 files changed, 424 insertions, 0 deletions
diff --git a/packages/bank-ui/src/pages/RegistrationPage.tsx b/packages/bank-ui/src/pages/RegistrationPage.tsx new file mode 100644 index 000000000..5dd19a63f --- /dev/null +++ b/packages/bank-ui/src/pages/RegistrationPage.tsx @@ -0,0 +1,424 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ +import { + HttpStatusCode, + TalerErrorCode +} from "@gnu-taler/taler-util"; +import { + LocalNotificationBanner, + RouteDefinition, + ShowInputErrorLabel, + useBankCoreApiContext, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { useSettingsContext } from "../context/settings.js"; +import { undefinedIfEmpty } from "../utils.js"; +import { getRandomPassword, getRandomUsername } from "./rnd.js"; + +export function RegistrationPage({ + onRegistrationSuccesful, + routeCancel, +}: { + onRegistrationSuccesful: (user: string, password: string) => void; + routeCancel: RouteDefinition; +}): VNode { + const { i18n } = useTranslationContext(); + const { config } = useBankCoreApiContext(); + if (!config.allow_registrations) { + return ( + <p>{i18n.str`Currently, the bank is not accepting new registrations!`}</p> + ); + } + return ( + <RegistrationForm + onRegistrationSuccesful={onRegistrationSuccesful} + routeCancel={routeCancel} + /> + ); +} + +// eslint-disable-next-line no-useless-escape +export const USERNAME_REGEX = /^[a-zA-Z0-9\-\.\_\~]*$/; +export const PHONE_REGEX = /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/; +export const EMAIL_REGEX = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/; + +/** + * Collect and submit registration data. + */ +function RegistrationForm({ + onRegistrationSuccesful, + routeCancel, +}: { + onRegistrationSuccesful: (user: string, password: string) => void; + routeCancel: RouteDefinition; +}): VNode { + const [username, setUsername] = useState<string | undefined>(); + const [name, setName] = useState<string | undefined>(); + const [password, setPassword] = useState<string | undefined>(); + // const [phone, setPhone] = useState<string | undefined>(); + // const [email, setEmail] = useState<string | undefined>(); + const [repeatPassword, setRepeatPassword] = useState<string | undefined>(); + const [notification, , handleError] = useLocalNotification(); + const settings = useSettingsContext(); + + const { + lib: { bank: api }, + } = useBankCoreApiContext(); + // const { register } = useTestingAPI(); + const { i18n } = useTranslationContext(); + + const errors = undefinedIfEmpty({ + name: !name ? i18n.str`Missing name` : undefined, + username: !username + ? i18n.str`Missing username` + : !USERNAME_REGEX.test(username) + ? i18n.str`Use letters, numbers or any of these characters: - . _ ~` + : undefined, + // phone: !phone + // ? undefined + // : !PHONE_REGEX.test(phone) + // ? i18n.str`Use letters and numbers only, and start with a lowercase letter` + // : undefined, + // email: !email + // ? undefined + // : !EMAIL_REGEX.test(email) + // ? i18n.str`Use letters and numbers only, and start with a lowercase letter` + // : undefined, + password: !password ? i18n.str`Missing password` : undefined, + repeatPassword: !repeatPassword + ? i18n.str`Missing password` + : repeatPassword !== password + ? i18n.str`Passwords don't match` + : undefined, + }); + + async function doRegistrationAndLogin( + name: string, + username: string, + password: string, + onComplete: () => void, + ) { + await handleError(async (onError) => { + const resp = await api.createAccount(undefined, { + name, + username, + password, + }); + if (resp.type === "ok") { + onComplete(); + } else { + onError(resp, (_case) => { + switch (_case) { + case HttpStatusCode.BadRequest: + return i18n.str`Server replied with invalid phone or email.`; + case HttpStatusCode.Unauthorized: + return i18n.str`No enough permission to create that account.`; + case TalerErrorCode.BANK_UNALLOWED_DEBIT: + return i18n.str`Registration is disabled because the bank ran out of bonus credit.`; + case TalerErrorCode.BANK_RESERVED_USERNAME_CONFLICT: + return i18n.str`That username can't be used because is reserved.`; + case TalerErrorCode.BANK_REGISTER_USERNAME_REUSE: + return i18n.str`That username is already taken.`; + case TalerErrorCode.BANK_REGISTER_PAYTO_URI_REUSE: + return i18n.str`That account id is already taken.`; + case TalerErrorCode.BANK_MISSING_TAN_INFO: + return i18n.str`No information for the selected authentication channel.`; + case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: + return i18n.str`Authentication channel is not supported.`; + case TalerErrorCode.BANK_NON_ADMIN_PATCH_DEBT_LIMIT: + return i18n.str`Only admin is allow to set debt limit.`; + case TalerErrorCode.BANK_NON_ADMIN_SET_TAN_CHANNEL: + return i18n.str`Only admin can create accounts with second factor authentication.`; + } + }); + } + }); + } + + async function doRegistrationStep() { + if (!username || !password || !name) return; + await doRegistrationAndLogin(name, username, password, () => { + setUsername(undefined); + setPassword(undefined); + setRepeatPassword(undefined); + onRegistrationSuccesful(username, password); + }); + } + + async function doRandomRegistration() { + const user = getRandomUsername(); + + const password = settings.simplePasswordForRandomAccounts + ? "123" + : getRandomPassword(); + const username = `_${user.first}-${user.second}_`; + const name = `${capitalizeFirstLetter(user.first)} ${capitalizeFirstLetter( + user.second, + )}`; + await doRegistrationAndLogin(name, username, password, () => { + onRegistrationSuccesful(username, password); + }); + } + + return ( + <Fragment> + <LocalNotificationBanner notification={notification} /> + + <div class="flex min-h-full flex-col justify-center"> + <div class="sm:mx-auto sm:w-full sm:max-w-sm"> + <h2 class="text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">{i18n.str`Account registration`}</h2> + </div> + + <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm"> + <form + class="space-y-6" + noValidate + onSubmit={(e) => { + e.preventDefault(); + }} + autoCapitalize="none" + autoCorrect="off" + > + <div> + <label + for="username" + class="block text-sm font-medium leading-6 text-gray-900" + > + <i18n.Translate>Login username</i18n.Translate> + <b style={{ color: "red" }}> *</b> + </label> + <div class="mt-2"> + <input + autoFocus + type="text" + name="username" + id="username" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={username ?? ""} + enterkeyhint="next" + placeholder="account identification to login" + autocomplete="username" + required + onInput={(e): void => { + setUsername(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.username} + isDirty={username !== undefined} + /> + </div> + </div> + + <div> + <div class="flex items-center justify-between"> + <label + for="password" + class="block text-sm font-medium leading-6 text-gray-900" + > + <i18n.Translate>Password</i18n.Translate> + <b style={{ color: "red" }}> *</b> + </label> + </div> + <div class="mt-2"> + <input + type="password" + name="password" + id="password" + autocomplete="current-password" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + enterkeyhint="send" + value={password ?? ""} + placeholder="Password" + required + onInput={(e): void => { + setPassword(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.password} + isDirty={password !== undefined} + /> + </div> + </div> + + <div> + <div class="flex items-center justify-between"> + <label + for="register-repeat" + class="block text-sm font-medium leading-6 text-gray-900" + > + <i18n.Translate>Repeat password</i18n.Translate> + <b style={{ color: "red" }}> *</b> + </label> + </div> + <div class="mt-2"> + <input + type="password" + name="register-repeat" + id="register-repeat" + autocomplete="current-password" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + enterkeyhint="send" + value={repeatPassword ?? ""} + placeholder="Same password" + required + onInput={(e): void => { + setRepeatPassword(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.repeatPassword} + isDirty={repeatPassword !== undefined} + /> + </div> + </div> + + <div> + <div class="flex items-center justify-between"> + <label + for="name" + class="block text-sm font-medium leading-6 text-gray-900" + > + <i18n.Translate>Full name</i18n.Translate> + <b style={{ color: "red" }}> *</b> + </label> + </div> + <div class="mt-2"> + <input + autoFocus + type="text" + name="name" + id="name" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={name ?? ""} + enterkeyhint="next" + placeholder="John Doe" + autocomplete="name" + required + onInput={(e): void => { + setName(e.currentTarget.value); + }} + /> + {/* <ShowInputErrorLabel + message={errors?.name} + isDirty={name !== undefined} + /> */} + </div> + </div> + + {/* <div> + <label for="phone" class="block text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Phone</i18n.Translate> + </label> + <div class="mt-2"> + <input + autoFocus + type="text" + name="phone" + id="phone" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={phone ?? ""} + enterkeyhint="next" + placeholder="your phone" + autocomplete="none" + onInput={(e): void => { + setPhone(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.phone} + isDirty={phone !== undefined} + /> + </div> + </div> + <div> + <label for="email" class="block text-sm font-medium leading-6 text-gray-900"> + <i18n.Translate>Email</i18n.Translate> + </label> + <div class="mt-2"> + <input + autoFocus + type="text" + name="email" + id="email" + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={email ?? ""} + enterkeyhint="next" + placeholder="your email" + autocomplete="email" + onInput={(e): void => { + setEmail(e.currentTarget.value); + }} + /> + <ShowInputErrorLabel + message={errors?.email} + isDirty={email !== undefined} + /> + </div> + </div> */} + + <div class="flex w-full justify-between"> + <a + name="cancel" + href={routeCancel.url({})} + class="ring-1 ring-gray-600 rounded-md bg-white disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-black shadow-sm hover:bg-white-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2" + > + <i18n.Translate>Cancel</i18n.Translate> + </a> + <button + type="submit" + name="register" + class=" rounded-md bg-indigo-600 disabled:bg-gray-300 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!!errors} + onClick={async (e) => { + e.preventDefault(); + + doRegistrationStep(); + }} + > + <i18n.Translate>Register</i18n.Translate> + </button> + </div> + </form> + + {settings.allowRandomAccountCreation && ( + <p class="mt-10 text-center text-sm text-gray-500 border-t"> + <button + type="submit" + name="create random" + class="flex mt-4 w-full justify-center rounded-md bg-green-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-green-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600" + onClick={(e) => { + e.preventDefault(); + doRandomRegistration(); + }} + > + <i18n.Translate>Create a random temporary user</i18n.Translate> + </button> + </p> + )} + </div> + </div> + </Fragment> + ); +} + +function capitalizeFirstLetter(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1); +} |