diff --git a/frontends/web/src/components/password.tsx b/frontends/web/src/components/password.tsx index 62d3d4ba84..aa28e848ec 100644 --- a/frontends/web/src/components/password.tsx +++ b/frontends/web/src/components/password.tsx @@ -1,6 +1,6 @@ /** * Copyright 2018 Shift Devices AG - * Copyright 2024 Shift Crypto AG + * Copyright 2024-2025 Shift Crypto AG * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,23 +15,13 @@ * limitations under the License. */ -import { Component, createRef } from 'react'; -import { TranslateProps, translate } from '@/decorators/translate'; +import { useEffect, useRef, useState, ChangeEvent, ClipboardEvent, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useCapsLock } from '@/hooks/keyboard'; import { Input, Checkbox, Field } from './forms'; import { alertUser } from './alert/Alert'; import style from './password.module.css'; -const excludeKeys = /^(Shift|Alt|Backspace|CapsLock|Tab)$/i; - -const hasCaps = (event: KeyboardEvent) => { - const key = event.key; - // will return null, when we cannot clearly detect if capsLock is active or not - if (key.length > 1 || key.toUpperCase() === key.toLowerCase() || excludeKeys.test(key)) { - return null; - } - // ideally we return event.getModifierState('CapsLock')) but this currently does always return false in Qt - return key.toUpperCase() === key && key.toLowerCase() !== key && !event.shiftKey; -}; type TPropsPasswordInput = { seePlaintext?: boolean; @@ -42,6 +32,7 @@ type TPropsPasswordInput = { onInput?: (event: React.ChangeEvent) => void; value: string; }; + export const PasswordInput = ({ seePlaintext, ...rest }: TPropsPasswordInput) => { return ( }; type TProps = { - idPrefix?: string; pattern?: string; autoFocus?: boolean; disabled?: boolean; @@ -63,310 +53,261 @@ type TProps = { onValidPassword: (password: string | null) => void; }; -type TPasswordSingleInputProps = TProps & TranslateProps; - -type TState = { - password: string; - seePlaintext: boolean; - capsLock: boolean; -}; - -class PasswordSingleInputClass extends Component { - private regex?: RegExp; - - state = { - password: '', - seePlaintext: false, - capsLock: false - }; - - password = createRef(); - - idPrefix = () => { - return this.props.idPrefix || ''; - }; - - handleCheckCaps = (event: KeyboardEvent) => { - const capsLock = hasCaps(event); - - if (capsLock !== null) { - this.setState({ capsLock }); - } - }; - - componentDidMount() { - window.addEventListener('keydown', this.handleCheckCaps); - if (this.props.pattern) { - this.regex = new RegExp(this.props.pattern); - } - if (this.props.autoFocus && this.password?.current) { - this.password.current.focus(); +export const PasswordSingleInput = ({ + pattern, + autoFocus, + disabled, + label, + placeholder, + title, + showLabel, + onValidPassword, +}: TProps) => { + const { t } = useTranslation(); + const capsLock = useCapsLock(); + + const [password, setPassword] = useState(''); + const [seePlaintext, setSeePlaintext] = useState(false); + + const passwordRef = useRef(null); + + // Autofocus + useEffect(() => { + if (autoFocus && passwordRef.current) { + passwordRef.current.focus(); } - } + }, [autoFocus]); - componentWillUnmount() { - window.removeEventListener('keydown', this.handleCheckCaps); - } - - tryPaste = (event: React.ClipboardEvent) => { - const target = event.currentTarget; - if (target.type === 'password') { + const tryPaste = (event: ClipboardEvent) => { + if (event.currentTarget.type === 'password') { event.preventDefault(); - alertUser(this.props.t('password.warning.paste', { - label: this.props.label - })); + alertUser( + t('password.warning.paste', { + label, + }) + ); } }; - clear = () => { - this.setState({ - password: '', - seePlaintext: false, - capsLock: false - }); - }; - - validate = () => { - if (this.regex && this.password.current && !this.password.current.validity.valid) { - return this.props.onValidPassword(null); + const validate = (value: string) => { + if (passwordRef.current && !passwordRef.current.validity.valid) { + onValidPassword(null); + return; } - if (this.state.password) { - this.props.onValidPassword(this.state.password); + if (value) { + onValidPassword(value); } else { - this.props.onValidPassword(null); + onValidPassword(null); } }; - handleFormChange = (event: React.ChangeEvent) => { - let value: string | boolean = event.target.value; + const handleFormChange = (event: ChangeEvent) => { if (event.target.type === 'checkbox') { - value = event.target.checked; + setSeePlaintext(event.target.checked); + } else { + const newPassword = event.target.value; + setPassword(newPassword); + validate(newPassword); } - const stateKey = event.target.id.slice(this.idPrefix().length) as keyof TState; - this.setState({ [stateKey]: value } as Pick, this.validate); }; - render() { - const { - t, - disabled, - label, - placeholder, - pattern, - title, - showLabel, - } = this.props; - const { - password, - seePlaintext, - capsLock, - } = this.state; - const warning = (capsLock && !seePlaintext) && ( - - ); - return ( - - }> - {warning} - - ); - } - -} - -const HOC = translate(undefined, { withRef: true })(PasswordSingleInputClass); -export { HOC as PasswordSingleInput }; + const warning = ( + capsLock && !seePlaintext ? ( + + ⇪ + + ) : null + ); + return ( + + } + > + {warning} + + ); +}; -type TPasswordRepeatProps = TPasswordSingleInputProps & { +type TPasswordRepeatProps = TProps & { + idPrefix?: string; repeatLabel?: string; repeatPlaceholder: string; }; -class PasswordRepeatInputClass extends Component { - private regex?: RegExp; - - state = { - password: '', - passwordRepeat: '', - seePlaintext: false, - capsLock: false - }; - - password = createRef(); - passwordRepeat = createRef(); - - idPrefix = () => { - return this.props.idPrefix || ''; - }; - - - handleCheckCaps = (event: KeyboardEvent) => { - const capsLock = hasCaps(event); - - if (capsLock !== null) { - this.setState({ capsLock }); +export const PasswordRepeatInput = ({ + idPrefix = '', + pattern, + autoFocus, + disabled, + label, + placeholder, + title, + repeatLabel, + repeatPlaceholder, + showLabel, + onValidPassword, +}: TPasswordRepeatProps) => { + const { t } = useTranslation(); + const capsLock = useCapsLock(); + + const [password, setPassword] = useState(''); + const [passwordRepeat, setPasswordRepeat] = useState(''); + const [seePlaintext, setSeePlaintext] = useState(false); + + const passwordRef = useRef(null); + const passwordRepeatRef = useRef(null); + + const regex = useMemo(() => (pattern ? new RegExp(pattern) : null), [pattern]); + + // Autofocus + useEffect(() => { + if (autoFocus && passwordRef.current) { + passwordRef.current.focus(); } - }; + }, [autoFocus]); - componentDidMount() { - window.addEventListener('keydown', this.handleCheckCaps); - if (this.props.pattern) { - this.regex = new RegExp(this.props.pattern); - } - if (this.props.autoFocus && this.password?.current) { - this.password.current.focus(); - } - } - - componentWillUnmount() { - window.removeEventListener('keydown', this.handleCheckCaps); - } - - tryPaste = (event: React.ClipboardEvent) => { - const target = event.currentTarget; - if (target.type === 'password') { + const tryPaste = (event: ClipboardEvent) => { + if (event.currentTarget.type === 'password') { event.preventDefault(); - alertUser(this.props.t('password.warning.paste', { - label: this.props.label - })); + alertUser( + t('password.warning.paste', { + label, + }) + ); } }; - validate = () => { + const validate = (pwd: string, pwdRepeat: string) => { if ( - this.regex && this.password.current && this.passwordRepeat.current - && (!this.password.current.validity.valid || !this.passwordRepeat.current.validity.valid) + passwordRef.current && + passwordRepeatRef.current && + (!passwordRef.current.validity.valid || !passwordRepeatRef.current.validity.valid) ) { - return this.props.onValidPassword(null); + onValidPassword(null); + return; } - if (this.state.password && this.state.password === this.state.passwordRepeat) { - this.props.onValidPassword(this.state.password); + if (pwd && pwd === pwdRepeat) { + onValidPassword(pwd); } else { - this.props.onValidPassword(null); + onValidPassword(null); } }; - handleFormChange = (event: React.ChangeEvent) => { - let value: string | boolean = event.target.value; + const handleFormChange = (event: ChangeEvent) => { if (event.target.type === 'checkbox') { - value = event.target.checked; + setSeePlaintext(event.target.checked); + return; + } + + if (event.target.id.endsWith('passwordRepeat')) { + const newRepeat = event.target.value; + setPasswordRepeat(newRepeat); + validate(password, newRepeat); + } else { + const newPassword = event.target.value; + setPassword(newPassword); + validate(newPassword, passwordRepeat); } - const stateKey = event.target.id.slice(this.idPrefix().length); - this.setState({ [stateKey]: value } as Pick, this.validate); }; - render() { - const { - t, - disabled, - label, - placeholder, - pattern, - title, - repeatLabel, - repeatPlaceholder, - showLabel, - } = this.props; - const { - password, - passwordRepeat, - seePlaintext, - capsLock, - } = this.state; - const warning = (capsLock && !seePlaintext) && ( - - ); - return ( -
- - {warning} - - - - {warning} - - - - - -
- ); - } -} + const warning = + capsLock && !seePlaintext ? ( + + ⇪ + + ) : null; + + return ( +
+ + {warning} + -const HOCRepeat = translate(undefined, { withRef: true })(PasswordRepeatInputClass); -export { HOCRepeat as PasswordRepeatInput }; + {regex && ( + + )} + + + {warning} + + + {regex && ( + + )} + + + + +
+ ); +}; type MatchesPatternProps = { regex: RegExp | undefined; value: string; text: string | undefined; }; + const MatchesPattern = ({ regex, value = '', text }: MatchesPatternProps) => { if (!regex || !value.length || regex.test(value)) { return null; } - return ( -

{text}

+

+ {text} +

); }; - diff --git a/frontends/web/src/hooks/keyboard.ts b/frontends/web/src/hooks/keyboard.ts index 6fa388908c..a39b5b07be 100644 --- a/frontends/web/src/hooks/keyboard.ts +++ b/frontends/web/src/hooks/keyboard.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; /** * gets fired on each keydown and executes the provided callback. @@ -54,6 +54,31 @@ export const useEsc = ( }); }; +const excludeKeys = /^(Shift|Alt|Backspace|CapsLock|Tab)$/i; + +const hasCaps = (event: KeyboardEvent) => { + const key = event.key; + // will return null, when we cannot clearly detect if capsLock is active or not + if (key.length > 1 || key.toUpperCase() === key.toLowerCase() || excludeKeys.test(key)) { + return null; + } + // ideally we return event.getModifierState('CapsLock')) but this currently does always return false in Qt + return key.toUpperCase() === key && key.toLowerCase() !== key && !event.shiftKey; +}; + +export const useCapsLock = () => { + const [capsLock, setCapsLock] = useState(false); + + useKeydown((event) => { + const result = hasCaps(event); + if (result !== null) { + setCapsLock(result); + } + }); + + return capsLock; +}; + const FOCUSABLE_SELECTOR = ` a:not(:disabled), button:not(:disabled),