diff --git a/frontends/web/src/components/password.tsx b/frontends/web/src/components/password.tsx index 62d3d4ba84..1920c89846 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,33 +15,23 @@ * 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; id?: string; - idPrefix?: string; label: string; placeholder?: string; 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 +52,259 @@ 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 }); +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(); } - }; - - 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); - } + }, [autoFocus]); - 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 & { 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 }); - } - }; - - 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 PasswordRepeatInput = ({ + 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]); - 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; + } + switch (event.target.id) { + case 'passwordRepeatFirst': + const newPassword = event.target.value; + setPassword(newPassword); + validate(newPassword, passwordRepeat); + break; + case 'passwordRepeatSecond': + const newRepeat = event.target.value; + setPasswordRepeat(newRepeat); + validate(password, newRepeat); + break; } - 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} + + + {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), diff --git a/frontends/web/src/routes/device/bitbox01/backups.tsx b/frontends/web/src/routes/device/bitbox01/backups.tsx index e442028515..dda01994c1 100644 --- a/frontends/web/src/routes/device/bitbox01/backups.tsx +++ b/frontends/web/src/routes/device/bitbox01/backups.tsx @@ -24,10 +24,10 @@ import { SimpleMarkup } from '../../../utils/markup'; import { alertUser } from '../../../components/alert/Alert'; import { Button } from '../../../components/forms'; import { BackupsListItem } from '../components/backup'; -import style from '../components/backups.module.css'; -import Check from './check'; -import Create from './create'; +import { Check } from './check'; +import { Create } from './create'; import { Restore } from './restore'; +import style from '../components/backups.module.css'; type BackupsProps = { deviceID: string; diff --git a/frontends/web/src/routes/device/bitbox01/bitbox01.jsx b/frontends/web/src/routes/device/bitbox01/bitbox01.jsx deleted file mode 100644 index 46a6e54aeb..0000000000 --- a/frontends/web/src/routes/device/bitbox01/bitbox01.jsx +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Copyright 2018 Shift Devices AG - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component } from 'react'; -import { AppUpgradeRequired } from '../../../components/appupgraderequired'; -import { apiGet } from '../../../utils/request'; -import { apiWebsocket } from '../../../utils/websocket'; -import Unlock from './unlock'; -import Bootloader from './upgrade/bootloader'; -import RequireUpgrade from './upgrade/require_upgrade'; -import Goal from './setup/goal'; -import { SecurityInformation } from './setup/security-information'; -import SeedCreateNew from './setup/seed-create-new'; -import SeedRestore from './setup/seed-restore'; -import { Initialize } from './setup/initialize'; -import Success from './setup/success'; -import Settings from './settings/settings'; -import { withTranslation } from 'react-i18next'; -import { AppContext } from '../../../contexts/AppContext'; - -const DeviceStatus = Object.freeze({ - BOOTLOADER: 'bootloader', - INITIALIZED: 'initialized', - UNINITIALIZED: 'uninitialized', - LOGGED_IN: 'logged_in', - SEEDED: 'seeded', - REQUIRE_FIRMWARE_UPGRADE: 'require_firmware_upgrade', - REQUIRE_APP_UPGRADE: 'require_app_upgrade' -}); - -const GOAL = Object.freeze({ - CREATE: 'create', - RESTORE: 'restore' -}); - -class Device extends Component { - static contextType = AppContext; - - state = { - firmwareVersion: null, - deviceStatus: '', - goal: '', - success: null, - }; - - componentDidMount() { - this.onDeviceStatusChanged(); - this.unsubscribe = apiWebsocket(({ type, data, deviceID }) => { - if (type === 'device' && data === 'statusChanged' && deviceID === this.getDeviceID()) { - this.onDeviceStatusChanged(); - } - }); - } - - componentWillUnmount() { - if (this.unsubscribe) { - this.unsubscribe(); - } - } - - onDeviceStatusChanged = () => { - apiGet('devices/' + this.props.deviceID + '/status').then(deviceStatus => { - this.setState({ deviceStatus }); - }); - }; - - getDeviceID() { - return this.props.deviceID || null; - } - - handleCreate = () => { - this.setState({ goal: GOAL.CREATE }); - }; - - handleRestore = () => { - this.setState({ goal: GOAL.RESTORE }); - }; - - handleBack = () => { - this.setState({ goal: null }); - }; - - handleSuccess = () => { - this.setState({ success: true }); - }; - - render() { - const { - deviceID, - } = this.props; - const { - deviceStatus, - goal, - success, - } = this.state; - if (!deviceStatus) { - return null; - } - if (success) { - return this.setState({ success: null })} />; - } - switch (deviceStatus) { - case DeviceStatus.BOOTLOADER: - return ; - case DeviceStatus.REQUIRE_FIRMWARE_UPGRADE: - return ; - case DeviceStatus.REQUIRE_APP_UPGRADE: - return ; - case DeviceStatus.INITIALIZED: - return ; - case DeviceStatus.UNINITIALIZED: - if (!goal) { - return ; - } - return ( - - - - ); - case DeviceStatus.LOGGED_IN: - switch (goal) { - case GOAL.CREATE: - return ( - - ); - case GOAL.RESTORE: - return ( - - ); - default: - return ; - } - case DeviceStatus.SEEDED: - return ; - default: - return null; - } - } -} - -export default withTranslation()(Device); diff --git a/frontends/web/src/routes/device/bitbox01/bitbox01.tsx b/frontends/web/src/routes/device/bitbox01/bitbox01.tsx new file mode 100644 index 0000000000..9dca95a63c --- /dev/null +++ b/frontends/web/src/routes/device/bitbox01/bitbox01.tsx @@ -0,0 +1,161 @@ +/** + * Copyright 2018 Shift Devices AG + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { AppUpgradeRequired } from '@/components/appupgraderequired'; +import { apiGet } from '@/utils/request'; +import { apiWebsocket } from '@/utils/websocket'; +import { Unlock } from './unlock'; +import Bootloader from './upgrade/bootloader'; +import RequireUpgrade from './upgrade/require_upgrade'; +import Goal from './setup/goal'; +import { SecurityInformation } from './setup/security-information'; +import { SeedCreateNew } from './setup/seed-create-new'; +import SeedRestore from './setup/seed-restore'; +import { Initialize } from './setup/initialize'; +import Success from './setup/success'; +import Settings from './settings/settings'; + +const DeviceStatus = Object.freeze({ + BOOTLOADER: 'bootloader', + INITIALIZED: 'initialized', + UNINITIALIZED: 'uninitialized', + LOGGED_IN: 'logged_in', + SEEDED: 'seeded', + REQUIRE_FIRMWARE_UPGRADE: 'require_firmware_upgrade', + REQUIRE_APP_UPGRADE: 'require_app_upgrade', +}); + +const GOAL = Object.freeze({ + CREATE: 'create', + RESTORE: 'restore', +}); + +type DeviceStatusType = (typeof DeviceStatus)[keyof typeof DeviceStatus]; +type GoalType = (typeof GOAL)[keyof typeof GOAL]; + +type Props = { + deviceID: string; +}; + +export const BitBox01 = ({ deviceID }: Props) => { + const [deviceStatus, setDeviceStatus] = useState(''); + const [goal, setGoal] = useState(null); + const [success, setSuccess] = useState(null); + + // --- Fetch device status --- + const onDeviceStatusChanged = useCallback(() => { + apiGet(`devices/${deviceID}/status`).then((status: DeviceStatusType) => { + setDeviceStatus(status); + }); + }, [deviceID]); + + useEffect(() => { + onDeviceStatusChanged(); + const unsubscribe = apiWebsocket((data) => { + if ( + 'type' in data // check if TEventLegacy + && data.type === 'device' + && 'data' in data + && data.data === 'statusChanged' + && data.deviceID === deviceID) { + onDeviceStatusChanged(); + } + }); + + return () => { + if (unsubscribe) { + unsubscribe(); + } + }; + }, [deviceID, onDeviceStatusChanged]); + + const handleCreate = () => setGoal(GOAL.CREATE); + const handleRestore = () => setGoal(GOAL.RESTORE); + const handleBack = () => setGoal(null); + const handleSuccess = () => setSuccess(true); + const handleHideSuccess = () => setSuccess(null); + + if (!deviceStatus) { + return null; + } + + if (success) { + return ( + + ); + } + + switch (deviceStatus) { + case DeviceStatus.BOOTLOADER: + return ( + + ); + case DeviceStatus.REQUIRE_FIRMWARE_UPGRADE: + return ( + + ); + case DeviceStatus.REQUIRE_APP_UPGRADE: + return ( + + ); + case DeviceStatus.INITIALIZED: + return ( + + ); + case DeviceStatus.UNINITIALIZED: + if (!goal) { + return ( + + ); + } + return ( + + + + ); + case DeviceStatus.LOGGED_IN: + switch (goal) { + case GOAL.CREATE: + return ( + + ); + case GOAL.RESTORE: + return ( + + ); + default: + return ( + + ); + } + case DeviceStatus.SEEDED: + return ( + + ); + default: + return null; + } +}; diff --git a/frontends/web/src/routes/device/bitbox01/check.jsx b/frontends/web/src/routes/device/bitbox01/check.jsx deleted file mode 100644 index f34a14270d..0000000000 --- a/frontends/web/src/routes/device/bitbox01/check.jsx +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Copyright 2018 Shift Devices AG - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component } from 'react'; -import { withTranslation } from 'react-i18next'; -import { Button } from '../../../components/forms'; -import { DialogLegacy } from '../../../components/dialog/dialog-legacy'; -import { PasswordSingleInput } from '../../../components/password'; -import { apiPost } from '../../../utils/request'; -// TODO: use DialogButtons -import style from '../../../components/dialog/dialog.module.css'; - -class Check extends Component { - state = { - password: null, - activeDialog: false, - message: null, - }; - - abort = () => { - this.setState({ - password: null, - activeDialog: false, - message: null, - }); - }; - - handleFormChange = event => { - this.setState({ [event.target.id]: event.target.value }); - }; - - validate = () => { - return this.props.selectedBackup && this.state.password; - }; - - check = event => { - event.preventDefault(); - if (!this.validate()) { - return; - } - this.setState({ message: this.props.t('backup.check.checking') }); - - apiPost('devices/' + this.props.deviceID + '/backups/check', { - password: this.state.password, - filename: this.props.selectedBackup, - }).catch(() => {}).then(({ success, matches, errorMessage }) => { - let message; - if (success) { - if (matches) { - message = this.props.t('backup.check.ok'); - } else { - message = this.props.t('backup.check.notOK'); - } - } else if (errorMessage) { - message = errorMessage; - } - this.setState({ message }); - }); - }; - - setValidPassword = password => { - this.setState({ password }); - }; - - render() { - const { - t, - selectedBackup, - } = this.props; - const { - activeDialog, - message, - } = this.state; - return ( -
- - { - activeDialog && ( - - { message ? ( -
-

{message}

-
- -
-
- ) : ( -
- -
- - -
- - )} -
- ) - } -
- ); - } -} - -export default withTranslation()(Check); diff --git a/frontends/web/src/routes/device/bitbox01/check.tsx b/frontends/web/src/routes/device/bitbox01/check.tsx new file mode 100644 index 0000000000..f4360c67c3 --- /dev/null +++ b/frontends/web/src/routes/device/bitbox01/check.tsx @@ -0,0 +1,123 @@ +/** + * Copyright 2018 Shift Devices AG + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState, FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/forms'; +import { DialogLegacy, DialogButtons } from '@/components/dialog/dialog-legacy'; +import { PasswordSingleInput } from '@/components/password'; +import { apiPost } from '@/utils/request'; + +type Props = { + deviceID: string; + selectedBackup?: string; +}; + +export const Check = ({ deviceID, selectedBackup }: Props) => { + const { t } = useTranslation(); + + const [password, setPassword] = useState(null); + const [activeDialog, setActiveDialog] = useState(false); + const [message, setMessage] = useState(null); + + const abort = () => { + setPassword(null); + setActiveDialog(false); + setMessage(null); + }; + + const validate = () => { + return Boolean(selectedBackup && password); + }; + + const handleCheck = async (event: FormEvent) => { + event.preventDefault(); + if (!validate()) { + return; + } + + setMessage(t('backup.check.checking')); + + try { + const { success, matches, errorMessage } = await apiPost( + `devices/${deviceID}/backups/check`, + { + password, + filename: selectedBackup, + } + ); + + if (success) { + setMessage(matches ? t('backup.check.ok') : t('backup.check.notOK')); + } else if (errorMessage) { + setMessage(errorMessage); + } else { + setMessage(t('backup.check.error')); + } + } catch (err) { + setMessage(String(err)); + } + }; + + const handleValidPassword = (pwd: string | null) => { + setPassword(pwd); + }; + + return ( +
+ + + {activeDialog && ( + + {message ? ( +
+

{message}

+ + + +
+ ) : ( +
+ + + + + + + )} +
+ )} +
+ ); +}; diff --git a/frontends/web/src/routes/device/bitbox01/create.jsx b/frontends/web/src/routes/device/bitbox01/create.jsx deleted file mode 100644 index 5ed32c551a..0000000000 --- a/frontends/web/src/routes/device/bitbox01/create.jsx +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Copyright 2018 Shift Devices AG - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component } from 'react'; -import { withTranslation } from 'react-i18next'; -import { Button, Input } from '../../../components/forms'; -import { PasswordInput } from '../../../components/password'; -import { alertUser } from '../../../components/alert/Alert'; -import { apiPost } from '../../../utils/request'; -import { DialogLegacy } from '../../../components/dialog/dialog-legacy'; -// TODO: use DialogButtons -import style from '../../../components/dialog/dialog.module.css'; - -class Create extends Component { - state = { - waiting: false, - backupName: '', - recoveryPassword: '', - activeDialog: false, - }; - - abort = () => { - this.setState({ - waiting: false, - backupName: '', - recoveryPassword: '', - activeDialog: false, - }); - }; - - handleFormChange = event => { - this.setState({ [event.target.id]: event.target.value }); - }; - - validate = () => { - return !this.state.waiting && this.state.backupName !== ''; - }; - - create = event => { - event.preventDefault(); - if (!this.validate()) { - return; - } - this.setState({ waiting: true }); - apiPost('devices/' + this.props.deviceID + '/backups/create', { - backupName: this.state.backupName, - recoveryPassword: this.state.recoveryPassword, - }).then(data => { - this.abort(); - if (!data.success) { - alertUser(data.errorMessage); - } else { - this.props.onCreate(); - if (!data.verification) { - alertUser(this.props.t('backup.create.verificationFailed')); - } - } - }); - }; - - render() { - const { t } = this.props; - const { - waiting, - recoveryPassword, - backupName, - activeDialog, - } = this.state; - return ( -
- - { - activeDialog && ( - -
- -

{t('backup.create.info')}

- -
- - -
- -
- ) - } -
- ); - } -} - -export default withTranslation()(Create); diff --git a/frontends/web/src/routes/device/bitbox01/create.tsx b/frontends/web/src/routes/device/bitbox01/create.tsx new file mode 100644 index 0000000000..6b5c920a29 --- /dev/null +++ b/frontends/web/src/routes/device/bitbox01/create.tsx @@ -0,0 +1,127 @@ +/** + * Copyright 2018 Shift Devices AG + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState, ChangeEvent, FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Input } from '@/components/forms'; +import { PasswordInput } from '@/components/password'; +import { alertUser } from '@/components/alert/Alert'; +import { apiPost } from '@/utils/request'; +import { DialogLegacy, DialogButtons } from '@/components/dialog/dialog-legacy'; + +type Props = { + deviceID: string; + onCreate: () => void; +}; + +export const Create = ({ deviceID, onCreate }: Props) => { + const { t } = useTranslation(); + + const [waiting, setWaiting] = useState(false); + const [backupName, setBackupName] = useState(''); + const [recoveryPassword, setRecoveryPassword] = useState(''); + const [activeDialog, setActiveDialog] = useState(false); + + const abort = () => { + setWaiting(false); + setBackupName(''); + setRecoveryPassword(''); + setActiveDialog(false); + }; + + const handleFormChange = (event: ChangeEvent) => { + const { id, value } = event.target; + if (id === 'backupName') { + setBackupName(value); + } + if (id === 'recoveryPassword') { + setRecoveryPassword(value); + } + }; + + const validate = () => { + return !waiting && backupName.trim() !== ''; + }; + + const create = async (event: FormEvent) => { + event.preventDefault(); + if (!validate()) { + return; + } + + setWaiting(true); + try { + const data = await apiPost(`devices/${deviceID}/backups/create`, { + backupName, + recoveryPassword, + }); + + abort(); + + if (!data.success) { + alertUser(data.errorMessage); + } else { + onCreate(); + if (!data.verification) { + alertUser(t('backup.create.verificationFailed')); + } + } + } catch (error) { + abort(); + alertUser(String(error)); + } + }; + + return ( +
+ + + {activeDialog && ( + +
+ +

{t('backup.create.info')}

+ + + + + + +
+ )} +
+ ); +}; diff --git a/frontends/web/src/routes/device/bitbox01/settings/components/changepin.jsx b/frontends/web/src/routes/device/bitbox01/settings/components/changepin.jsx deleted file mode 100644 index 027f05b35a..0000000000 --- a/frontends/web/src/routes/device/bitbox01/settings/components/changepin.jsx +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Copyright 2018 Shift Devices AG - * Copyright 2021 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. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component } from 'react'; -import { withTranslation } from 'react-i18next'; -import { Button } from '../../../../../components/forms'; -import { alertUser } from '../../../../../components/alert/Alert'; -import { DialogLegacy, DialogButtons } from '../../../../../components/dialog/dialog-legacy'; -import { WaitDialog } from '../../../../../components/wait-dialog/wait-dialog'; -import { PasswordInput, PasswordRepeatInput } from '../../../../../components/password'; -import { apiPost } from '../../../../../utils/request'; -import { SettingsButton } from '../../../../../components/settingsButton/settingsButton'; - -class ChangePIN extends Component { - state = { - oldPIN: null, - newPIN: null, - errorCode: null, - isConfirming: false, - activeDialog: false, - }; - - abort = () => { - this.setState({ - oldPIN: null, - newPIN: null, - isConfirming: false, - activeDialog: false, - }); - }; - - validate = () => { - return this.state.newPIN && this.state.oldPIN; - }; - - changePin = event => { - event.preventDefault(); - if (!this.validate()) { - return; - } - this.setState({ - activeDialog: false, - isConfirming: true, - }); - apiPost('devices/' + this.props.deviceID + '/change-password', { - oldPIN: this.state.oldPIN, - newPIN: this.state.newPIN, - }).catch(() => {}).then(data => { - this.abort(); - if (!data.success) { - alertUser(this.props.t(`bitbox.error.e${data.code}`, { - defaultValue: data.errorMessage, - })); - } - }); - }; - - setValidOldPIN = e => { - this.setState({ oldPIN: e.target.value }); - }; - - setValidNewPIN = newPIN => { - this.setState({ newPIN }); - }; - - render() { - const { - t, - disabled, - } = this.props; - const { - oldPIN, - isConfirming, - activeDialog, - } = this.state; - return ( -
- this.setState({ activeDialog: true })}> - {t('button.changepin')} - - { - activeDialog && ( - -
- - {t('changePin.newTitle') &&

{t('changePin.newTitle')}

} - - - - - - -
- ) - } - { - isConfirming && ( - - ) - } -
- ); - } -} - -export default withTranslation()(ChangePIN); diff --git a/frontends/web/src/routes/device/bitbox01/settings/components/changepin.tsx b/frontends/web/src/routes/device/bitbox01/settings/components/changepin.tsx new file mode 100644 index 0000000000..50c3f4c9ea --- /dev/null +++ b/frontends/web/src/routes/device/bitbox01/settings/components/changepin.tsx @@ -0,0 +1,132 @@ +/** + * Copyright 2018 Shift Devices AG + * Copyright 2021-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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState, ChangeEvent, FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/forms'; +import { alertUser } from '@/components/alert/Alert'; +import { DialogLegacy, DialogButtons } from '@/components/dialog/dialog-legacy'; +import { WaitDialog } from '@/components/wait-dialog/wait-dialog'; +import { PasswordInput, PasswordRepeatInput } from '@/components/password'; +import { apiPost } from '@/utils/request'; +import { SettingsButton } from '@/components/settingsButton/settingsButton'; + +type Props = { + deviceID: string; + disabled?: boolean; +}; + +export const ChangePIN = ({ deviceID, disabled }: Props) => { + const { t } = useTranslation(); + + const [oldPIN, setOldPIN] = useState(null); + const [newPIN, setNewPIN] = useState(null); + const [isConfirming, setIsConfirming] = useState(false); + const [activeDialog, setActiveDialog] = useState(false); + + const abort = () => { + setOldPIN(null); + setNewPIN(null); + setIsConfirming(false); + setActiveDialog(false); + }; + + const validate = () => { + return Boolean(newPIN && oldPIN); + }; + + const changePin = async (event: FormEvent) => { + event.preventDefault(); + if (!validate()) { + return; + } + + setActiveDialog(false); + setIsConfirming(true); + + try { + const data = await apiPost(`devices/${deviceID}/change-password`, { + oldPIN, + newPIN, + }); + + abort(); + + if (!data.success) { + alertUser( + t(`bitbox.error.e${data.code as string}`, { + defaultValue: data.errorMessage, + }) + ); + } + } catch (error) { + abort(); + alertUser(String(error)); + } + }; + + const handleOldPINChange = (e: ChangeEvent) => { + setOldPIN(e.target.value); + }; + + const handleNewPINValid = (pin: string | null) => { + setNewPIN(pin); + }; + + return ( +
+ setActiveDialog(true)}> + {t('button.changepin')} + + + {activeDialog && ( + +
+ + + {t('changePin.newTitle') &&

{t('changePin.newTitle')}

} + + + + + + + + +
+ )} + + {isConfirming && } +
+ ); +}; diff --git a/frontends/web/src/routes/device/bitbox01/settings/components/reset.jsx b/frontends/web/src/routes/device/bitbox01/settings/components/reset.jsx deleted file mode 100644 index 308a4cc2cf..0000000000 --- a/frontends/web/src/routes/device/bitbox01/settings/components/reset.jsx +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Copyright 2018 Shift Devices AG - * Copyright 2021 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. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component } from 'react'; -import { route } from '../../../../../utils/route'; -import { withTranslation } from 'react-i18next'; -import { Button, Checkbox } from '../../../../../components/forms'; -import { DialogLegacy, DialogButtons } from '../../../../../components/dialog/dialog-legacy'; -import { WaitDialog } from '../../../../../components/wait-dialog/wait-dialog'; -import { PasswordInput } from '../../../../../components/password'; -import { apiPost } from '../../../../../utils/request'; -import { alertUser } from '../../../../../components/alert/Alert'; -import style from '../../bitbox01.module.css'; -import { SettingsButton } from '../../../../../components/settingsButton/settingsButton'; - -class Reset extends Component { - state = { - pin: null, - isConfirming: false, - activeDialog: false, - understand: false, - }; - - handleUnderstandChange = (e) => { - this.setState({ understand: e.target.checked }); - }; - - resetDevice = () => { - this.setState({ - activeDialog: false, - isConfirming: true, - }); - apiPost('devices/' + this.props.deviceID + '/reset', { pin: this.state.pin }).then(data => { - this.abort(); - if (data.success) { - if (data.didReset) { - route('/', true); - } - } else if (data.errorMessage) { - alertUser(this.props.t(`bitbox.error.e${data.code}`, { - defaultValue: data.errorMessage, - })); - } - }); - }; - - setValidPIN = e => { - this.setState({ pin: e.target.value }); - }; - - abort = () => { - this.setState({ - pin: null, - understand: false, - isConfirming: false, - activeDialog: false, - }); - }; - - render() { - const { t } = this.props; - const { - isConfirming, - activeDialog, - understand, - pin, - } = this.state; - return ( -
- this.setState({ activeDialog: true })}> - {t('reset.title')} - - { - activeDialog && ( - -

- {t('reset.description')} -

- -
- -
- - - - -
- ) - } - { isConfirming ? ( - - ) : null } -
- ); - } -} - -export default withTranslation()(Reset); diff --git a/frontends/web/src/routes/device/bitbox01/settings/components/reset.tsx b/frontends/web/src/routes/device/bitbox01/settings/components/reset.tsx new file mode 100644 index 0000000000..3387f12bb4 --- /dev/null +++ b/frontends/web/src/routes/device/bitbox01/settings/components/reset.tsx @@ -0,0 +1,122 @@ +/** + * Copyright 2018 Shift Devices AG + * Copyright 2021-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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useState, ChangeEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { route } from '@/utils/route'; +import { Button, Checkbox } from '@/components/forms'; +import { DialogLegacy, DialogButtons } from '@/components/dialog/dialog-legacy'; +import { WaitDialog } from '@/components/wait-dialog/wait-dialog'; +import { PasswordInput } from '@/components/password'; +import { apiPost } from '@/utils/request'; +import { alertUser } from '@/components/alert/Alert'; +import { SettingsButton } from '@/components/settingsButton/settingsButton'; +import style from '../../bitbox01.module.css'; + +type Props = { + deviceID: string; +}; + +export const Reset = ({ deviceID }: Props) => { + const { t } = useTranslation(); + + const [pin, setPin] = useState(null); + const [understand, setUnderstand] = useState(false); + const [activeDialog, setActiveDialog] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + + const handleUnderstandChange = (e: ChangeEvent) => { + setUnderstand(e.target.checked); + }; + + const setValidPIN = (e: ChangeEvent) => { + setPin(e.target.value); + }; + + const abort = () => { + setPin(null); + setUnderstand(false); + setActiveDialog(false); + setIsConfirming(false); + }; + + const resetDevice = async () => { + setActiveDialog(false); + setIsConfirming(true); + + try { + const data = await apiPost(`devices/${deviceID}/reset`, { pin }); + + abort(); + + if (data.success) { + if (data.didReset) { + route('/', true); + } + } else if (data.errorMessage) { + alertUser( + t(`bitbox.error.e${data.code as string}`, { + defaultValue: data.errorMessage, + }) + ); + } + } catch (err) { + abort(); + alertUser(String(err)); + } + }; + + return ( +
+ setActiveDialog(true)}> + {t('reset.title')} + + + {activeDialog && ( + +

{t('reset.description')}

+ + + +
+ +
+ + + + + +
+ )} + + {isConfirming && } +
+ ); +}; diff --git a/frontends/web/src/routes/device/bitbox01/settings/settings.tsx b/frontends/web/src/routes/device/bitbox01/settings/settings.tsx index 6ef025cbc5..b7a4ec8491 100644 --- a/frontends/web/src/routes/device/bitbox01/settings/settings.tsx +++ b/frontends/web/src/routes/device/bitbox01/settings/settings.tsx @@ -25,8 +25,8 @@ import { Entry } from '../../../../components/guide/entry'; import { Header } from '../../../../components/layout'; import { Spinner } from '../../../../components/spinner/Spinner'; import Blink from './components/blink'; -import ChangePIN from './components/changepin'; -import Reset from './components/reset'; +import { ChangePIN } from './components/changepin'; +import { Reset } from './components/reset'; import UpgradeFirmware from '../components/upgradefirmware'; import { SettingsButton } from '../../../../components/settingsButton/settingsButton'; import { SettingsItem } from '../../../../components/settingsButton/settingsItem'; diff --git a/frontends/web/src/routes/device/bitbox01/setup/seed-create-new.jsx b/frontends/web/src/routes/device/bitbox01/setup/seed-create-new.jsx deleted file mode 100644 index 17f90e5435..0000000000 --- a/frontends/web/src/routes/device/bitbox01/setup/seed-create-new.jsx +++ /dev/null @@ -1,264 +0,0 @@ -/** - * Copyright 2018 Shift Devices AG - * Copyright 2022 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. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, createRef } from 'react'; -import { withTranslation } from 'react-i18next'; -import { getDeviceInfo } from '../../../../api/bitbox01'; -import { apiPost } from '../../../../utils/request'; -import { PasswordRepeatInput } from '../../../../components/password'; -import { Button, Input, Checkbox } from '../../../../components/forms'; -import { Message } from '../../../../components/message/message'; -import { SwissMadeOpenSource, SwissMadeOpenSourceDark, Alert, Warning } from '../../../../components/icon'; -import { Header } from '../../../../components/layout'; -import { Spinner } from '../../../../components/spinner/Spinner'; -import { LanguageSwitch } from '../../../../components/language/language'; -import { getDarkmode } from '../../../../components/darkmode/darkmode'; -import style from '../bitbox01.module.css'; - -const STATUS = Object.freeze({ - DEFAULT: 'default', - CREATING: 'creating', - CHECKING: 'checking', - ERROR: 'error', -}); - -class SeedCreateNew extends Component { - state = { - showInfo: true, - status: STATUS.CHECKING, - walletName: '', - backupPassword: '', - error: '', - agreements: { - password_change: false, - password_required: false, - funds_access: false, - }, - }; - - walletNameInput = createRef(); - - componentDidMount () { - this.checkSDcard(); - } - - validate = () => { - if (!this.walletNameInput.current || !this.walletNameInput.current.validity.valid || !this.validAgreements()) { - return false; - } - return this.state.backupPassword && this.state.walletName !== ''; - }; - - handleFormChange = ({ target }) => { - this.setState({ [target.id]: target.value }); - }; - - handleSubmit = event => { - event.preventDefault(); - if (!this.validate()) { - return; - } - this.setState({ status: STATUS.CREATING, error: '' }); - apiPost('devices/' + this.props.deviceID + '/create-wallet', { - walletName: this.state.walletName, - backupPassword: this.state.backupPassword - }).then(data => { - if (!data.success) { - this.setState({ - status: STATUS.ERROR, - error: this.props.t(`seed.error.e${data.code}`, { - defaultValue: data.errorMessage - }), - }); - } else { - this.props.onSuccess(); - } - this.setState({ backupPassword: '' }); - }); - }; - - setValidBackupPassword = backupPassword => { - this.setState({ backupPassword }); - }; - - validAgreements = () => { - const { agreements } = this.state; - const invalid = Object.keys(agreements).map(agr => agreements[agr]).includes(false); - return !invalid; - }; - - handleAgreementChange = ({ target }) => { - this.setState(state => ({ agreements: { - ...state.agreements, - [target.id]: target.checked - } })); - }; - - checkSDcard = () => { - getDeviceInfo(this.props.deviceID) - .then((deviceInfo) => { - if (deviceInfo?.sdcard) { - return this.setState({ status: STATUS.DEFAULT, error: '' }); - } - this.setState({ - status: STATUS.ERROR, - error: this.props.t('seed.error.e200'), - }); - setTimeout(this.checkSDcard, 2500); - }); - }; - - handleStart = () => { - this.setState({ showInfo: false }); - this.checkSDcard(); - }; - - renderSpinner() { - switch (this.state.status) { - case STATUS.CHECKING: - return ( - - ); - case STATUS.CREATING: - return ( - - ); - default: - return null; - } - } - - render() { - const { - t, - goBack, - } = this.props; - const { - showInfo, - status, - walletName, - error, - agreements, - } = this.state; - const content = showInfo ? ( -
-
    -
  1. {t('seed.info.description1')}
  2. -
  3. {t('seed.info.description2')}
  4. -
-

{t('seed.info.description3')}

-

{t('seed.info.description4')}

-
- - -
-
- ) : ( -
-
- - -
-
-
- -

{t('seed.description')}

-
- - - -
-
- - -
-
- ); - - return ( -
-
-
-
{t('welcome.title')}}> - -
-
-

{t('seed.info.title')}

- { - error && ( - - - { error } - - ) - } - {content} -
- {getDarkmode() ? : } -
-
-
- { this.renderSpinner() } -
-
- ); - } -} - -export default withTranslation()(SeedCreateNew); diff --git a/frontends/web/src/routes/device/bitbox01/setup/seed-create-new.tsx b/frontends/web/src/routes/device/bitbox01/setup/seed-create-new.tsx new file mode 100644 index 0000000000..9d3e778fb7 --- /dev/null +++ b/frontends/web/src/routes/device/bitbox01/setup/seed-create-new.tsx @@ -0,0 +1,270 @@ +/** + * Copyright 2018 Shift Devices AG + * Copyright 2022-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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useEffect, useRef, useState, ChangeEvent, FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getDeviceInfo } from '@/api/bitbox01'; +import { apiPost } from '@/utils/request'; +import { PasswordRepeatInput } from '@/components/password'; +import { Button, Input, Checkbox } from '@/components/forms'; +import { Message } from '@/components/message/message'; +import { SwissMadeOpenSource, SwissMadeOpenSourceDark, Alert, Warning } from '@/components/icon'; +import { Header } from '@/components/layout'; +import { Spinner } from '@/components/spinner/Spinner'; +import { LanguageSwitch } from '@/components/language/language'; +import { getDarkmode } from '@/components/darkmode/darkmode'; +import style from '../bitbox01.module.css'; + +const STATUS = Object.freeze({ + DEFAULT: 'default', + CREATING: 'creating', + CHECKING: 'checking', + ERROR: 'error', +}); + +type Props = { + deviceID: string; + goBack: () => void; + onSuccess: () => void; +}; + +type Agreements = { + password_change: boolean; + password_required: boolean; + funds_access: boolean; +}; + +export const SeedCreateNew = ({ + deviceID, + goBack, + onSuccess, +}: Props) => { + const { t } = useTranslation(); + + const [showInfo, setShowInfo] = useState(true); + const [status, setStatus] = useState(STATUS.CHECKING); + const [walletName, setWalletName] = useState(''); + const [backupPassword, setBackupPassword] = useState(''); + const [error, setError] = useState(''); + const [agreements, setAgreements] = useState({ + password_change: false, + password_required: false, + funds_access: false, + }); + + const walletNameInput = useRef(null); + + // --- Lifecycle --- + useEffect(() => { + checkSDcard(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // --- Handlers --- + const handleFormChange = (event: ChangeEvent) => { + const { id, value } = event.target; + if (id === 'walletName') { + setWalletName(value); + } + }; + + const handleAgreementChange = (event: ChangeEvent) => { + const { id, checked } = event.target; + setAgreements(prev => ({ ...prev, [id]: checked })); + }; + + const setValidBackupPassword = (password: string | null) => { + setBackupPassword(password === null ? '' : password); + }; + + const validAgreements = () => { + return Object.values(agreements).every(Boolean); + }; + + const validate = () => { + const walletInput = walletNameInput.current; + if (!walletInput || !walletInput.validity.valid || !validAgreements()) { + return false; + } + return backupPassword.trim() !== '' && walletName.trim() !== ''; + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!validate()) { + return; + } + + setStatus(STATUS.CREATING); + setError(''); + + try { + const data = await apiPost(`devices/${deviceID}/create-wallet`, { + walletName, + backupPassword, + }); + + if (!data.success) { + setStatus(STATUS.ERROR); + setError( + t(`seed.error.e${data.code as string}`, { + defaultValue: data.errorMessage, + }) + ); + } else { + onSuccess(); + } + } catch (err) { + setStatus(STATUS.ERROR); + setError(String(err)); + } finally { + setBackupPassword(''); + } + }; + + const checkSDcard = async () => { + try { + const deviceInfo = await getDeviceInfo(deviceID); + if (deviceInfo?.sdcard) { + setStatus(STATUS.DEFAULT); + setError(''); + } else { + setStatus(STATUS.ERROR); + setError(t('seed.error.e200')); + setTimeout(checkSDcard, 2500); + } + } catch { + setStatus(STATUS.ERROR); + setError(t('seed.error.e200')); + setTimeout(checkSDcard, 2500); + } + }; + + const handleStart = () => { + setShowInfo(false); + checkSDcard(); + }; + + const renderSpinner = () => { + switch (status) { + case STATUS.CHECKING: + return ; + case STATUS.CREATING: + return ; + default: + return null; + } + }; + + const content = showInfo ? ( +
+
    +
  1. {t('seed.info.description1')}
  2. +
  3. {t('seed.info.description2')}
  4. +
+

{t('seed.info.description3')}

+

{t('seed.info.description4')}

+
+ + +
+
+ ) : ( +
+
+ + +
+
+
+ +

{t('seed.description')}

+
+ + + +
+
+ + +
+
+ ); + + return ( +
+
+
+
{t('welcome.title')}}> + +
+
+

{t('seed.info.title')}

+ {error && ( + + + {error} + + )} + {content} +
+ {getDarkmode() ? : } +
+
+
+ {renderSpinner()} +
+
+ ); +}; diff --git a/frontends/web/src/routes/device/bitbox01/unlock.jsx b/frontends/web/src/routes/device/bitbox01/unlock.jsx deleted file mode 100644 index e07cb4d717..0000000000 --- a/frontends/web/src/routes/device/bitbox01/unlock.jsx +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Copyright 2018 Shift Devices AG - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component } from 'react'; -import { route } from '../../../utils/route'; -import { apiGet, apiPost } from '../../../utils/request'; -import { Button } from '../../../components/forms'; -import { PasswordSingleInput } from '../../../components/password'; -import { Message } from '../../../components/message/message'; -import { AppLogo, AppLogoInverted, SwissMadeOpenSource, SwissMadeOpenSourceDark } from '../../../components/icon/logo'; -import { Guide } from '../../../components/guide/guide'; -import { Entry } from '../../../components/guide/entry'; -import { Header, Footer } from '../../../components/layout'; -import { Spinner } from '../../../components/spinner/Spinner'; -import { withTranslation } from 'react-i18next'; -import { getDarkmode } from '../../../components/darkmode/darkmode'; - -const stateEnum = Object.freeze({ - DEFAULT: 'default', - WAITING: 'waiting', - ERROR: 'error' -}); - -class Unlock extends Component { - state = { - status: stateEnum.DEFAULT, - errorMessage: '', - errorCode: null, - remainingAttempts: null, - needsLongTouch: false, - password: '', - }; - - handleFormChange = password => { - this.setState({ password }); - }; - - validate = () => { - return this.state.password !== ''; - }; - - handleSubmit = event => { - event.preventDefault(); - if (!this.validate()) { - return; - } - this.setState({ - status: stateEnum.WAITING - }); - apiPost('devices/' + this.props.deviceID + '/login', { password: this.state.password }).then(data => { - if (data.success) { - apiGet('devices/' + this.props.deviceID + '/status').then(status => { - if (status === 'seeded') { - console.info('unlock.jsx route to /account-summary'); - route('/account-summary', true); - } - }); - } - if (!data.success) { - if (data.code) { - this.setState({ errorCode: data.code }); - } - if (data.remainingAttempts) { - this.setState({ remainingAttempts: data.remainingAttempts }); - } - if (data.needsLongTouch) { - this.setState({ needsLongTouch: data.needsLongTouch }); - } - this.setState({ status: stateEnum.ERROR, errorMessage: data.errorMessage }); - } - }); - this.setState({ password: '' }); - }; - - render() { - const { t } = this.props; - const { - status, - errorCode, - errorMessage, - remainingAttempts, - needsLongTouch, - } = this.state; - let submissionState = null; - switch (status) { - case stateEnum.DEFAULT: - submissionState =

{t('unlock.description')}

; - break; - case stateEnum.WAITING: - submissionState = ; - break; - case stateEnum.ERROR: - submissionState = ( - - {t(`unlock.error.e${errorCode}`, { - defaultValue: errorMessage, - remainingAttempts, - context: needsLongTouch ? 'touch' : 'normal' - })} - - ); - break; - default: - break; - } - - const darkmode = getDarkmode(); - return ( -
-
-
-
{t('welcome.title')}} /> -
- {darkmode ? : } -
- {submissionState} - { - status !== stateEnum.WAITING && ( -
-
- -
-
- -
-
- ) - } -
-
-
- {darkmode ? : } -
-
-
- - - - -
- ); - } -} - -export default withTranslation()(Unlock); diff --git a/frontends/web/src/routes/device/bitbox01/unlock.tsx b/frontends/web/src/routes/device/bitbox01/unlock.tsx new file mode 100644 index 0000000000..c0ecfe612d --- /dev/null +++ b/frontends/web/src/routes/device/bitbox01/unlock.tsx @@ -0,0 +1,182 @@ +/** + * Copyright 2018 Shift Devices AG + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useState, FormEvent, useCallback, ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { route } from '@/utils/route'; +import { apiGet, apiPost } from '@/utils/request'; +import { Button } from '@/components/forms'; +import { PasswordSingleInput } from '@/components/password'; +import { Message } from '@/components/message/message'; +import { AppLogo, AppLogoInverted, SwissMadeOpenSource, SwissMadeOpenSourceDark } from '@/components/icon/logo'; +import { Guide } from '@/components/guide/guide'; +import { Entry } from '@/components/guide/entry'; +import { Header, Footer } from '@/components/layout'; +import { Spinner } from '@/components/spinner/Spinner'; +import { getDarkmode } from '@/components/darkmode/darkmode'; + +const stateEnum = Object.freeze({ + DEFAULT: 'default', + WAITING: 'waiting', + ERROR: 'error', +}); + +type Props = { + deviceID: string; +}; + +type UnlockResponse = { + success: true; +} | { + success: false; + code?: number; + errorMessage?: string; + remainingAttempts?: number; + needsLongTouch?: boolean; +}; + +export const Unlock = ({ deviceID }: Props) => { + const { t } = useTranslation(); + + const [status, setStatus] = useState(stateEnum.DEFAULT); + const [errorMessage, setErrorMessage] = useState(''); + const [errorCode, setErrorCode] = useState(null); + const [remainingAttempts, setRemainingAttempts] = useState(null); + const [needsLongTouch, setNeedsLongTouch] = useState(false); + const [password, setPassword] = useState(''); + + const validate = useCallback(() => password.trim() !== '', [password]); + + const handlePasswordChange = (pwd: string | null) => { + setPassword(pwd === null ? '' : pwd); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!validate()) { + return; + } + + setStatus(stateEnum.WAITING); + + try { + const data: UnlockResponse = await apiPost(`devices/${deviceID}/login`, { password }); + + if (data.success) { + const deviceStatus = await apiGet(`devices/${deviceID}/status`); + if (deviceStatus === 'seeded') { + console.info('unlock.tsx route to /account-summary'); + route('/account-summary', true); + } + } else { + if (data.code) { + setErrorCode(data.code); + } + if (data.remainingAttempts !== undefined) { + setRemainingAttempts(data.remainingAttempts); + } + if (data.needsLongTouch !== undefined) { + setNeedsLongTouch(data.needsLongTouch); + } + + setErrorMessage(data.errorMessage || ''); + setStatus(stateEnum.ERROR); + } + } catch (err) { + setErrorMessage(String(err)); + setStatus(stateEnum.ERROR); + } finally { + setPassword(''); + } + }; + + const darkmode = getDarkmode(); + + let submissionState: ReactNode = null; + switch (status) { + case stateEnum.DEFAULT: + submissionState =

{t('unlock.description')}

; + break; + case stateEnum.WAITING: + submissionState = ; + break; + case stateEnum.ERROR: + submissionState = ( + + {t(`unlock.error.e${errorCode || ''}`, { + defaultValue: errorMessage, + remainingAttempts, + context: needsLongTouch ? 'touch' : 'normal', + })} + + ); + break; + default: + break; + } + + return ( +
+
+
+
{t('welcome.title')}} /> +
+ {darkmode ? : } +
+ {submissionState} + {status !== stateEnum.WAITING && ( +
+
+ +
+
+ +
+
+ )} +
+
+
+ {darkmode ? : } +
+
+
+ + + + +
+ ); +}; diff --git a/frontends/web/src/routes/device/deviceswitch.tsx b/frontends/web/src/routes/device/deviceswitch.tsx index e21237f61c..5fe47a26b1 100644 --- a/frontends/web/src/routes/device/deviceswitch.tsx +++ b/frontends/web/src/routes/device/deviceswitch.tsx @@ -1,5 +1,6 @@ /** * Copyright 2018 Shift Devices AG + * Copyright 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. @@ -14,8 +15,8 @@ * limitations under the License. */ -import { TDevices } from '@/api/devices'; -import BitBox01 from './bitbox01/bitbox01'; +import type { TDevices } from '@/api/devices'; +import { BitBox01 } from './bitbox01/bitbox01'; import { BitBox02 } from './bitbox02/bitbox02'; import { BitBox02Bootloader } from '@/components/devices/bitbox02bootloader/bitbox02bootloader'; import { Waiting } from './waiting'; @@ -26,7 +27,7 @@ type TProps = { hasAccounts: boolean; }; -const DeviceSwitch = ({ deviceID, devices, hasAccounts }: TProps) => { +export const DeviceSwitch = ({ deviceID, devices, hasAccounts }: TProps) => { const deviceIDs = Object.keys(devices); if (deviceID === null || !deviceIDs.includes(deviceID)) { @@ -50,5 +51,3 @@ const DeviceSwitch = ({ deviceID, devices, hasAccounts }: TProps) => { return ; } }; - -export { DeviceSwitch };