From 3a80f7ad45e1efdc94942556e427305f9764ead7 Mon Sep 17 00:00:00 2001 From: thisconnect Date: Sat, 6 Sep 2025 09:26:52 +0200 Subject: [PATCH 1/4] frontend: refactor password input to functional component --- frontends/web/src/components/password.tsx | 497 +++++++++++----------- 1 file changed, 239 insertions(+), 258 deletions(-) diff --git a/frontends/web/src/components/password.tsx b/frontends/web/src/components/password.tsx index 62d3d4ba84..fa29f37c0c 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,8 +15,8 @@ * limitations under the License. */ -import { Component, createRef } from 'react'; -import { TranslateProps, translate } from '@/decorators/translate'; +import { useEffect, useRef, useState, ChangeEvent, ClipboardEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { Input, Checkbox, Field } from './forms'; import { alertUser } from './alert/Alert'; import style from './password.module.css'; @@ -42,6 +42,7 @@ type TPropsPasswordInput = { onInput?: (event: React.ChangeEvent) => void; value: string; }; + export const PasswordInput = ({ seePlaintext, ...rest }: TPropsPasswordInput) => { return ( 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 }); +export const PasswordSingleInput = ({ + idPrefix = '', + pattern, + autoFocus, + disabled, + label, + placeholder, + title, + showLabel, + onValidPassword, +}: TProps) => { + const { t } = useTranslation(); + const [password, setPassword] = useState(''); + const [seePlaintext, setSeePlaintext] = useState(false); + const [capsLock, setCapsLock] = useState(false); + + const passwordRef = useRef(null); + const regexRef = useRef(); + + // Setup regex + autofocus + useEffect(() => { + if (pattern) { + regexRef.current = new RegExp(pattern); } - }; - - 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(); + if (autoFocus && passwordRef.current) { + passwordRef.current.focus(); } - } - - componentWillUnmount() { - window.removeEventListener('keydown', this.handleCheckCaps); - } - - tryPaste = (event: React.ClipboardEvent) => { - const target = event.currentTarget; - if (target.type === 'password') { + }, [pattern, autoFocus]); + + // Listen to caps lock key events + useEffect(() => { + const handleCheckCaps = (event: KeyboardEvent) => { + const result = hasCaps(event); + if (result !== null) { + setCapsLock(result); + } + }; + window.addEventListener('keydown', handleCheckCaps); + return () => { + window.removeEventListener('keydown', handleCheckCaps); + }; + }, []); + + 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 (regexRef.current && 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 & { 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 [password, setPassword] = useState(''); + const [passwordRepeat, setPasswordRepeat] = useState(''); + const [seePlaintext, setSeePlaintext] = useState(false); + const [capsLock, setCapsLock] = useState(false); + + const passwordRef = useRef(null); + const passwordRepeatRef = useRef(null); + const regexRef = useRef(); + + // Setup regex + autofocus + useEffect(() => { + if (pattern) { + regexRef.current = new RegExp(pattern); } - }; - - 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(); + if (autoFocus && passwordRef.current) { + passwordRef.current.focus(); } - } - - componentWillUnmount() { - window.removeEventListener('keydown', this.handleCheckCaps); - } - - tryPaste = (event: React.ClipboardEvent) => { - const target = event.currentTarget; - if (target.type === 'password') { + }, [pattern, autoFocus]); + + // Listen to caps lock key events + useEffect(() => { + const handleCheckCaps = (event: KeyboardEvent) => { + const result = hasCaps(event); + if (result !== null) { + setCapsLock(result); + } + }; + window.addEventListener('keydown', handleCheckCaps); + return () => { + window.removeEventListener('keydown', handleCheckCaps); + }; + }, []); + + 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) + regexRef.current && + 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; -const HOCRepeat = translate(undefined, { withRef: true })(PasswordRepeatInputClass); -export { HOCRepeat as PasswordRepeatInput }; + return ( +
+ + {warning} + + + + + + {warning} + + + + + + + +
+ ); +}; 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} +

); }; - From 42bb33861847128e7f7e3d4f53534b1bf16cbd89 Mon Sep 17 00:00:00 2001 From: thisconnect Date: Sat, 6 Sep 2025 10:37:45 +0200 Subject: [PATCH 2/4] frontend: refactor use-capslock into a hook --- frontends/web/src/components/password.tsx | 45 ++--------------------- frontends/web/src/hooks/keyboard.ts | 27 +++++++++++++- 2 files changed, 30 insertions(+), 42 deletions(-) diff --git a/frontends/web/src/components/password.tsx b/frontends/web/src/components/password.tsx index fa29f37c0c..3f854fe072 100644 --- a/frontends/web/src/components/password.tsx +++ b/frontends/web/src/components/password.tsx @@ -17,21 +17,11 @@ import { useEffect, useRef, useState, ChangeEvent, ClipboardEvent } 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; @@ -76,9 +66,10 @@ export const PasswordSingleInput = ({ onValidPassword, }: TProps) => { const { t } = useTranslation(); + const capsLock = useCapsLock(); + const [password, setPassword] = useState(''); const [seePlaintext, setSeePlaintext] = useState(false); - const [capsLock, setCapsLock] = useState(false); const passwordRef = useRef(null); const regexRef = useRef(); @@ -93,20 +84,6 @@ export const PasswordSingleInput = ({ } }, [pattern, autoFocus]); - // Listen to caps lock key events - useEffect(() => { - const handleCheckCaps = (event: KeyboardEvent) => { - const result = hasCaps(event); - if (result !== null) { - setCapsLock(result); - } - }; - window.addEventListener('keydown', handleCheckCaps); - return () => { - window.removeEventListener('keydown', handleCheckCaps); - }; - }, []); - const tryPaste = (event: ClipboardEvent) => { if (event.currentTarget.type === 'password') { event.preventDefault(); @@ -196,11 +173,11 @@ export const PasswordRepeatInput = ({ onValidPassword, }: TPasswordRepeatProps) => { const { t } = useTranslation(); + const capsLock = useCapsLock(); const [password, setPassword] = useState(''); const [passwordRepeat, setPasswordRepeat] = useState(''); const [seePlaintext, setSeePlaintext] = useState(false); - const [capsLock, setCapsLock] = useState(false); const passwordRef = useRef(null); const passwordRepeatRef = useRef(null); @@ -216,20 +193,6 @@ export const PasswordRepeatInput = ({ } }, [pattern, autoFocus]); - // Listen to caps lock key events - useEffect(() => { - const handleCheckCaps = (event: KeyboardEvent) => { - const result = hasCaps(event); - if (result !== null) { - setCapsLock(result); - } - }; - window.addEventListener('keydown', handleCheckCaps); - return () => { - window.removeEventListener('keydown', handleCheckCaps); - }; - }, []); - const tryPaste = (event: ClipboardEvent) => { if (event.currentTarget.type === 'password') { event.preventDefault(); 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), From f49a51692afbf80a9f8cb159b07681cd43b91553 Mon Sep 17 00:00:00 2001 From: thisconnect Date: Sat, 6 Sep 2025 10:43:50 +0200 Subject: [PATCH 3/4] frontend: use memo to keep the regex if needed --- frontends/web/src/components/password.tsx | 31 ++++++++++------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/frontends/web/src/components/password.tsx b/frontends/web/src/components/password.tsx index 3f854fe072..218f31010c 100644 --- a/frontends/web/src/components/password.tsx +++ b/frontends/web/src/components/password.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { useEffect, useRef, useState, ChangeEvent, ClipboardEvent } from 'react'; +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'; @@ -72,17 +72,13 @@ export const PasswordSingleInput = ({ const [seePlaintext, setSeePlaintext] = useState(false); const passwordRef = useRef(null); - const regexRef = useRef(); - // Setup regex + autofocus + // Autofocus useEffect(() => { - if (pattern) { - regexRef.current = new RegExp(pattern); - } if (autoFocus && passwordRef.current) { passwordRef.current.focus(); } - }, [pattern, autoFocus]); + }, [autoFocus]); const tryPaste = (event: ClipboardEvent) => { if (event.currentTarget.type === 'password') { @@ -96,7 +92,7 @@ export const PasswordSingleInput = ({ }; const validate = (value: string) => { - if (regexRef.current && passwordRef.current && !passwordRef.current.validity.valid) { + if (passwordRef.current && !passwordRef.current.validity.valid) { onValidPassword(null); return; } @@ -181,17 +177,15 @@ export const PasswordRepeatInput = ({ const passwordRef = useRef(null); const passwordRepeatRef = useRef(null); - const regexRef = useRef(); - // Setup regex + autofocus + const regex = useMemo(() => (pattern ? new RegExp(pattern) : null), [pattern]); + + // Autofocus useEffect(() => { - if (pattern) { - regexRef.current = new RegExp(pattern); - } if (autoFocus && passwordRef.current) { passwordRef.current.focus(); } - }, [pattern, autoFocus]); + }, [autoFocus]); const tryPaste = (event: ClipboardEvent) => { if (event.currentTarget.type === 'password') { @@ -206,7 +200,6 @@ export const PasswordRepeatInput = ({ const validate = (pwd: string, pwdRepeat: string) => { if ( - regexRef.current && passwordRef.current && passwordRepeatRef.current && (!passwordRef.current.validity.valid || !passwordRepeatRef.current.validity.valid) @@ -264,7 +257,9 @@ export const PasswordRepeatInput = ({ {warning} - + {regex && ( + + )} - + {regex && ( + + )} Date: Mon, 8 Sep 2025 14:24:09 +0200 Subject: [PATCH 4/4] frontend: remove idprefix from password component --- frontends/web/src/components/password.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontends/web/src/components/password.tsx b/frontends/web/src/components/password.tsx index 218f31010c..aa28e848ec 100644 --- a/frontends/web/src/components/password.tsx +++ b/frontends/web/src/components/password.tsx @@ -43,7 +43,6 @@ export const PasswordInput = ({ seePlaintext, ...rest }: TPropsPasswordInput) => }; type TProps = { - idPrefix?: string; pattern?: string; autoFocus?: boolean; disabled?: boolean; @@ -55,7 +54,6 @@ type TProps = { }; export const PasswordSingleInput = ({ - idPrefix = '', pattern, autoFocus, disabled, @@ -113,12 +111,13 @@ export const PasswordSingleInput = ({ } }; - const warning = + const warning = ( capsLock && !seePlaintext ? ( - ) : null; + ) : null + ); return (