From 118cf4dbdb3d99f953ec89e8a898c24c9f78adc9 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 28 Dec 2025 19:48:43 +0100 Subject: [PATCH 01/27] Backup Codes: export flow --- client/src/PublicRequest.ts | 3 + demos/Export.html | 168 +++++++--- src/components/BackupCodesIllustration.css | 248 +++++++++++++++ src/components/BackupCodesIllustration.js | 300 ++++++++++++++++++ src/components/FlippableHandler.css | 1 + src/components/FlippableHandler.js | 32 +- src/lib/BackupCodes.js | 106 +++++++ src/lib/Key.js | 167 +++++++++- src/request/create/Create.js | 1 + src/request/export/Export.css | 213 ++++++++----- src/request/export/Export.js | 29 +- src/request/export/ExportApi.js | 9 +- src/request/export/ExportBackupCodes.js | 350 +++++++++++++++++++++ src/request/export/ExportFile.js | 2 - src/request/export/index.html | 173 +++++++++- src/request/import/ImportFile.js | 3 +- src/request/import/ImportWords.js | 4 +- src/request/remove-key/index.html | 2 +- src/translations/de.json | 21 +- src/translations/en.json | 21 +- src/translations/es.json | 21 +- src/translations/fr.json | 21 +- src/translations/nl.json | 21 +- src/translations/pt.json | 21 +- src/translations/ru.json | 21 +- src/translations/uk.json | 21 +- src/translations/zh.json | 21 +- tools/build.sh | 11 +- types/ViewTransition.d.ts | 15 + 29 files changed, 1856 insertions(+), 170 deletions(-) create mode 100644 src/components/BackupCodesIllustration.css create mode 100644 src/components/BackupCodesIllustration.js create mode 100644 src/lib/BackupCodes.js create mode 100644 src/request/export/ExportBackupCodes.js create mode 100644 types/ViewTransition.d.ts diff --git a/client/src/PublicRequest.ts b/client/src/PublicRequest.ts index 98c6820f8..14407ec93 100644 --- a/client/src/PublicRequest.ts +++ b/client/src/PublicRequest.ts @@ -31,6 +31,7 @@ export type SingleKeyResult = { }>; fileExported: boolean; wordsExported: boolean; + backupCodesExported: boolean; bitcoinXPub?: string; polygonAddresses?: Array<{ address: string, @@ -155,11 +156,13 @@ export type SimpleRequest = BasicRequest & { export type ExportRequest = SimpleRequest & { fileOnly?: boolean, wordsOnly?: boolean, + backupCodesOnly?: boolean, }; export type ExportResult = { fileExported: boolean, wordsExported: boolean, + backupCodesExported: boolean, }; type SignTransactionRequestCommon = SimpleRequest & TransactionInfo; diff --git a/demos/Export.html b/demos/Export.html index 19411c2e3..273356750 100644 --- a/demos/Export.html +++ b/demos/Export.html @@ -13,6 +13,34 @@
+
+ + +
+
+ + + + +
@@ -21,71 +49,109 @@
- diff --git a/src/components/BackupCodesIllustration.css b/src/components/BackupCodesIllustration.css new file mode 100644 index 000000000..169b4e6cb --- /dev/null +++ b/src/components/BackupCodesIllustration.css @@ -0,0 +1,248 @@ +.backup-codes-illustration { + contain: size layout paint style; + display: flex; + justify-content: center; + align-items: center; + position: relative; + width: 100%; + height: 26rem; + padding: 0; +} + +/* message-bubble variables */ + +.backup-codes-illustration .message-bubble { + --zoom: 1; + /* to avoid rendering at sub-pixel precision, we round values */ + --message-bubble-width: round(calc(27rem * var(--zoom)), 1px); + --message-bubble-min-height: round(calc(12rem * var(--zoom)), 1px); + --message-bubble-padding: round(calc(1.5rem * var(--zoom)), 1px); + --message-bubble-padding-bottom: round(calc(2.5rem * var(--zoom)), 1px); + --label-font-size: round(calc(1.75rem * var(--zoom)), 1px); + --label-margin-bottom: round(calc(1rem * var(--zoom)), 1px); + --code-font-size: round(calc(1.75rem * var(--zoom)), 1px); + --counter-size: round(calc(3rem * var(--zoom)), 1px); + --counter-offset: round(calc(3rem * var(--zoom) * .4), 1px); + --counter-font-size: round(calc(1.5rem * var(--zoom)), 1px); + --counter-checkmark-size: round(calc(1.25rem * var(--zoom)), 1px); +} + +/* fallback if rounding is not supported */ +@supports not (width: round(1.2px, 1px)) { + .backup-codes-illustration .message-bubble { + --message-bubble-width: calc(27rem * var(--zoom)); + --message-bubble-min-height: calc(12rem * var(--zoom)); + --message-bubble-padding: calc(1.5rem * var(--zoom)); + --message-bubble-padding-bottom: calc(2.5rem * var(--zoom)); + --label-font-size: calc(1.75rem * var(--zoom)); + --label-margin-bottom: calc(1rem * var(--zoom)); + --code-font-size: calc(1.75rem * var(--zoom)); + --counter-size: calc(3rem * var(--zoom)); + --counter-offset: calc(3rem * var(--zoom) * .4); + --counter-font-size: calc(1.5rem * var(--zoom)); + --counter-checkmark-size: calc(1.25rem * var(--zoom)); + } +} + +/* common message-bubble styles */ + +.backup-codes-illustration .message-bubble { + position: absolute; + + width: var(--message-bubble-width); + min-height: var(--message-bubble-min-height); /* min-height instead of height to allow code to break into 3 lines */ + padding: var(--message-bubble-padding); + padding-bottom: var(--message-bubble-padding-bottom); + + line-height: 1; + filter: drop-shadow(0 0 calc(20px * var(--zoom)) rgba(0, 0, 0, 0.3)) + drop-shadow(0 calc(1.34px * var(--zoom)) calc(4.47px * var(--zoom)) rgba(59, 76, 106, 0.0775)) + drop-shadow(0 calc(0.4px * var(--zoom)) calc(1.33px * var(--zoom)) rgba(59, 76, 106, 0.0525)); +} + +/* background */ +/* As separate element with mask-image, such that the drop-shadow is not cut off by the mask-image. */ + +.backup-codes-illustration .message-bubble .background { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + z-index: -1; +} +.backup-codes-illustration .message-bubble.code-1 .background { + /* left message bubble */ + background-image: radial-gradient(141.42% 141.42% at 100% 100%, #693BC4 0%, #8F3FD5 100%); + /* As mask-image to be able to scale with the zoomed element, and clip-path with shape() as alternative is not + widely supported yet */ + mask-image: url('data:image/svg+xml,'); +} +.backup-codes-illustration .message-bubble.code-2 .background { + /* right message bubble */ + background-image: radial-gradient(141.42% 141.42% at 0% 100%, #DC1845 0%, #F33F68 100%); + /* As mask-image to be able to scale with the zoomed element, and clip-path with shape() as alternative is not + widely supported yet */ + mask-image: url('data:image/svg+xml,'); +} + +/* label and backup code */ + +.backup-codes-illustration .message-bubble .label { + margin-bottom: var(--label-margin-bottom); + font-size: var(--label-font-size); + font-weight: 500; + line-height: 1; + color: rgba(255, 255, 255, .6); +} + +.backup-codes-illustration .message-bubble .code { + font-size: var(--code-font-size); + font-family: inherit; + font-weight: 500; + line-height: 1.2; + letter-spacing: -.0095em; /* best effort to limit the code to two lines of text, which works for most codes */ + word-break: break-all; + color: white; +} + +/* counter circle */ + +.backup-codes-illustration .message-bubble::after, +.backup-codes-illustration .message-bubble .background::after { + display: flex; + justify-content: center; + align-items: center; + position: absolute; + width: var(--counter-size); + height: var(--counter-size); + top: calc(-1 * var(--counter-offset)); + left: calc(-1 * var(--counter-offset)); + border-radius: 50%; + font-size: var(--counter-font-size); + font-weight: 700; + background-color: white; +} +.backup-codes-illustration .message-bubble.code-1::after { + content: '1'; + color: #8D3FD4; +} +.backup-codes-illustration .message-bubble.code-2::after { + content: '2'; + color: #F33E67; +} +.backup-codes-illustration .message-bubble .background::after { + /* To avoid overlapping drop shadows, we don't have individual drop shadows on the message-bubble background and the + counter circle, but instead have a single drop shadow on the entire message-bubble. However, the counter circle + should also cast part of its shadow on the background, which we emulate via a copy of the circle on the background + element. Note that the shadow of that copy is limited to the background via the background's mask-image, such that + this shadow does not overlap with the message-bubble's shadow. */ + content: ''; + filter: drop-shadow(0 0 calc(20px * var(--zoom)) rgba(0, 0, 0, 0.2)) + drop-shadow(0 calc(1.34px * var(--zoom)) calc(4.47px * var(--zoom)) rgba(59, 76, 106, 0.1775)) + drop-shadow(0 calc(0.4px * var(--zoom)) calc(1.33px * var(--zoom)) rgba(59, 76, 106, 0.1525)); +} + +/* message-bubble states */ + +.backup-codes-illustration .message-bubble:is(.masked, .loading) .label { + color: white; +} +.backup-codes-illustration .message-bubble:is(.masked, .loading) .code { + /* Make the text transparent and redact it with a rounded box as background image instead. As the text is an inline + element, the background is applied to the lines of text, nicely matching the length of the lines. */ + line-height: 1.2; + color: transparent; + background-image: url('data:image/svg+xml,'); + /* Apply background to each line of text individually, such that each line has the rounded borders. */ + box-decoration-break: clone; + -webkit-box-decoration-break: clone; + user-select: none; + pointer-events: none; +} +.backup-codes-illustration .message-bubble:is(.masked, .loading) .code:empty::after { + /* Placeholder content if no code is set yet */ + content: '----------------------------------------------------------'; +} + +.backup-codes-illustration .message-bubble:not(.faded).loading .code { + animation: backup-codes-illustration-loading-animation .8s cubic-bezier(0.76, 0.29, 0.29, 0.76) alternate infinite; +} + +@keyframes backup-codes-illustration-loading-animation { + from { opacity: 1; } + to { opacity: .6; } +} + +.backup-codes-illustration .message-bubble.faded { + /* Remove drop shadow and make the message-bubble transparent white. */ + filter: brightness(255) opacity(.1); +} + +.backup-codes-illustration .message-bubble.zoomed { + --zoom: calc(10 / 7); +} +.backup-codes-illustration .message-bubble.zoomed .code { + line-height: 1.35; +} + +.backup-codes-illustration:not(.backup-codes-success) .message-bubble.complete.code-1 .background { + background-image: radial-gradient(100% 100% at 100% 100%, #41A38E 0%, #21BCA5 100%); +} +.backup-codes-illustration:not(.backup-codes-success) .message-bubble.complete.code-2 .background { + background-image: radial-gradient(100% 100% at 0% 100%, #41A38E 0%, #21BCA5 100%); +} +.backup-codes-illustration .message-bubble.complete::after { + /* replace counter with a checkmark icon */ + content: ''; + background-image: url('data:image/svg+xml,'); + background-size: var(--counter-checkmark-size); + background-repeat: no-repeat; + background-position: center; +} + +/* message-bubble positioning for individual steps */ + +.backup-codes-illustration.backup-codes-intro .message-bubble.code-1, +.backup-codes-illustration.backup-codes-success .message-bubble.code-1 { + transform: translate(-4.5rem, -3.5rem); + transform: translate(round(-4.5rem, 1px), round(-3.5rem, 1px)); +} +.backup-codes-illustration.backup-codes-intro .message-bubble.code-2, +.backup-codes-illustration.backup-codes-success .message-bubble.code-2 { + transform: translate(4.5rem, 3.5rem); + transform: translate(round(4.5rem, 1px), round(3.5rem, 1px)); +} + +.backup-codes-illustration.backup-codes-send-code-1 .message-bubble.code-2, +.backup-codes-illustration.backup-codes-send-code-1-confirm .message-bubble.code-2 { + transform: translate(12.5rem, 5.25rem); + transform: translate(round(12.5rem, 1px), round(5.25rem, 1px)); + z-index: -1; +} + +.backup-codes-illustration.backup-codes-send-code-2 .message-bubble.code-1, +.backup-codes-illustration.backup-codes-send-code-2-confirm .message-bubble.code-1 { + transform: translate(-12.5rem, -5.25rem); + transform: translate(round(-12.5rem, 1px), round(-5.25rem, 1px)); +} + +/* View transition setup */ + +.page:not(.disable-view-transition-names):target .backup-codes-illustration .message-bubble.code-1, +.page.enforce-view-transition-names .backup-codes-illustration .message-bubble.code-1 { + view-transition-name: backup-codes-illustration-code-1; +} +.page:not(.disable-view-transition-names):target .backup-codes-illustration .message-bubble.code-2, +.page.enforce-view-transition-names .backup-codes-illustration .message-bubble.code-2 { + view-transition-name: backup-codes-illustration-code-2; +} +::view-transition-group(*) { + animation-duration: var(--backup-codes-view-transition-duration); /* set in BackupCodesIllustration.js */ +} +::view-transition-old(backup-codes-illustration-code-1), +::view-transition-new(backup-codes-illustration-code-1), +::view-transition-old(backup-codes-illustration-code-2), +::view-transition-new(backup-codes-illustration-code-2) { + height: 100%; +} diff --git a/src/components/BackupCodesIllustration.js b/src/components/BackupCodesIllustration.js new file mode 100644 index 000000000..37d269b19 --- /dev/null +++ b/src/components/BackupCodesIllustration.js @@ -0,0 +1,300 @@ +/* global I18n */ +/* global TemplateTags */ +/* global ExportBackupCodes */ + +/** + * @typedef {Exclude} BackupCodesIllustrationStep + */ + +class BackupCodesIllustration { // eslint-disable-line no-unused-vars + /** + * @param {?BackupCodesIllustrationStep} [step] + * @param {?HTMLDivElement} [$el] + */ + constructor(step, $el) { + this.$el = BackupCodesIllustration._createElement($el); + this._messageBubbles = /** @type {[HTMLDivElement, HTMLDivElement]} */ ( + Array.from(this.$el.querySelectorAll('.message-bubble'))); + this._codes = /** @type {[HTMLDivElement, HTMLDivElement]} */ ( + Array.from(this.$el.querySelectorAll('.code'))); + if (step) { + this.setStep(step); + } + } + + /** + * @param {?HTMLDivElement} [$el] + * @returns {HTMLDivElement} + */ + static _createElement($el) { + $el = $el || document.createElement('div'); + $el.classList.add('backup-codes-illustration'); + + $el.innerHTML = TemplateTags.noVars` +
+
+
Nimiq Backup Code {n}/2
+ +
+
+
+
Nimiq Backup Code {n}/2
+ +
+ `; + + $el.querySelectorAll('.label').forEach((label, index) => I18n.translateToHtmlContent( + /** @type {HTMLElement} */ (label), + 'backup-codes-illustration-label', + { n: (index + 1).toString() }, + )); + + return $el; + } + + /** + * @returns {HTMLDivElement} + */ + getElement() { + return this.$el; + } + + /** + * @param {BackupCodesIllustrationStep} step + * @param {HTMLElement} [newParent] + */ + setStep(step, newParent) { + for (const s of Object.values(ExportBackupCodes.Pages)) { + this.$el.classList.toggle(s, s === step); + } + + const { + INTRO, + SEND_CODE_1, + SEND_CODE_1_CONFIRM, + SEND_CODE_2, + SEND_CODE_2_CONFIRM, + SUCCESS, + } = ExportBackupCodes.Pages; + this._messageBubbles.forEach((bubble, index) => { + bubble.classList.toggle( + 'masked', + step === INTRO + || ([SEND_CODE_1, SEND_CODE_1_CONFIRM].some(s => s === step) && index === 1), + ); + bubble.classList.toggle( + 'faded', + ([SEND_CODE_1, SEND_CODE_1_CONFIRM].some(s => s === step) && index === 1) + || ([SEND_CODE_2, SEND_CODE_2_CONFIRM].some(s => s === step) && index === 0), + ); + bubble.classList.toggle( + 'zoomed', + [SEND_CODE_1, SEND_CODE_1_CONFIRM, SEND_CODE_2, SEND_CODE_2_CONFIRM].some(s => s === step), + ); + bubble.classList.toggle( + 'complete', + ([SEND_CODE_1_CONFIRM, SEND_CODE_2].some(s => s === step) && index === 0) + || [SEND_CODE_2_CONFIRM, SUCCESS].some(s => s === step), + ); + }); + + if (newParent) { + newParent.appendChild(this.$el); + } + } + + /** + * @param {boolean} isLoading + */ + setLoading(isLoading) { + for (const bubble of this._messageBubbles) { + bubble.classList.toggle('loading', isLoading); + } + } + + /** + * @param {1 | 2} codeIndex + * @param {string} code + */ + setCode(codeIndex, code) { + this._codes[codeIndex - 1].textContent = code; + } + + /** + * @param {ViewTransition} viewTransition + * @param {BackupCodesIllustrationStep} previousStep + * @param {BackupCodesIllustrationStep} newStep + * @param {HTMLElement} $viewport + * @returns {Promise} + */ + static async customizeViewTransition(viewTransition, previousStep, newStep, $viewport) { + const TRANSITION_DURATION_LONG = 500; + const TRANSITION_DURATION_SHORT = 300; + const STEP_TOP_MESSAGE_BUBBLE = { + [ExportBackupCodes.Pages.INTRO]: 2, + [ExportBackupCodes.Pages.SEND_CODE_1]: 1, + [ExportBackupCodes.Pages.SEND_CODE_1_CONFIRM]: 1, + [ExportBackupCodes.Pages.SEND_CODE_2]: 2, + [ExportBackupCodes.Pages.SEND_CODE_2_CONFIRM]: 2, + [ExportBackupCodes.Pages.SUCCESS]: 2, + }; + + const previousTopMessageBubble = STEP_TOP_MESSAGE_BUBBLE[previousStep]; + const newTopMessageBubble = STEP_TOP_MESSAGE_BUBBLE[newStep]; + const isSwitchingMessageBubbles = previousTopMessageBubble !== newTopMessageBubble; + const transitionDuration = isSwitchingMessageBubbles ? TRANSITION_DURATION_LONG : TRANSITION_DURATION_SHORT; + const transitionOptions = Object.freeze({ + duration: transitionDuration, + fill: 'both', + }); + + document.documentElement.style.setProperty( + '--backup-codes-view-transition-duration', + `${transitionDuration}ms`, + ); + + if (newStep === previousStep) return; // no further customization needed; just go with the browser default + + await viewTransition.ready; + const viewport = $viewport.getBoundingClientRect(); + for (let codeIndex = 1; codeIndex <= 2; codeIndex++) { + const transitionName = `backup-codes-illustration-code-${codeIndex}`; + + // Extract transition of size and position from ::view-transition-group. + const transitionGroup = `::view-transition-group(${transitionName})`; + const transitionGroupSizeAndPositionAnimation = document.getAnimations().find( + ({ effect }) => !!effect && 'pseudoElement' in effect && effect.pseudoElement === transitionGroup, + ); + if (!transitionGroupSizeAndPositionAnimation + || !(transitionGroupSizeAndPositionAnimation.effect instanceof KeyframeEffect)) continue; + const sizeAndPositions = transitionGroupSizeAndPositionAnimation.effect.getKeyframes().map(keyframe => { + const width = Number.parseFloat(String(keyframe.width)); + const height = Number.parseFloat(String(keyframe.height)); + const [translateX, translateY] = [ + /(?<=matrix\((?:[^,]+,\s*){4}|translateX?\()(\d+(?:.\d+)?)/, + /(?<=matrix\((?:[^,]+,\s*){5}|translateY\(|translate\([^,]+,\s*)(\d+(?:.\d+)?)/, + ].map(regex => { + const regexMatch = String(keyframe.transform).match(regex); + if (!regexMatch || !regexMatch[1]) return 0; + return Number.parseFloat(regexMatch[1]); + }); + return { width, height, translateX, translateY }; // eslint-disable-line object-curly-newline + }); + + // Setup ::view-transition-group as viewport and hide any overflow. While the properties we're setting are + // not really animatable and meant to be animated, animate() provides a convenient way for setting them on a + // pseudo-element and only temporary. + const transitionGroupConstantStyles = { + top: `${viewport.top}px`, + left: `${viewport.left}px`, + width: `${viewport.width}px`, + height: `${viewport.height}px`, + overflow: 'hidden', // hide content outside the viewport + transform: 'none', // disable default positioning by browser + }; + transitionGroupSizeAndPositionAnimation.cancel(); // cancel default animation of browser + document.documentElement.animate([{ + ...transitionGroupConstantStyles, + zIndex: codeIndex === previousTopMessageBubble ? 1 : 0, + }, { + ...transitionGroupConstantStyles, + zIndex: codeIndex === newTopMessageBubble ? 1 : 0, + }], { + ...transitionOptions, + pseudoElement: transitionGroup, + }); + + // Instead of the default transition of the size and position on ::view-transition-group, we transition them + // on the ::view-transition-image-pair, also further customizing the transition. Additionally, instead of + // transitioning the size via with and height, as the default transition, we transition those via transform + // for improved performance. + const transitionImagePair = `::view-transition-image-pair(${transitionName})`; + const transitionImagePairConstantStyles = { + width: `${sizeAndPositions[0].width}px`, + height: `${sizeAndPositions[0].height}px`, + transformOrigin: 'top left', + }; + const translateXStart = sizeAndPositions[0].translateX - viewport.left; + const translateYStart = sizeAndPositions[0].translateY - viewport.top; + const translateXEnd = sizeAndPositions[1].translateX - viewport.left; + const translateYEnd = sizeAndPositions[1].translateY - viewport.top; + const scaleXStart = 1; + const scaleYStart = 1; + const scaleXEnd = sizeAndPositions[1].width / sizeAndPositions[0].width; + const scaleYEnd = sizeAndPositions[1].height / sizeAndPositions[0].height; + const transitionImagePairKeyframeStart = { + ...transitionImagePairConstantStyles, + transform: `translate(${translateXStart}px, ${translateYStart}px) ` + + `scale(${scaleXStart}, ${scaleYStart})`, + }; + const transitionImagePairKeyframeEnd = { + ...transitionImagePairConstantStyles, + transform: `translate(${translateXEnd}px, ${translateYEnd}px) ` + + `scale(${scaleXEnd}, ${scaleYEnd})`, + }; + if (previousTopMessageBubble === newTopMessageBubble) { + // No switch of which message bubble is on top. + document.documentElement.animate( + [transitionImagePairKeyframeStart, transitionImagePairKeyframeEnd], + { + ...transitionOptions, + pseudoElement: transitionImagePair, + }, + ); + // In this case, let ::view-transition-old and ::view-transition-new animate with the browser's default + // transition of fading between the two. + } else { + // A switch of which message bubble is on top. + // The message bubbles should first + // - move side by side, with code 1 moving up and code 2 moving down along the y-axis, and both message + // bubbles being their scaled-up size + // - pause there + // - then re-stack at their final size, swapping which one is on top of the stack along the z-axis (by a + // shift of z-index of transitionGroup). + const translateXMid = (translateXStart + translateXEnd) / 2; + const translateYMid = (translateYStart + translateYEnd) / 2 + // Code 1 moves up, code 2 moves down; the code that goes on top moves less. + + (codeIndex === 1 ? -1 : 1) * (codeIndex === newTopMessageBubble ? 0.25 : 0.40) + * Math.max(sizeAndPositions[0].height, sizeAndPositions[1].height); + const scaleXMid = Math.max(scaleXStart, scaleXEnd); + const scaleYMid = Math.max(scaleYStart, scaleYEnd); + const transitionImagePairKeyframeMid = { + ...transitionImagePairConstantStyles, + transform: `translate(${translateXMid}px, ${translateYMid}px) ` + + `scale(${scaleXMid}, ${scaleYMid})`, + }; + document.documentElement.animate( + [ + transitionImagePairKeyframeStart, + transitionImagePairKeyframeMid, + transitionImagePairKeyframeMid, // pause by specifying the mid keyframe twice + transitionImagePairKeyframeEnd, + ], + { + ...transitionOptions, + pseudoElement: transitionImagePair, + }, + ); + + for (const image of ['old', 'new']) { + const opacityStart = image === 'old' ? 1 : 0; + const opacityEnd = 1 - opacityStart; + // When message bubbles are next to each other (i.e. in the mid state), they should both display + // their non-zoomed (if the zoom changes) or non-grayed-out (zoomed non-background) content. + const isZooming = scaleXStart !== scaleXEnd; + const isZoomingIn = scaleXStart < scaleXEnd; + const isStartingInForeground = codeIndex === previousTopMessageBubble; + const opacityMid = (isZooming ? isZoomingIn : isStartingInForeground) ? opacityStart : opacityEnd; + document.documentElement.animate( + { + opacity: [opacityStart, opacityMid, opacityMid, opacityEnd], + }, { + ...transitionOptions, + pseudoElement: `::view-transition-${image}(${transitionName})`, + }, + ); + } + } + } + } +} diff --git a/src/components/FlippableHandler.css b/src/components/FlippableHandler.css index 8105241cc..5e8afbf68 100644 --- a/src/components/FlippableHandler.css +++ b/src/components/FlippableHandler.css @@ -16,6 +16,7 @@ pointer-events: none; } +#rotation-container.disabled, #rotation-container.disable-transition { transition: none; } diff --git a/src/components/FlippableHandler.js b/src/components/FlippableHandler.js index 3cdda5fb9..b8ac3c9f5 100644 --- a/src/components/FlippableHandler.js +++ b/src/components/FlippableHandler.js @@ -1,4 +1,20 @@ class FlippableHandler { + /** + * @returns {boolean} + */ + static get disabled() { + return !!this._disabled; + } + + /** + * @param {boolean} shouldDisable + */ + static set disabled(shouldDisable) { + const $rotationContainer = /** @type {HTMLElement} */ (document.getElementById('rotation-container')); + $rotationContainer.classList.toggle('disabled', shouldDisable); + this._disabled = shouldDisable; + } + /** * Classname 'flipped' is the default flippable name, and has its css already available. * If other classes should be flipped to the backside they need to be added to FlippableHandler.css @@ -18,12 +34,19 @@ class FlippableHandler { }, 0); } } + window.addEventListener('hashchange', event => { const newHash = new URL(event.newURL).hash; const oldHash = new URL(event.oldURL).hash; + const $oldEl = oldHash ? document.querySelector(oldHash) || undefined : undefined; + const $newEl = newHash ? document.querySelector(newHash) || undefined : undefined; + + if (FlippableHandler.disabled) { + FlippableHandler._updateContainerHeight($newEl); + return; + } + if (oldHash && newHash) { - const $oldEl = document.querySelector(oldHash); - const $newEl = document.querySelector(newHash); if ($newEl && $oldEl && $newEl.classList.contains(classname) !== $oldEl.classList.contains(classname)) { $newEl.classList.add('display-flex'); @@ -35,10 +58,9 @@ class FlippableHandler { FlippableHandler._updateContainerHeight($newEl); }, 0); } else { - FlippableHandler._updateContainerHeight($newEl || undefined); + FlippableHandler._updateContainerHeight($newEl); } } else if (newHash) { - const $newEl = document.querySelector(newHash); if ($newEl && $newEl.classList.contains(classname)) { $rotationContainer.classList.add('disable-transition'); FlippableHandler._updateContainerHeight($newEl); @@ -48,7 +70,7 @@ class FlippableHandler { }, 0); } else { $rotationContainer.classList.add('disable-transition'); - FlippableHandler._updateContainerHeight($newEl || undefined); + FlippableHandler._updateContainerHeight($newEl); window.setTimeout(() => { $rotationContainer.classList.remove('disable-transition'); }, 0); diff --git a/src/lib/BackupCodes.js b/src/lib/BackupCodes.js new file mode 100644 index 000000000..6b8efb3ab --- /dev/null +++ b/src/lib/BackupCodes.js @@ -0,0 +1,106 @@ +/* global Nimiq */ +/* global Key */ + +/* eslint-disable no-bitwise */ + +class BackupCodes { + /** + * Deterministically generate two Backup Codes which can be used together to recover the underlying key. + * For a given key, always the same Backup Codes are generated. + * @param {Key} key + * @returns {Promise<[string, string]>} + */ + static async generate(key) { + const version = BackupCodes.VERSION; + const flags = key.secret instanceof Nimiq.PrivateKey + ? BackupCodes.FLAG_IS_LEGACY_PRIVATE_KEY + : BackupCodes.FLAG_NONE; + const versionAndFlags = (version & BackupCodes.VERSION_BIT_MASK) + | ((flags << BackupCodes.FLAGS_BIT_SHIFT) & BackupCodes.FLAGS_BIT_MASK); + const secretBytes = key.secret.serialize(); + + // Note that we don't need to include a checksum, because the codes themselves can serve as that as they're + // basically derived from the secret and metadata by hashing. + const plainText = new Nimiq.SerialBuffer(/* versionAndFlags */ 1 + secretBytes.length); + plainText.writeUint8(versionAndFlags); + plainText.write(secretBytes); + + // We generate one code from the key via a key derivation function (kdf), which is then applied to the plaintext + // as a one-time-pad (otp) to yield the second code. We use Argon2id as kdf (as opposed to, for example, pbkdf2) + // due to its memory-hardness. + const derivationUseCase = `BackupCodes - ${versionAndFlags}`; // include metadata in derivation / checksum + const code1Bytes = await key.deriveSecret(derivationUseCase, 'Argon2id', 8, plainText.length); + const code2Bytes = Nimiq.BufferUtils.xor(plainText, code1Bytes); + + const code1 = BackupCodes._renderCode(code1Bytes); + const code2 = BackupCodes._renderCode(code2Bytes); + return [code1, code2]; + } + + /** + * @param {string} code1 + * @param {string} code2 + * @returns {Promise} + */ + static async recoverKey(code1, code2) { + const code1Bytes = BackupCodes._parseCode(code1); + const code2Bytes = BackupCodes._parseCode(code2); + if (code1Bytes.byteLength !== /* versionAndFlags */ 1 + Nimiq.Secret.SIZE + || code2Bytes.byteLength !== code1Bytes.byteLength) { + throw new Error('Invalid Backup Codes: invalid length'); + } + const plainText = Nimiq.BufferUtils.xor(code1Bytes, code2Bytes); + + const versionAndFlags = plainText[0]; + const version = versionAndFlags & BackupCodes.VERSION_BIT_MASK; + const flags = (versionAndFlags & BackupCodes.FLAGS_BIT_MASK) >> BackupCodes.FLAGS_BIT_SHIFT; + + if (version > BackupCodes.VERSION) throw new Error('Invalid Backup Codes: unsupported version'); + const isLegacyPrivateKey = !!(flags & BackupCodes.FLAG_IS_LEGACY_PRIVATE_KEY); + + const secretBytes = plainText.slice(/* versionAndFlags */ 1); + const secret = isLegacyPrivateKey ? new Nimiq.PrivateKey(secretBytes) : new Nimiq.Entropy(secretBytes); + const key = new Key(secret); + + // Check whether our recovered key would derive the same codes, as a checksum check. + const [checksum1, checksum2] = await BackupCodes.generate(key); + if (checksum1 !== code1 || checksum2 !== code2) throw new Error('Invalid Backup Codes: checksum mismatch'); + + return key; + } + + /** + * @private + * @param {Uint8Array} code + * @returns {string} + */ + static _renderCode(code) { + return Nimiq.BufferUtils.toBase64(code) + .replace(/=+$/g, '') // Remove trailing padding. + // Replace special characters of base64 (+ and /) with less wide characters to keep the rendered code short. + .replace(/\//g, '!') + .replace(/\+/g, ';'); + } + + /** + * @private + * @param {string} code + * @returns {Uint8Array} + */ + static _parseCode(code) { + return Nimiq.BufferUtils.fromBase64(code.replace(/!/g, '/').replace(/;/g, '+')); + } +} + +BackupCodes.VERSION = 0; +BackupCodes.FLAG_NONE = 0; +BackupCodes.FLAG_IS_LEGACY_PRIVATE_KEY = 1 << 0; +// We store the version and flags combined in a single byte, with the flags (F) being stored in the most significant +// bits and the version (V) stored in big-endian format in the least significant bits (e.g. FVVVVVVV). This gives us the +// flexibility to introduce more flags and increase version numbers without the two overlapping for quite some while. +BackupCodes.FLAGS_COUNT = 1; +BackupCodes.FLAGS_BIT_SHIFT = 8 - BackupCodes.FLAGS_COUNT; +BackupCodes.FLAGS_BIT_MASK = (0xff << BackupCodes.FLAGS_BIT_SHIFT) & 0xff; +BackupCodes.VERSION_BIT_MASK = (~BackupCodes.FLAGS_BIT_MASK) & 0xff; + +/* eslint-enable no-bitwise */ diff --git a/src/lib/Key.js b/src/lib/Key.js index a63f507b4..9908baec4 100644 --- a/src/lib/Key.js +++ b/src/lib/Key.js @@ -156,6 +156,155 @@ class Key { : this._secret; } + /** + * Deterministically derive a secret from the key's secret via a key derivation function. The use of a kdf ensures + * that the generated secret is computationally expensive to generate, and can thus be exposed securely, without the + * risk of exposing the underlying key's secret or making brute-forcing the key's secret more feasible by serving as + * a cheap hint for the correct secret. + * @param {string} useCase - Allows to generate a separate secret per use case. + * @param {'PBKDF2-SHA512' | 'Argon2d' | 'Argon2id'} kdfAlgorithm + * @param {number} kdfIterations + * @param {number} derivedSecretLength - Size in bytes. + * @returns {Promise} + */ + async deriveSecret(useCase, kdfAlgorithm, kdfIterations, derivedSecretLength) { + const seedBytes = this.secret.serialize(); + // As we want to deterministically derive secrets, we have to use a deterministic salt too, instead of a random + // salt. This leverages the fact that the underlying seed is already a very high entropy input, and thus no + // random salt is required for additional entropy, rainbow table resistance or resilience to reused input + // (typically password reuse). + let salt; + if (useCase === 'RSA Seed' && kdfAlgorithm === 'PBKDF2-SHA512') { + // Legacy salt for the RSA Seed kept for compatibility. + salt = this._defaultAddress.serialize(); + } else { + // Generate a salt specific to the use case and kdf parameters. + const saltCustomization = Utf8Tools.stringToUtf8ByteArray([ + useCase, + kdfAlgorithm, + kdfIterations, + derivedSecretLength, + // Use different salts for legacy PrivateKey based accounts, and modern Entropy based accounts, to avoid + // deriving the same secrets if their underlying secret bytes are the same. + this.secret instanceof Nimiq.PrivateKey ? 'PrivateKey' : 'Entropy', + ].join()); + // We use hkdf to derive the salt. This will likely not add much to the security of the final derived secret + // compared to simply using just saltCustomization as salt directly, as it's still just derived from the + // seed and the kdf parameters, but adding another kdf into the mix won't hurt either, and hkdf is cheap. + const hkdfParams = { + name: 'HKDF', + hash: 'SHA-256', + salt: saltCustomization, + info: saltCustomization, + }; + const hkdfKeyMaterial = await window.crypto.subtle.importKey( + /* format */ 'raw', + /* keyData */ seedBytes, + /* algorithm */ hkdfParams, // The key material is to be used in a HKDF derivation. + /* extractable */ false, + /* keyUsages */ ['deriveBits'], + ); + salt = new Uint8Array(await window.crypto.subtle.deriveBits( + /* algorithm */ hkdfParams, + /* baseKey */ hkdfKeyMaterial, + /* length */ 256, + )); + } + + switch (kdfAlgorithm) { + case 'PBKDF2-SHA512': { + const pbkdf2Params = { + name: 'PBKDF2', + hash: 'SHA-512', + salt, + iterations: kdfIterations, + }; + const pbkdf2KeyMaterial = await window.crypto.subtle.importKey( + /* format */ 'raw', + /* keyData */ seedBytes, + /* algorithm */ pbkdf2Params, // The key material is to be used in a PBKDF2 derivation. + /* extractable */ false, + /* keyUsages */ ['deriveBits'], + ); + return new Uint8Array(await window.crypto.subtle.deriveBits( + /* algorithm */ pbkdf2Params, + /* baseKey */ pbkdf2KeyMaterial, + /* length */ derivedSecretLength * 8, + )); + } + case 'Argon2d': { + // Argon2d isn't supported by the browser's subtle crypto APIs and Nimiq PoS only provides a synchronous + // method for Argon2d, but we can get away with not having to run a web worker by using the asynchronous + // otpKdf, from which the Argon2d hash can be reconstructed by canceling out the dummy data via a second + // xor. + const dummyData = new Uint8Array(derivedSecretLength); + return Nimiq.BufferUtils.xor( + await Nimiq.CryptoUtils.otpKdf(dummyData, seedBytes, salt, kdfIterations), + dummyData, + ); + } + case 'Argon2id': { + // Argon2id isn't supported by the browser's subtle crypto API and Nimiq PoS only provides a synchronous + // method for it. We run it in a worker to avoid blocking the main thread. This unfortunately introduces + // some overhead by having to start the worker thread and having to load Nimiq in the worker. + // We need to specify the Nimiq PoS module as absolute path, as the worker's import.meta is a blob url. + // Note that this path gets adapted by the build script for production. + const nimiqPath = new URL('../../../node_modules/@nimiq/core/web/index.js', window.location.href).href; + const workerScript = new Blob([` + import * as Nimiq from '${nimiqPath}'; + self.addEventListener('message', async event => { + try { + if (typeof event.data !== 'object') throw new Error('Unexpected worker message'); + const { seedBytes, salt, kdfIterations, derivedSecretLength } = event.data; + await Nimiq.default(); + const response = Nimiq.Hash.computeNimiqArgon2id( + seedBytes, + salt, + kdfIterations, + derivedSecretLength, + ); + self.postMessage(response, { transfer: [response.buffer] }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : String(e); + self.postMessage('Error in deriveSecret Argon2id worker: ' + errorMessage); + } + }); + `], { type: 'text/javascript' }); + const workerUrl = URL.createObjectURL(workerScript); + const worker = new Worker(workerUrl, { type: 'module' }); + try { + return await new Promise((resolve, reject) => { + worker.onmessage = event => { + worker.onmessage = null; + worker.onerror = null; + if (event.data instanceof Uint8Array) { + resolve(event.data); + } else { + reject(event.data); + } + }; + worker.onerror = error => { + worker.onmessage = null; + worker.onerror = null; + const errorMessage = error instanceof Error ? error.message : String(error); + reject(new Error(`Error in deriveSecret Argon2id worker: ${errorMessage}`)); + }; + worker.postMessage( + // eslint-disable-next-line object-curly-newline + { seedBytes, salt, kdfIterations, derivedSecretLength }, + { transfer: [seedBytes.buffer, salt.buffer] }, + ); + }); + } finally { + URL.revokeObjectURL(workerUrl); + worker.terminate(); + } + } + default: + throw new Error(`Unsupported KDF algorithm: ${kdfAlgorithm}`); + } + } + /** * @param {Uint8Array} hkdfSalt * @param {string} useCase - Allows to generate a separate AES key per use case. @@ -281,28 +430,24 @@ class Key { /** @type {Promise} */ const loadPromise = new Promise(resolve => iframe.addEventListener('load', resolve)); document.body.appendChild(iframe); - await loadPromise; - - if (!iframe.contentWindow) { - throw new Error('Could not load sandboxed RSA iframe'); - } // Extend 32-byte secret into 1024-byte seed /** @type {Uint8Array} */ let seed; switch (keyParams.kdf) { case 'PBKDF2-SHA512': - seed = Nimiq.CryptoUtils.computePBKDF2sha512( - this.secret.serialize(), - this._defaultAddress.serialize(), - keyParams.iterations, - 1024, // Output size (required) - ); + seed = await this.deriveSecret('RSA Seed', keyParams.kdf, keyParams.iterations, 1024); break; default: throw new Error(`Unsupported KDF function: ${keyParams.kdf}`); } + await loadPromise; + + if (!iframe.contentWindow) { + throw new Error('Could not load sandboxed RSA iframe'); + } + // Send computation command to iframe iframe.contentWindow.postMessage({ command: 'generateKey', diff --git a/src/request/create/Create.js b/src/request/create/Create.js index a2b6a9454..6af46badf 100644 --- a/src/request/create/Create.js +++ b/src/request/create/Create.js @@ -181,6 +181,7 @@ class Create { }], fileExported: true, wordsExported: false, + backupCodesExported: false, bitcoinXPub: new BitcoinKey(key).deriveExtendedPublicKey(request.bitcoinXPubPath), polygonAddresses: [{ address: new PolygonKey(key).deriveAddress(polygonKeypath), diff --git a/src/request/export/Export.css b/src/request/export/Export.css index b22bfab61..2202b9c6b 100644 --- a/src/request/export/Export.css +++ b/src/request/export/Export.css @@ -36,12 +36,12 @@ ul.nq-list li > .nq-icon { .page#recovery-words .recovery-words { height: 39rem; - margin: 0; position: relative; margin: -4rem; - padding: 0rem 3rem; - -webkit-mask-image: linear-gradient(0deg , rgba(255,255,255,0), rgba(255,255,255, 1) 4rem, rgba(255,255,255,1) calc(100% - 4rem), rgba(255,255,255,0)); + padding: 0 3rem; mask-image: linear-gradient(0deg , rgba(255,255,255,0), rgba(255,255,255, 1) 4rem, rgba(255,255,255,1) calc(100% - 4rem), rgba(255,255,255,0)); + overflow-x: hidden; + overflow-y: scroll; } .recovery-words .words-container .word-section { @@ -82,6 +82,26 @@ ul.nq-list li > .nq-icon { opacity: 1; } +/* blur when page is displayed in background */ +.page#recovery-words .page-header .warning, +.page#recovery-words .words-container { + transition: filter .6s, opacity .6s; +} +.page#recovery-words:not(:target) .page-header .warning, +.page#recovery-words:not(:target) .words-container { + filter: blur(10px); + opacity: .4; +} +/* hide elements which are covered or don't look nice in the background when blurred */ +.page#recovery-words:not(:target) .page-header > :not(.warning), +.page#recovery-words:not(:target) .page-footer { + opacity: 0; +} +/* hide scroll bar when page is displayed in background */ +.page#recovery-words:not(:target) .recovery-words { + overflow-y: hidden; +} + .page#recovery-words-intro .warning { margin-top: 1.5rem; } @@ -108,16 +128,14 @@ ul.nq-list li > .nq-icon { margin-right: auto; } -.page#login-file-unlock .nq-card-body, -.page#recovery-words-unlock .nq-card-body { +.page:is(#login-file-unlock, #recovery-words-unlock, #backup-codes-unlock) .nq-card-body { display: flex; flex-direction: column; align-items: center; justify-content: space-around; } -.page#login-file-unlock .nq-card-body .nq-icon, -.page#recovery-words-unlock .nq-card-body .nq-icon { +.page:is(#login-file-unlock, #recovery-words-unlock, #backup-codes-unlock) .nq-card-body .nq-icon { width: 8rem; height: auto; opacity: 0.3; @@ -170,15 +188,21 @@ ul.nq-list li > .nq-icon { flex-direction: column; } +/* blur when page is displayed in background */ .page#login-file-download img { display: flex; transition: filter .6s, opacity .6s; - opacity: .15; - -webkit-filter: blur(20px); - -moz-filter: blur(20px); - -o-filter: blur(20px); - -ms-filter: blur(20px); +} +.page#login-file-download:not(:target) img { filter: blur(20px); + opacity: .15; +} +/* hide elements which don't look nice in the background when blurred */ +.page#login-file-download:not(:target) .page-header, +.page#login-file-download:not(:target) button, +.page#login-file-download:not(:target) .download-button, +.page#login-file-download:not(:target) .continue { + opacity: 0; } .page#login-file-download.loginfile-download-initiated .page-header .nq-h1:not(.confirm-download), @@ -186,89 +210,28 @@ ul.nq-list li > .nq-icon { display: none; } -.page#recovery-words .page-header .warning, -.page#recovery-words .words-container { - transition: filter .6s, opacity .6s; - opacity: .4; - -webkit-filter: blur(10px); - -moz-filter: blur(10px); - -o-filter: blur(10px); - -ms-filter: blur(10px); - filter: blur(10px); -} - -.page#recovery-words .recovery-words { - overflow-x: hidden; - overflow-y: hidden; -} - -.page#login-file-download:target img, -.page#recovery-words:target .page-header .warning, -.page#recovery-words:target .words-container { - opacity: 1; - -webkit-filter: blur(0px); - -moz-filter: blur(0px); - -o-filter: blur(0px); - -ms-filter: blur(0px); - filter: blur(0px); -} - -.page#recovery-words:target .recovery-words { - overflow-y: scroll; - overflow-x: hidden; -} - -.page#login-file-unlock:target ~ .page#login-file-download, -.page#login-file-success:target ~ .page#login-file-download, -.page#recovery-words-intro:target ~ .page#login-file-download, +.page:is(#login-file-unlock, #login-file-success, #recovery-words-intro):target ~ .page#login-file-download, .page#recovery-words-intro:target ~ .page#login-file-success, -.page#recovery-words-unlock:target ~ .page#recovery-words { - display: flex; /* display previous page as blurred background or backside during flip */ +.page#recovery-words-unlock:target ~ .page#recovery-words, +.page#backup-codes-unlock:target ~ .page#backup-codes-intro { + display: flex; /* display page as blurred background or on backside during flip */ max-height: 70.5rem; /* avoid that page overflows the foreground page */ } -.page#recovery-words .page-header h1, -.page#recovery-words .page-footer, -.page#recovery-words .page-header .progress-indicator, -.page#recovery-words .page-header .page-header-back-button, -.page#login-file-download .page-header, -.page#login-file-download button, -.page#login-file-download .download-button, -.page#login-file-download .continue { - opacity: 0; -} - -.page#recovery-words:target .page-header h1, -.page#recovery-words:target .page-footer, -.page#recovery-words:target .page-header .progress-indicator, -.page#login-file-download:target .page-header, -.page#login-file-download:target button, -.page#login-file-download:target .download-button { - opacity: 1; -} - -.page#recovery-words:target .page-header .page-header-back-button { - opacity: .4; -} -.page#recovery-words:target .page-header .page-header-back-button:hover, -.page#recovery-words:target .page-header .page-header-back-button:focus { - opacity: 1; -} - .page#login-file-unlock, .page#login-file-success, -.page#recovery-words-unlock { +.page#recovery-words-unlock, +.page#backup-codes-unlock { background-image: none; - background-color: rgba(255, 255, 255, .0); /* transparent white */ + background-color: rgba(255, 255, 255, .0); /* transparent white, such that background page is visible */ } .page#login-file-success { - opacity: 0; transition: opacity .3s .1s ease; } -.page#login-file-success:target { - opacity: 1; +.page#login-file-success:not(:target) { + opacity: 0; } .page#login-file-success .page-body { @@ -339,3 +302,89 @@ ul.nq-list li > .nq-icon { .page#login-file-success p { text-align: center; } + +/* backup codes view transition setup */ +.page[id*="backup-codes"]:not(.disable-view-transition-names):target, +.page[id*="backup-codes"].enforce-view-transition-names { + view-transition-name: backup-codes-page; +} +#rotation-container:has(.page[id*="backup-codes"]:target) ~ .global-close { + view-transition-name: global-close; +} +.page[id*="backup-codes"]:not(.disable-view-transition-names):target .nq-button, +.page[id*="backup-codes"].enforce-view-transition-names .nq-button { + view-transition-name: backup-codes-continue-button; +} +.page[id*="backup-codes"]:not(.disable-view-transition-names):target .page-header-back-button, +.page[id*="backup-codes"].enforce-view-transition-names .page-header-back-button { + view-transition-name: backup-codes-page-header-back-button; +} +::view-transition-old(backup-codes-page), +::view-transition-new(backup-codes-page) { + height: 100%; +} + +/* blur when page is displayed in background */ +.page#backup-codes-intro :is(.page-header, .page-footer) .nq-notice, +.page#backup-codes-intro .backup-codes-illustration { + transition: filter .6s, opacity .6s; +} +.page#backup-codes-intro:not(.enforce-view-transition-names):not(:target) :is(.page-header, .page-footer) .nq-notice { + filter: blur(10px); + opacity: .4; +} +.page#backup-codes-intro:not(.enforce-view-transition-names):not(:target) .backup-codes-illustration { + filter: blur(20px); + opacity: .15; +} +/* hide elements which don't look nice in the background when blurred */ +.page#backup-codes-intro:not(.enforce-view-transition-names):not(:target) :is(.page-header, .page-footer) > :not(.nq-notice) { + opacity: 0; +} + +/* individual export backup codes page and element styles */ + +.page:is([id*="backup-codes"]):not(#backup-codes-unlock) .page-header { + padding-top: 3rem; + padding-bottom: 1.5rem; +} + +.page:is(#backup-codes-intro, #backup-codes-send-code-1, #backup-codes-send-code-2, #backup-codes-success) .nq-notice { + text-align: center; + line-height: 1.38; +} + +.page:is(#backup-codes-intro, #backup-codes-send-code-1, #backup-codes-send-code-2, #backup-codes-success) .page-header .nq-notice, +.page#backup-codes-send-code-1 .page-footer .nq-notice { + margin-top: 1.75rem; + color: rgba(255, 255, 255, .7); +} + +.page:is(#backup-codes-intro, #backup-codes-success) .page-footer .nq-notice { + margin: 1rem 0; +} + +.page#backup-codes-intro .page-footer .nq-notice { + margin-bottom: 1.25rem; + white-space: pre-line; +} + +.page#backup-codes-success .page-footer .nq-notice { + margin: 0 3rem 3.25rem; +} + +.page#backup-codes-send-code-1.loading .page-footer .hide-loading, +.page#backup-codes-send-code-1:not(.loading) .page-footer .show-loading { + display: none; +} + +.page#backup-codes-send-code-1 .page-footer .nq-link { + margin: -1.5rem 0 1rem; +} + +.page:is(#backup-codes-send-code-1-confirm, #backup-codes-send-code-2-confirm) .page-footer .nq-button-s { + margin-bottom: 2rem; + padding: 0 3rem; + align-self: center; + background: rgba(255, 255, 255, .1); +} diff --git a/src/request/export/Export.js b/src/request/export/Export.js index f657663df..86138ca13 100644 --- a/src/request/export/Export.js +++ b/src/request/export/Export.js @@ -1,5 +1,6 @@ /* global ExportFile */ /* global ExportWords */ +/* global ExportBackupCodes */ /* global Nimiq */ /** @@ -21,6 +22,7 @@ class Export { this.exported = { wordsExported: false, fileExported: false, + backupCodesExported: false, }; this._exportWordsHandler = new ExportWords(request, @@ -29,6 +31,9 @@ class Export { this._exportFileHandler = new ExportFile(request, this._fileExportSuccessful.bind(this), this._reject.bind(this)); + this._exportBackupCodesHandler = new ExportBackupCodes(request, + this._backupCodesExportSuccessful.bind(this), + this._reject.bind(this)); /** @type {HTMLElement} */ (document.querySelector(`#${ExportFile.Pages.LOGIN_FILE_INTRO} .page-header-back-button`)) @@ -42,6 +47,11 @@ class Export { .classList.add('display-none'); } + // Note: the Export flow supports exporting the Login File followed by the Recovery Words in a combined flow for + // historical reasons. This is however not actually used anymore in Nimiq's apps, and also not in third-party + // apps, as the Hub Export request is only whitelisted for Nimiq domains. Support for this combined flow could + // thus be removed. The newer export of backup codes is only supported individually anymore. + this._fileSuccessPage = /** @type {HTMLDivElement} */ ( document.getElementById(Export.Pages.LOGIN_FILE_SUCCESS)); @@ -58,6 +68,7 @@ class Export { this._exportWordsHandler.run(); }); + // For a combined Login File + Recovery Words export, set the key on the other handler, if it has been unlocked. this._exportFileHandler.on(ExportFile.Events.KEY_CHANGED, key => this._exportWordsHandler.setKey(key)); this._exportWordsHandler.on(ExportWords.Events.KEY_CHANGED, @@ -65,15 +76,20 @@ class Export { } run() { - if (this._request.wordsOnly || this._request.keyInfo.type === Nimiq.Secret.Type.PRIVATE_KEY) { + if (this._request.backupCodesOnly) { + this._exportBackupCodesHandler.run(); + } else if (this._request.wordsOnly + // Legacy private keys do not support Login Files. Let them export to Recovery Words by instead. + || this._request.keyInfo.type === Nimiq.Secret.Type.PRIVATE_KEY) { this._exportWordsHandler.run(); } else { + // In general, by default, start the flow with the Login File export, potentially to be followed by the + // Recovery Words export. this._exportFileHandler.run(); } } /** - * * @param {KeyguardRequest.SimpleResult} fileResult */ _fileExportSuccessful(fileResult) { @@ -89,13 +105,20 @@ class Export { } /** - * * @param {KeyguardRequest.SimpleResult} wordsResult */ _wordsExportSuccessful(wordsResult) { this.exported.wordsExported = wordsResult.success; this._resolve(this.exported); } + + /** + * @param {KeyguardRequest.SimpleResult} backupCodesResult + */ + _backupCodesExportSuccessful(backupCodesResult) { + this.exported.backupCodesExported = backupCodesResult.success; + this._resolve(this.exported); + } } Export.Pages = { diff --git a/src/request/export/ExportApi.js b/src/request/export/ExportApi.js index aef42dcbd..fe274135d 100644 --- a/src/request/export/ExportApi.js +++ b/src/request/export/ExportApi.js @@ -19,8 +19,13 @@ class ExportApi extends TopLevelApi { // eslint-disable-line no-unused-vars parsedRequest.keyLabel = this.parseLabel(request.keyLabel); parsedRequest.fileOnly = this.parseBoolean(request.fileOnly); parsedRequest.wordsOnly = this.parseBoolean(request.wordsOnly); - if (parsedRequest.fileOnly && parsedRequest.wordsOnly) { - throw new Errors.InvalidRequestError('fileOnly and wordsOnly cannot both be set to true.'); + parsedRequest.backupCodesOnly = this.parseBoolean(request.backupCodesOnly); + if ([ + parsedRequest.fileOnly, + parsedRequest.wordsOnly, + parsedRequest.backupCodesOnly, + ].filter(Boolean).length > 1) { + throw new Errors.InvalidRequestError('Only one of fileOnly, wordsOnly, backupCodesOnly can be requested'); } return parsedRequest; diff --git a/src/request/export/ExportBackupCodes.js b/src/request/export/ExportBackupCodes.js new file mode 100644 index 000000000..fe921d543 --- /dev/null +++ b/src/request/export/ExportBackupCodes.js @@ -0,0 +1,350 @@ +/* global FlippableHandler */ +/* global I18n */ +/* global PasswordBox */ +/* global ProgressIndicator */ +/* global BackupCodesIllustration */ +/* global BackupCodes */ +/* global KeyStore */ +/* global AccountStore */ +/* global Errors */ +/* global Utf8Tools */ +/* global ClipboardUtils */ +/* global TopLevelApi */ +/* global Key */ + +/** + * @callback ExportBackupCodes.resolve + * @param {KeyguardRequest.SimpleResult} result + */ + +// Note: different to ExportWords and ExportFile, ExportBackupCodes is currently not planned to be part of a longer flow +// including multiple backup options, and thus does not need to flip the page via FlippableHandler, notify others of an +// unlocked key via Observable, or receive an unlocked key from others via a setKey method. +class ExportBackupCodes { + /** + * @param {Parsed} request + * @param {ExportBackupCodes.resolve} resolve + * @param {reject} reject + */ + constructor(request, resolve, reject) { + this._request = request; + this._resolve = resolve; + this._reject = reject; + + /** @type {Promise<[string, string]>} */ + this._backupCodesPromise = Promise.resolve(['', '']); + + // pages + /** @type {ExportBackupCodes.Pages[]} */ + this._pageIds = Object.values(ExportBackupCodes.Pages); + /** @type {Record} */ + this._pagesById = this._pageIds.reduce( + (result, pageId) => ({ ...result, [pageId]: /** @type {HTMLElement} */ (document.getElementById(pageId)) }), + /** @type {Record} */ ({}), + ); + + // illustrations + /** @type {Record} */ + this._illustrationsByStep = this._pageIds.reduce( + (result, pageId) => { + if (pageId === ExportBackupCodes.Pages.UNLOCK) return result; + const $page = this._pagesById[pageId]; + const $illustration = /** @type {HTMLDivElement} */ ($page.querySelector('.backup-codes-illustration')); + return { + ...result, + [pageId]: new BackupCodesIllustration(pageId, $illustration), + }; + }, + /** @type {Record} */ ({}), + ); + + // password box + const $passwordBox = /** @type {HTMLFormElement} */ ( + this._pagesById[ExportBackupCodes.Pages.UNLOCK].querySelector('.password-box')); + this._passwordBox = new PasswordBox($passwordBox, { + buttonI18nTag: 'passwordbox-log-in', + hideInput: !request.keyInfo.encrypted, + minLength: request.keyInfo.hasPin ? Key.PIN_LENGTH : undefined, + }); + this._passwordBox.on(PasswordBox.Events.SUBMIT, this._generateCodes.bind(this)); + TopLevelApi.focusPasswordBox(); + + // current view transition + /** @type {ExportBackupCodes.Pages | null} */ + this._currentlyTransitioningNewPageId = null; + /** @type {ViewTransition | null} */ + this._currentViewTransition = null; + + // Handle heading and continue button translation interpolations. + for (const codeIndex of [/** @type {'1'} */('1'), /** @type {'2'} */('2')]) { + const pageId = ExportBackupCodes.Pages[`SEND_CODE_${codeIndex}`]; + const $page = this._pagesById[pageId]; + const $heading = /** @type {HTMLHeadingElement} */ ($page.querySelector('.nq-h1')); + const $continueButton = /** @type {HTMLButtonElement} */ ($page.querySelector('.nq-button')); + I18n.translateToHtmlContent($heading, 'export-backup-codes-send-code-heading', { n: codeIndex }); + I18n.translateToHtmlContent($continueButton, 'export-backup-codes-send-code-copy', { n: codeIndex }); + } + + // progress indicators, continue buttons and back buttons + let progressIndicatorStep = 1; + for (const pageId of this._pageIds) { + const $page = this._pagesById[pageId]; + + // progress indicator + const $progressIndicator = /** @type {HTMLDivElement} */ ($page.querySelector('.progress-indicator')); + new ProgressIndicator($progressIndicator, 5, progressIndicatorStep); // eslint-disable-line no-new + if (pageId !== ExportBackupCodes.Pages.SEND_CODE_1 && pageId !== ExportBackupCodes.Pages.SEND_CODE_2) { + // Advance the progress indicator step if the page is not followed by a confirmation page. + progressIndicatorStep += 1; + } + + // continue button + const $continueButton = $page.querySelector('.nq-button:not(.submit)'); + if ($continueButton) { + // If the page has a continue button (that is not in the password form), add the event listener. + $continueButton.addEventListener('click', async () => { + if (pageId === ExportBackupCodes.Pages.SUCCESS) { + this._resolve({ success: true }); + return; + } + const backupCodesHandlingPromise = this._backupCodesPromise.then(([code1, code2]) => { + if (pageId === ExportBackupCodes.Pages.INTRO) { + // Set code 2 in the INTRO page's foreground message bubble when the page shouldn't be + // visible anymore, for if that was skipped in _generateCodes. + setTimeout(() => this._illustrationsByStep[pageId].setCode(2, code2), 100); + } + if (pageId === ExportBackupCodes.Pages.SEND_CODE_1) { + ClipboardUtils.copy(code1); + } + if (pageId === ExportBackupCodes.Pages.SEND_CODE_2) { + ClipboardUtils.copy(code2); + } + }); + if (pageId !== ExportBackupCodes.Pages.INTRO) { + // Only on the INTRO page allow continuing to the next page when the backup codes are not ready + // yet, in which case a loading animation will then be shown on the following SEND_CODE_1 page. + // Note that on all the other pages, the codes should already be available, and the promise + // should resolve instantly, by design of the flow. + await backupCodesHandlingPromise; + } + this._changePage('forward'); + }); + } + + // back button + const $backButton = $page.querySelector('.nq-button-s'); + if ($backButton) { + // If the page has a back button, add the event listener. + $backButton.addEventListener('click', () => this._changePage('back')); + } + } + + // Augment browser navigations with view transition animations. + window.addEventListener('popstate', event => { + const hasUAVisualTransition = 'hasUAVisualTransition' in event && !!event.hasUAVisualTransition; + // At the time of a popstate event, location.hash is already updated, but the document / DOM not yet and the + // hashchange event has not triggered yet. + const oldTarget = document.querySelector(':target'); + const oldPageId = oldTarget ? /** @type {ExportBackupCodes.Pages} */ (oldTarget.id) : null; + const newPageId = /** @type {ExportBackupCodes.Pages} */ (window.location.hash.replace(/^#/, '')); + if (!oldPageId || !newPageId || !this._shouldTransitionView(oldPageId, newPageId) + // The user agent already provided a visual transition itself (e.g. swipe back). + || hasUAVisualTransition + ) return; + + // Before transitioning the view, temporarily show the old page and enforce view transition names on it + // instead of on the new :target for the View Transition API to be able to capture snapshots of it. Note + // that this would not be necessary when using the Navigation API as it can detect and intercept navigations + // before they happen, but unfortunately it is not widely supported yet. + for (const $page of Object.values(this._pagesById)) { + if ($page === oldTarget) { + $page.classList.add('display-flex', 'enforce-view-transition-names'); + $page.style.zIndex = '99'; // cover new page to avoid it being visible to the user already + } else { + $page.classList.add('disable-view-transition-names'); + } + } + + this._transitionView(() => { + for (const $page of Object.values(this._pagesById)) { + $page.classList.remove( + 'display-flex', + 'enforce-view-transition-names', + 'disable-view-transition-names', + ); + $page.style.zIndex = ''; + } + }, oldPageId, newPageId); + }); + } + + run() { + FlippableHandler.disabled = true; // avoid conflicts between FlippableHandler's hashchange events and ours + window.location.hash = ExportBackupCodes.Pages.UNLOCK; + } + + /** + * @private + * @param {string} password + */ + async _generateCodes(password) { + TopLevelApi.setLoading(true); + + const passwordBuffer = password ? Utf8Tools.stringToUtf8ByteArray(password) : undefined; + /** @type {Key?} */ + let key = null; + try { + key = this._request.keyInfo.useLegacyStore + ? await AccountStore.instance.get( + this._request.keyInfo.defaultAddress.toUserFriendlyAddress(), + /** @type {Uint8Array} */ (passwordBuffer), + ) + : await KeyStore.instance.get(this._request.keyInfo.id, passwordBuffer); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage === 'Invalid key') { + this._passwordBox.onPasswordIncorrect(); + TopLevelApi.setLoading(false); + return; + } + this._reject(new Errors.CoreError(error instanceof Error ? error : errorMessage)); + return; + } + + if (!key) { + this._reject(new Errors.KeyNotFoundError()); + return; + } + + // Show a loading state on SEND_CODE_1 page for the case that the user already proceeds to that page, while the + // codes are not ready yet. + /** @param {boolean} isGeneratingCodes */ + const setGeneratingCodes = isGeneratingCodes => { + const $sendCode1Page = this._pagesById[ExportBackupCodes.Pages.SEND_CODE_1]; + const sendCode1Illustration = this._illustrationsByStep[ExportBackupCodes.Pages.SEND_CODE_1]; + $sendCode1Page.classList.toggle('loading', isGeneratingCodes); + sendCode1Illustration.setLoading(isGeneratingCodes); + }; + setGeneratingCodes(true); + + this._backupCodesPromise = BackupCodes.generate(key); // generate codes in background + this._backupCodesPromise.then(async ([code1, code2]) => { + // Set the codes with a view transition. + // If the user is still on the INTRO page, where the codes are masked, the change is not super noticeable + // anyway, but we use a view transition nonetheless. Additionally, when on the INTRO page, we update only + // code 1 which is partially hidden behind the message bubble of code 2, to make the change even less + // noticeable, and code 2 can stay the placeholder, because when switching to SEND_CODE_1, it just changes + // to its faded state in the background without unveiling the code. We then update code 2 of the INTRO page + // later when the user continues to SEND_CODE_1 in the event handler of the continue button, although not + // strictly necessary. + // If the user already proceeded to the SEND_CODE_1 page, we transition from the loading animation there, + // still based on the placeholders, to the actual, unveiled code, without updating the placeholders of the + // loading animation, such that the discrepancy between the length of the placeholders and unveiled code is + // also rather unnoticeably. Code 2 even disappears into the faded background, such that it can be updated + // unnoticed, too. + if (this._currentViewTransition) { + // Wait for current view transition to finish or be cancelled. + await this._currentViewTransition.finished.catch(() => {}); + await new Promise(resolve => requestAnimationFrame(resolve)); + } + const currentPageId = /** @type {ExportBackupCodes.Pages} */ (window.location.hash.replace(/^#/, '')); + this._transitionView(() => { + setGeneratingCodes(false); + for (const [step, illustration] of Object.entries(this._illustrationsByStep)) { + illustration.setCode(1, code1); + if (currentPageId === ExportBackupCodes.Pages.INTRO && step === currentPageId) continue; + // Set code 2 only on other pages than INTRO, unless the user is not on INTRO anymore, see above. + illustration.setCode(2, code2); + } + }, currentPageId, currentPageId); + }); + + // Proceed to INTRO page. + this._changePage('forward'); + TopLevelApi.setLoading(false); + } + + /** + * @param {'forward' | 'back'} direction + * @private + */ + async _changePage(direction) { + const oldPageId = /** @type {ExportBackupCodes.Pages} */ (window.location.hash.replace(/^#/, '')); + const oldPageIndex = this._pageIds.indexOf(oldPageId); + const newPageId = this._pageIds[oldPageIndex + (direction === 'forward' ? 1 : -1)]; + + await this._transitionView(() => new Promise(resolve => { + // Let the domUpdateHandler resolve, once the DOM actually updated. + window.addEventListener('hashchange', () => resolve(), { once: true }); + if (direction === 'forward') { + window.location.hash = newPageId; + } else { + window.history.back(); + } + }), oldPageId, newPageId); + } + + /** + * @private + * @param {() => Promise | void} domUpdateHandler + * @param {ExportBackupCodes.Pages} oldPageId + * @param {ExportBackupCodes.Pages} newPageId + */ + async _transitionView(domUpdateHandler, oldPageId, newPageId) { + if (!this._shouldTransitionView(oldPageId, newPageId)) { + // Go to new page without a view transition. + await domUpdateHandler(); + return; + } + this._currentlyTransitioningNewPageId = newPageId; + // Note that starting a new view transition cancels the animation of a previous one. + this._currentViewTransition = document.startViewTransition(domUpdateHandler); + // Customize view transition, without awaiting it. + Promise.all([ + BackupCodesIllustration.customizeViewTransition( + this._currentViewTransition, + // oldPageId and newPageId are checked in _shouldTransitionView. + /** @type {BackupCodesIllustrationStep} */ (oldPageId), + /** @type {BackupCodesIllustrationStep} */ (newPageId), + this._pagesById[newPageId], + ), + this._currentViewTransition.finished, + ]).catch(() => { + // Catch exceptions to avoid unhandled promise rejections on view transition cancellation. + }).then(() => { + if (this._currentlyTransitioningNewPageId !== newPageId) return; + // Reached target page, and it hasn't changed in the meantime. + this._currentlyTransitioningNewPageId = null; + this._currentViewTransition = null; + }); + // Await the actual DOM update (i.e. the important part), and throw if it fails. + await this._currentViewTransition.updateCallbackDone; + } + + /** + * @param {ExportBackupCodes.Pages} oldPageId + * @param {ExportBackupCodes.Pages} newPageId + * @returns {boolean} + */ + _shouldTransitionView(oldPageId, newPageId) { + return !!document.startViewTransition // view transitions supported + && newPageId !== this._currentlyTransitioningNewPageId // transition not already running or scheduled + && this._pageIds.includes(oldPageId) && this._pageIds.includes(newPageId) // part of ExportBackupCodes flow + // Disable view transitions for the unlock page, as it has its own, separate transition effect. + && oldPageId !== ExportBackupCodes.Pages.UNLOCK && newPageId !== ExportBackupCodes.Pages.UNLOCK; + } +} + +/** + * @enum {'backup-codes-unlock' | 'backup-codes-intro' | 'backup-codes-send-code-1' | 'backup-codes-send-code-1-confirm' + * | 'backup-codes-send-code-2' | 'backup-codes-send-code-2-confirm' | 'backup-codes-success'} + */ +ExportBackupCodes.Pages = Object.freeze({ + UNLOCK: 'backup-codes-unlock', + INTRO: 'backup-codes-intro', + SEND_CODE_1: 'backup-codes-send-code-1', + SEND_CODE_1_CONFIRM: 'backup-codes-send-code-1-confirm', + SEND_CODE_2: 'backup-codes-send-code-2', + SEND_CODE_2_CONFIRM: 'backup-codes-send-code-2-confirm', + SUCCESS: 'backup-codes-success', +}); diff --git a/src/request/export/ExportFile.js b/src/request/export/ExportFile.js index 48e5eb17c..bc1c42fbf 100644 --- a/src/request/export/ExportFile.js +++ b/src/request/export/ExportFile.js @@ -49,8 +49,6 @@ class ExportFile extends Observable { $setPasswordPage.querySelector('.login-file-icon')); const $passwordBox = /** @type {HTMLFormElement} */ ( $unlockFilePage.querySelector('.password-box')); - this.$setPasswordBackButton = /** @type {HTMLLinkElement} */ ( - $setPasswordPage.querySelector('a.page-header-back-button')); const $passwordSetterBox = /** @type {HTMLFormElement} */ ( $setPasswordPage.querySelector('.password-setter-box')); const $downloadLoginFile = /** @type {HTMLDivElement} */ ( diff --git a/src/request/export/index.html b/src/request/export/index.html index 15c2bc8e1..18d48032a 100644 --- a/src/request/export/index.html +++ b/src/request/export/index.html @@ -43,12 +43,14 @@ + + @@ -57,8 +59,10 @@ + + @@ -76,6 +80,7 @@ + @@ -137,7 +142,7 @@

There is no Password Re -

Unlock your Backup

+

Unlock your Backup

@@ -319,6 +324,172 @@

Do

+ +
+ + +
+ + + +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+ + +
- From 27e00a9aff1b73c5f7f897c7805c8276d364c75b Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 19 Jan 2026 16:47:49 +0100 Subject: [PATCH 05/27] Import refactor: more consistent naming of translation keys --- src/request/create/index.html | 4 +- src/request/export/index.html | 4 +- src/request/import/index.html | 16 +++---- src/request/remove-key/index.html | 4 +- src/translations/de.json | 29 +++++++------ src/translations/en.json | 29 +++++++------ src/translations/es.json | 29 +++++++------ src/translations/fr.json | 65 ++++++++++++++-------------- src/translations/nl.json | 29 +++++++------ src/translations/pt.json | 29 +++++++------ src/translations/ru.json | 29 +++++++------ src/translations/uk.json | 71 ++++++++++++++++--------------- src/translations/zh.json | 29 +++++++------ 13 files changed, 188 insertions(+), 179 deletions(-) diff --git a/src/request/create/index.html b/src/request/create/index.html index 25658b507..e94cf5279 100644 --- a/src/request/create/index.html +++ b/src/request/create/index.html @@ -170,8 +170,8 @@

-

Download your Login File

-

Download successful?

+

Download your Login File

+

Download successful?

diff --git a/src/request/export/index.html b/src/request/export/index.html index 118226196..c44e57868 100644 --- a/src/request/export/index.html +++ b/src/request/export/index.html @@ -319,8 +319,8 @@

Take 5 Minutes for a B -

Download your Login File

-

Download successful?

+

Download your Login File

+

Download successful?

diff --git a/src/request/import/index.html b/src/request/import/index.html index ea19b51be..6b6c982e9 100644 --- a/src/request/import/index.html +++ b/src/request/import/index.html @@ -110,7 +110,7 @@ -

Enter Recovery Words

+

Enter Recovery Words

@@ -131,8 +131,8 @@

Create a password
- - + +
@@ -146,7 +146,7 @@

Create a password -

Download your Login File

+

Download your Login File

@@ -166,11 +166,11 @@

Download your Logi -

Import your Login File

+

Import your Login File

- +
@@ -179,14 +179,14 @@

Import your Login File diff --git a/src/request/remove-key/index.html b/src/request/remove-key/index.html index 5a85c270c..8378481f7 100644 --- a/src/request/remove-key/index.html +++ b/src/request/remove-key/index.html @@ -324,8 +324,8 @@

Access your Login File

-

Download your Login File

-

Download successful?

+

Download your Login File

+

Download successful?

diff --git a/src/translations/de.json b/src/translations/de.json index c4c009dac..a4f723b60 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -43,26 +43,27 @@ "create-login-file-paragraph-3": "Du könntest versehentlich ausgeloggt werden. Lade die Login-Datei herunter und speichere sie sicher, um die Kontrolle zu behalten.", "create-login-file-return": "Verstanden", - "import-heading-enter-recovery-words": "Wiederherstellungswörter eingeben", - "import-import-login-file": "Mit Login-Datei einloggen", - "import-login-to-continue": "Bitte logge dich neu ein, um fortzufahren.", - "import-unlock-account": "Entsperre dein Konto", - "import-create-account": "Erstelle ein neues Konto", - "import-qr-video-tooltip": "Scanne deine Login‑Datei mit der Kamera deines Geräts.", - - "import-file-button-words": "Mit Wiederherstellungswörtern einloggen", - - "import-words-file-available": "Die Wiederherstellungswörter erzeugen eine neue Login‑Datei. Setze ein Passwort, um sie zu schützen.", - "import-words-file-unavailable": "Die Wiederherstellungswörter erzeugen ein neues Konto. Setze ein Passwort, um es zu schützen.", + "import-words-heading": "Wiederherstellungswörter eingeben", "import-words-hint": "Springe mit Tab zwischen Feldern", "import-words-error": "Das ist kein gültiges Konto. Schreibfehler?", "import-words-wrong-seed-phrase": "Diese Wiederherstellungswörter gehören zu einem anderen Konto", - "import-words-download-loginfile": "Lade deine Login‑Datei herunter", + + "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + + "import-file-heading": "Mit Login-Datei einloggen", + "import-file-login-to-continue": "Bitte logge dich neu ein, um fortzufahren.", + "import-file-button-words": "Login with Recovery Words", + "import-file-create-account": "Erstelle ein neues Konto", + "import-file-qr-tooltip": "Scanne deine Login‑Datei mit der Kamera deines Geräts.", + "import-unlock-account": "Entsperre dein Konto", "file-import-prompt": "Zum Hochladen hierher ziehen oder klicken", "file-import-error-could-not-read": "Login-Datei nicht erkannt.", "file-import-error-invalid": "Ungültige Login‑Datei.", + "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "qr-video-scanner-cancel": "Abbrechen", "qr-video-scanner-no-camera": "Dein Endgerät verfügt über keine verwendbare Kamera.", "qr-video-scanner-enable-camera": "Erlaube den Zugriff auf deine Kamera, um QR-Codes zu scannen.", @@ -144,8 +145,9 @@ "identicon-selector-avatars-hint-2": "Stelle sie dir wie Kontonummern vor.", "identicon-selector-generate-new": "Neue Avatare", + "download-loginfile-heading": "Lade deine Login‑Datei herunter", + "download-loginfile-heading-confirm-download": "Download erfolgreich?", "download-loginfile-download": "Herunterladen", - "download-loginfile-successful": "Download erfolgreich?", "download-loginfile-tap-and-hold": "Zum Herunterladen\nBild gedrückt halten", "download-loginfile-continue": "Weiter", @@ -207,7 +209,6 @@ "export-backup-codes-success-heading": "All set up?", "export-backup-codes-success-text": "You should now have both codes, stored as messages on two separate platforms.", "export-backup-codes-success-recovery": "If you lose your password or Login File, you can regain access by entering both codes in the login screen.", - "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", "go-to-recovery-words": "Erstelle ein Backup", "export-unlock-heading": "Wiederherstellungswörter entsperren", "export-heading-validate-backup": "Überprüfe dein Backup", diff --git a/src/translations/en.json b/src/translations/en.json index bb7d33c97..6424cd728 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -43,26 +43,27 @@ "create-login-file-paragraph-3": "You might get logged out by accident. Download and store the Login File safely to stay in control.", "create-login-file-return": "Got it", - "import-heading-enter-recovery-words": "Enter Recovery Words", - "import-import-login-file": "Import your Login File", - "import-login-to-continue": "Please login again to continue.", - "import-unlock-account": "Unlock your Account", - "import-create-account": "Create new account", - "import-qr-video-tooltip": "Scan your Login File with your device's camera.", - - "import-file-button-words": "Login with Recovery Words", - - "import-words-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", - "import-words-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + "import-words-heading": "Enter Recovery Words", "import-words-hint": "Press Tab to Jump to the next field", "import-words-error": "This is not a valid account. Typo?", "import-words-wrong-seed-phrase": "These Recovery Words belong to a different account", - "import-words-download-loginfile": "Download your Login File", + + "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + + "import-file-heading": "Import your Login File", + "import-file-login-to-continue": "Please login again to continue.", + "import-file-button-words": "Login with Recovery Words", + "import-file-create-account": "Create new account", + "import-file-qr-tooltip": "Scan your Login File with your device's camera.", + "import-unlock-account": "Unlock your Account", "file-import-prompt": "Drag here or click to import", "file-import-error-could-not-read": "Could not read Login File.", "file-import-error-invalid": "Invalid Login File.", + "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "qr-video-scanner-cancel": "Cancel", "qr-video-scanner-no-camera": "Your device does not have an accessible camera.", "qr-video-scanner-enable-camera": "Unblock the camera for this website to scan QR codes.", @@ -144,8 +145,9 @@ "identicon-selector-avatars-hint-2": "Think of them as bank account numbers.", "identicon-selector-generate-new": "New avatars", + "download-loginfile-heading": "Download your Login File", + "download-loginfile-heading-confirm-download": "Download successful?", "download-loginfile-download": "Download", - "download-loginfile-successful": "Download successful?", "download-loginfile-tap-and-hold": "Tap and hold image\nto download", "download-loginfile-continue": "Continue", @@ -207,7 +209,6 @@ "export-backup-codes-success-heading": "All set up?", "export-backup-codes-success-text": "You should now have both codes, stored as messages on two separate platforms.", "export-backup-codes-success-recovery": "If you lose your password or Login File, you can regain access by entering both codes in the login screen.", - "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", "go-to-recovery-words": "Create backup", "export-unlock-heading": "Unlock your Backup", "export-heading-validate-backup": "Validate your Backup", diff --git a/src/translations/es.json b/src/translations/es.json index e0ad8e5dc..7126e52e4 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -43,26 +43,27 @@ "create-login-file-paragraph-3": "Puede que se salga de su sesión por accidente. Descargue y almacene su Archivo de Inicio de Sesión para mantenerse en control.", "create-login-file-return": "Lo tengo", - "import-heading-enter-recovery-words": "Ingrese Palabras de Recuperación", - "import-import-login-file": "Importe su Archivo de Sesión", - "import-login-to-continue": "Por favor inicie sesión de nuevo para continuar.", - "import-unlock-account": "Desbloquear su Cuenta", - "import-create-account": "Crear nueva cuenta", - "import-qr-video-tooltip": "Escanee su Archivo de Sesión con la cámara de su dispositivo.", - - "import-file-button-words": "Inicie sesión con Palabras de Recuperación", - - "import-words-file-available": "Usando las Palabras de Recuperación se crea una nueva un nuevo Archivo de Sesión. Cree una contraseña para asegurarlo.", - "import-words-file-unavailable": "Usando las Palabras de Recuperación se crea una nueva cuenta. Cree una contraseña para asegurarla.", + "import-words-heading": "Ingrese Palabras de Recuperación", "import-words-hint": "Presione Tab para Saltar a la próxima casilla.", "import-words-error": "Esta no es una cuenta válida. Revise que escribió todos los caracteres.", "import-words-wrong-seed-phrase": "Estas Palabras de Recuperación le pertenecen a una cuenta diferente.", - "import-words-download-loginfile": "Descargue su Archivo de Acceso", + + "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + + "import-file-heading": "Importe su Archivo de Sesión", + "import-file-login-to-continue": "Por favor inicie sesión de nuevo para continuar.", + "import-file-button-words": "Login with Recovery Words", + "import-file-create-account": "Crear nueva cuenta", + "import-file-qr-tooltip": "Escanee su Archivo de Sesión con la cámara de su dispositivo.", + "import-unlock-account": "Desbloquear su Cuenta", "file-import-prompt": "Arrastre aquí o haga click para importar", "file-import-error-could-not-read": "No se pudo leer el Archivo de Sesión.", "file-import-error-invalid": "Archivo de Sesión inválido.", + "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "qr-video-scanner-cancel": "Cancelar", "qr-video-scanner-no-camera": "Su dispositivo no tiene una cámara accesible.", "qr-video-scanner-enable-camera": "Desbloquee la cámara para esta página para poder escanear códigos QR.", @@ -144,8 +145,9 @@ "identicon-selector-avatars-hint-2": "Considérelas como números de cuentas bancarias.", "identicon-selector-generate-new": "Nuevos avatares", + "download-loginfile-heading": "Descargue su Archivo de Acceso", + "download-loginfile-heading-confirm-download": "¿Descarga exitosa?", "download-loginfile-download": "Descargar", - "download-loginfile-successful": "¿Descarga exitosa?", "download-loginfile-tap-and-hold": "Toca la pantalla y mantén\npresionado para descargar.", "download-loginfile-continue": "Continue", @@ -207,7 +209,6 @@ "export-backup-codes-success-heading": "All set up?", "export-backup-codes-success-text": "You should now have both codes, stored as messages on two separate platforms.", "export-backup-codes-success-recovery": "If you lose your password or Login File, you can regain access by entering both codes in the login screen.", - "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", "go-to-recovery-words": "Crear respaldo", "export-unlock-heading": "Desbloquear su Respaldo", "export-heading-validate-backup": "Valide su Respaldo", diff --git a/src/translations/fr.json b/src/translations/fr.json index 98317204b..7b66bc94b 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -43,26 +43,27 @@ "create-login-file-paragraph-3": "Vous pourriez vous être déconnecté par accident. Téléchargez et conservez le fichier de connexion en toute sécurité pour converser votre accès au compte.", "create-login-file-return": "J'ai compris", - "import-heading-enter-recovery-words": "Entrez vos Mots de Récupération", - "import-import-login-file": "Importez votre Fichier de Connexion", - "import-login-to-continue": "Veuillez vous reconnecter pour continuer.", - "import-unlock-account": "Déverrouiller votre Compte", - "import-create-account": "Créer un nouveau compte", - "import-qr-video-tooltip": "Scannez votre Fichier de Connexion avec la caméra de votre appareil.", - - "import-file-button-words": "Se connecter avec les Mots de Récupération", - - "import-words-file-available": "L'utilisation des Mots de Récupération crée un nouveau Fichier de Connexion. Créez un mot de passe pour le sécuriser.", - "import-words-file-unavailable": "L'utilisation des Mots de Récupération crée un nouveau compte. Créez un mot de passe pour le sécuriser.", + "import-words-heading": "Entrez vos Mots de Récupération", "import-words-hint": "Pressez Tab pour passer au champ suivant", "import-words-error": "Ce n'est pas un compte valide. Faute de frappe ?", "import-words-wrong-seed-phrase": "Ces Mots de Récupération appartiennent à un autre compte", - "import-words-download-loginfile": "Téléchargez votre Fichier de connexion", + + "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + + "import-file-heading": "Importez votre Fichier de Connexion", + "import-file-login-to-continue": "Veuillez vous reconnecter pour continuer.", + "import-file-button-words": "Login with Recovery Words", + "import-file-create-account": "Créer un nouveau compte", + "import-file-qr-tooltip": "Scannez votre Fichier de Connexion avec la caméra de votre appareil.", + "import-unlock-account": "Déverrouiller votre Compte", "file-import-prompt": "Glissez-déposez ici ou cliquez pour importer", "file-import-error-could-not-read": "Impossible de lire le fichier de connexion.", "file-import-error-invalid": "Fichier de connexion invalide.", + "backup-codes-illustration-label": "Code de récupération Nimiq {n}/2", + "qr-video-scanner-cancel": "Annuler", "qr-video-scanner-no-camera": "Votre appareil n'a pas de caméra accessible.", "qr-video-scanner-enable-camera": "Débloquez la caméra pour ce site de façon à scanner les QR codes.", @@ -144,8 +145,9 @@ "identicon-selector-avatars-hint-2": "Rappelez-vous en comme des numéros de compte.", "identicon-selector-generate-new": "Nouveaux avatars", + "download-loginfile-heading": "Téléchargez votre Fichier de connexion", + "download-loginfile-heading-confirm-download": "Téléchargement réussi ?", "download-loginfile-download": "Télécharger", - "download-loginfile-successful": "Téléchargement réussi ?", "download-loginfile-tap-and-hold": "Appuyez et maintenez l'image\npour la télécharger", "download-loginfile-continue": "Continuer", @@ -189,25 +191,24 @@ "export-file-success-words-intro": "Il n'y a pas d'option 'mot de passe oublié'. Notez les 24 mots pour créer une sauvegarde sécurisée.", "export-words-intro-heading": "Il n'y a pas de Récupéra­tion de Mot de Passe !", "export-words-hint": "Faites défiler pour continuer", - "export-backup-codes-intro-heading": "Send yourself two backup codes", - "export-backup-codes-intro-text": "The codes combined grant access to your account. Send them to yourself using two different platforms.", - "export-backup-codes-intro-warning": "Anyone with both codes will have full access!\nDon't share them and keep them safe.", - "export-backup-codes-lets-go": "Let's go", - "export-backup-codes-send-code-heading": "Send yourself Code {n}", - "export-backup-codes-send-code-instructions-code-1": "Send this code to yourself for example by email or messenger. Make sure you will find it, in case you need it.", - "export-backup-codes-send-code-instructions-code-2": "Send this code to yourself using another email or messenger. For your safety, both codes must be stored separately.", - "export-backup-codes-send-code-loading": "Generating codes...", - "export-backup-codes-send-code-copy": "Copy code {n}", - "export-backup-codes-send-code-instructions-link": "How to send to yourself", - "export-backup-codes-send-code-confirm-heading-code-1": "Did you send Code 1 to yourself?", - "export-backup-codes-send-code-confirm-heading-code-2": "Did you send Code 2 with a different method than Code 1?", - "export-backup-codes-send-code-confirm-continue-code-1": "Yes, continue to code 2", - "export-backup-codes-send-code-confirm-continue-code-2": "Yes, all done", - "export-backup-codes-send-code-confirm-go-back": "No, go back", - "export-backup-codes-success-heading": "All set up?", - "export-backup-codes-success-text": "You should now have both codes, stored as messages on two separate platforms.", - "export-backup-codes-success-recovery": "If you lose your password or Login File, you can regain access by entering both codes in the login screen.", - "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "export-backup-codes-intro-heading": "Envoyez-vous deux codes de récupération", + "export-backup-codes-intro-text": "Les codes combinés permettent d'accéder à votre compte. Envoyez-les-vous à vous-même en utilisant deux plateformes différentes.", + "export-backup-codes-intro-warning": "Toute personne disposant des deux codes aura un accès complet !\nNe les partagez pas et conservez-les en lieu sûr.", + "export-backup-codes-lets-go": "Allons-y", + "export-backup-codes-send-code-heading": "Envoyez-vous le code {n}", + "export-backup-codes-send-code-instructions-code-1": "Envoyez-vous ce code par e-mail ou par messagerie instantanée, par exemple. Assurez-vous de pouvoir le retrouver si vous en avez besoin.", + "export-backup-codes-send-code-instructions-code-2": "Envoyez-vous ce code à l'aide d'une autre adresse e-mail ou d'une autre messagerie instantanée. Pour votre sécurité, les deux codes doivent être conservés séparément.", + "export-backup-codes-send-code-loading": "Génération des codes en cours...", + "export-backup-codes-send-code-copy": "Copier le code {n}", + "export-backup-codes-send-code-instructions-link": "Comment vous l'envoyer à vous-même", + "export-backup-codes-send-code-confirm-heading-code-1": "Vous êtes vous envoyé le code 1 ?", + "export-backup-codes-send-code-confirm-heading-code-2": "Avez-vous envoyé le code 2 en utilisant une méthode différente de celle utilisée pour le code 1 ?", + "export-backup-codes-send-code-confirm-continue-code-1": "Oui, continuer au code 2", + "export-backup-codes-send-code-confirm-continue-code-2": "Oui, c'est terminé.", + "export-backup-codes-send-code-confirm-go-back": "Non, je retourne en arrière", + "export-backup-codes-success-heading": "Tout est prêt ?", + "export-backup-codes-success-text": "Vous devriez maintenant disposer des deux codes, enregistrés sous forme de messages sur deux plateformes distinctes.", + "export-backup-codes-success-recovery": "Si vous perdez votre mot de passe ou votre fichier de connexion, vous pouvez retrouver l'accès en saisissant les deux codes dans l'écran de connexion.", "go-to-recovery-words": "Créer une sauvegarde", "export-unlock-heading": "Déverrouillez votre Sauvegarde", "export-heading-validate-backup": "Validez votre Sauvegarde", diff --git a/src/translations/nl.json b/src/translations/nl.json index 205fc72db..b5790e985 100644 --- a/src/translations/nl.json +++ b/src/translations/nl.json @@ -43,26 +43,27 @@ "create-login-file-paragraph-3": "Je kunt per ongeluk worden uitgelogd. Download en bewaar de Login File veilig om geen toegang te verliezen.", "create-login-file-return": "Begrepen", - "import-heading-enter-recovery-words": "Voer herstelwoorden in", - "import-import-login-file": "Importeer je Login File", - "import-login-to-continue": "Log opnieuw in om door te gaan.", - "import-unlock-account": "Ontgrendel je account", - "import-create-account": "Creëer nieuw account", - "import-qr-video-tooltip": "Scan je Login File met de camera.", - - "import-file-button-words": "Inloggen met herstelwoorden", - - "import-words-file-available": "Met behulp van de herstelwoorden wordt een nieuwe Login File gemaakt. Maak een wachtwoord om het te beveiligen.", - "import-words-file-unavailable": "Met behulp van de herstelwoorden maak je een nieuw account aan. Maak een wachtwoord om het te beveiligen.", + "import-words-heading": "Voer herstelwoorden in", "import-words-hint": "Druk op Tab om naar het volgende veld te springen", "import-words-error": "Dit is geen geldig account. Typfoutje?", "import-words-wrong-seed-phrase": "Deze herstelwoorden horen bij een ander account", - "import-words-download-loginfile": "Download jouw Login File", + + "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + + "import-file-heading": "Importeer je Login File", + "import-file-login-to-continue": "Log opnieuw in om door te gaan.", + "import-file-button-words": "Login with Recovery Words", + "import-file-create-account": "Creëer nieuw account", + "import-file-qr-tooltip": "Scan je Login File met de camera.", + "import-unlock-account": "Ontgrendel je account", "file-import-prompt": "Sleep hierheen of klik om te importeren", "file-import-error-could-not-read": "Kan Login File niet lezen.", "file-import-error-invalid": "Ongeldige Login File.", + "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "qr-video-scanner-cancel": "Annuleer", "qr-video-scanner-no-camera": "Je apparaat heeft geen toegankelijke camera.", "qr-video-scanner-enable-camera": "Deblokkeer de camera zodat deze website QR-codes kan scannen.", @@ -144,8 +145,9 @@ "identicon-selector-avatars-hint-2": "Beschouw ze als bankrekeningnummers.", "identicon-selector-generate-new": "Nieuwe avatar", + "download-loginfile-heading": "Download jouw Login File", + "download-loginfile-heading-confirm-download": "Download succesvol?", "download-loginfile-download": "Downloaden", - "download-loginfile-successful": "Download succesvol?", "download-loginfile-tap-and-hold": "Tik en houd de afbeelding\nvast om te downloaden", "download-loginfile-continue": "Doorgaan", @@ -207,7 +209,6 @@ "export-backup-codes-success-heading": "All set up?", "export-backup-codes-success-text": "You should now have both codes, stored as messages on two separate platforms.", "export-backup-codes-success-recovery": "If you lose your password or Login File, you can regain access by entering both codes in the login screen.", - "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", "go-to-recovery-words": "Back-up maken", "export-unlock-heading": "Back-up ontgrendelen", "export-heading-validate-backup": "Valideer je back-up", diff --git a/src/translations/pt.json b/src/translations/pt.json index e66742750..5cd23c7e2 100644 --- a/src/translations/pt.json +++ b/src/translations/pt.json @@ -43,26 +43,27 @@ "create-login-file-paragraph-3": "Podes ter feito encerrado a sessão por acidente. Faz download e guarda o Ficheiro de Login em segurança para manter o controlo.", "create-login-file-return": "Percebi", - "import-heading-enter-recovery-words": "Insire as Palavra de Recuperação", - "import-import-login-file": "Importa o teu Ficheiro de Login", - "import-login-to-continue": "Por favor faz login outra vez e continua.", - "import-unlock-account": "Desbloqueia a tua Conta", - "import-create-account": "Criar nova conta", - "import-qr-video-tooltip": "Digitalize o teu Ficheiro de Login com a câmara do teu dispositivo.", - - "import-file-button-words": "Inicia sessão com as Palavra de Recuperação", - - "import-words-file-available": "Usar as Palavra de Recuperação cria um novo Ficheiro de Login. Cria uma palavra passe para proteger o ficheiro.", - "import-words-file-unavailable": "Usar as Palavra de Recuperação cria uma nova conta. Cria uma palavra passe para protegê-la.", + "import-words-heading": "Insire as Palavra de Recuperação", "import-words-hint": "Clicar em Tab para saltar para o próximo campo", "import-words-error": "Isto não é uma conta válida. Erro de escrita?", "import-words-wrong-seed-phrase": "Estas Palavra de Recuperação pertencem a uma conta diferente", - "import-words-download-loginfile": "Faz download do teu Ficheiro de Login.", + + "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + + "import-file-heading": "Importa o teu Ficheiro de Login", + "import-file-login-to-continue": "Por favor faz login outra vez e continua.", + "import-file-button-words": "Login with Recovery Words", + "import-file-create-account": "Criar nova conta", + "import-file-qr-tooltip": "Digitalize o teu Ficheiro de Login com a câmara do teu dispositivo.", + "import-unlock-account": "Desbloqueia a tua Conta", "file-import-prompt": "Arrasta para aqui ou clica para importar", "file-import-error-could-not-read": "Não foi possível ler o Ficheiro de Login.", "file-import-error-invalid": "Ficheiro de Login inválido.", + "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "qr-video-scanner-cancel": "Cancelar", "qr-video-scanner-no-camera": "O teu dispositivo não tem uma câmara acessível.", "qr-video-scanner-enable-camera": "Desbloquear a câmara para este site para digitalizar os códigos QR.", @@ -144,8 +145,9 @@ "identicon-selector-avatars-hint-2": "Encara-os como números de conta de um banco.", "identicon-selector-generate-new": "Novos avatares", + "download-loginfile-heading": "Faz download do teu Ficheiro de Login.", + "download-loginfile-heading-confirm-download": "Download bem sucedido?", "download-loginfile-download": "Download", - "download-loginfile-successful": "Download bem sucedido?", "download-loginfile-tap-and-hold": "Clica e segura na imagem \npara fazer download", "download-loginfile-continue": "Continuar", @@ -207,7 +209,6 @@ "export-backup-codes-success-heading": "All set up?", "export-backup-codes-success-text": "You should now have both codes, stored as messages on two separate platforms.", "export-backup-codes-success-recovery": "If you lose your password or Login File, you can regain access by entering both codes in the login screen.", - "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", "go-to-recovery-words": "Criar backup", "export-unlock-heading": "Desbloqueia o teu Backup", "export-heading-validate-backup": "Valida o teu Backup", diff --git a/src/translations/ru.json b/src/translations/ru.json index 0b9a41a0c..224017a90 100644 --- a/src/translations/ru.json +++ b/src/translations/ru.json @@ -43,26 +43,27 @@ "create-login-file-paragraph-3": "Вы можете случайно выйти из системы. Скачайте и надёжно сохраните Файл Авторизации, чтобы не потерять доступ.", "create-login-file-return": "Понятно", - "import-heading-enter-recovery-words": "Введите Защитную Фразу", - "import-import-login-file": "Импортировать ваш Файл Авторизации", - "import-login-to-continue": "Войдите снова чтобы продолжить.", - "import-unlock-account": "Разблокировать ваш Аккаунт", - "import-create-account": "Создать новый аккаунт", - "import-qr-video-tooltip": "Отсканируйте Файл Авторизации камерой.", - - "import-file-button-words": "Войти с помощью Защитной Фразы", - - "import-words-file-available": "Каждый раз, когда вы используете для восстановления Защитную Фразу, создаётся новый Файл Авторизации. Чтобы обезопасить его, создайте пароль.", - "import-words-file-unavailable": "Каждый раз, когда вы используете для восстановления Защитную Фразу, создаётся новый Файл Авторизации. Чтобы обезопасить его, создайте пароль.", + "import-words-heading": "Введите Защитную Фразу", "import-words-hint": "Нажмите Tab для перехода к следующему полю", "import-words-error": "Это некорректный аккаунт. Опечатались?", "import-words-wrong-seed-phrase": "Эта Защитная Фраза принадлежит другому аккаунту", - "import-words-download-loginfile": "Download your Login File", + + "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + + "import-file-heading": "Импортировать ваш Файл Авторизации", + "import-file-login-to-continue": "Войдите снова чтобы продолжить.", + "import-file-button-words": "Login with Recovery Words", + "import-file-create-account": "Создать новый аккаунт", + "import-file-qr-tooltip": "Отсканируйте Файл Авторизации камерой.", + "import-unlock-account": "Разблокировать ваш Аккаунт", "file-import-prompt": "Перетащите сюда или кликните для импорта", "file-import-error-could-not-read": "Не прочитался Файл Авторизации.", "file-import-error-invalid": "Некорректный Файл Авторизации.", + "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "qr-video-scanner-cancel": "Отмена", "qr-video-scanner-no-camera": "Камера вашего устройства недоступна.", "qr-video-scanner-enable-camera": "Разблокируйте камеру для этого сайта чтобы сканировать QR-коды.", @@ -144,8 +145,9 @@ "identicon-selector-avatars-hint-2": "Представьте их как номера банковских счётов.", "identicon-selector-generate-new": "Новые аватары", + "download-loginfile-heading": "Download your Login File", + "download-loginfile-heading-confirm-download": "Скачивание прошло успешно?", "download-loginfile-download": "Скачать", - "download-loginfile-successful": "Скачивание прошло успешно?", "download-loginfile-tap-and-hold": "Удерживайте изображение\nчтобы скачать его", "download-loginfile-continue": "Продолжить", @@ -207,7 +209,6 @@ "export-backup-codes-success-heading": "All set up?", "export-backup-codes-success-text": "You should now have both codes, stored as messages on two separate platforms.", "export-backup-codes-success-recovery": "If you lose your password or Login File, you can regain access by entering both codes in the login screen.", - "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", "go-to-recovery-words": "Создать резервную копию", "export-unlock-heading": "Разблокировать резервную копию", "export-heading-validate-backup": "Проверить вашу резервную копию", diff --git a/src/translations/uk.json b/src/translations/uk.json index 8385ae3ab..6bc8818a2 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -1,7 +1,7 @@ { "_language": "Українська", - "language-changed": "Мову інтерфейсу змінено. Чи хочете ви обновити сторінку щоб замінити усі переклади? В інакшому випадку, деякі переклади можуть бути не оновлені.", + "language-changed": "Мову інтерфейсу змінено. Перезавантажити сторінку, щоб оновити всі переклади? Інакше деякі тексти можуть не оновитися.", "back-to-app": "Повернутися до {appName}", "back-to-accounts": "Повернутися до моїх рахунків", @@ -14,10 +14,10 @@ "back-to-cpl": "Повернутися до CryptoPayment.link", "tx-data-funding-cashlink": "Чек поповнюється", - "tx-data-contract-creation": "{contractType} creation", - "tx-data-contract-withdrawal": "{contractType} withdrawal", - "tx-data-staking": "Staking", - "tx-data-staking-update": "Staking update", + "tx-data-contract-creation": "{contractType} створення", + "tx-data-contract-withdrawal": "{contractType} виведення", + "tx-data-staking": "Стейкінг", + "tx-data-staking-update": "Оновлення стейкінгу", "label-unknown": "Невідомо", "recovery-words-title": "Напишіть ці 24 слова на папері", @@ -39,30 +39,31 @@ "create-heading-what-is-loginfile": "Що таке файлю-ключ?", "create-login-file-explainer-intro": "Файл-ключ, разом з паролем, надає доступ до вашого рахунку.", "create-login-file-paragraph-1": "Німік не зберігає ваших даних. Файлю-ключ заміщує емейл як засіб входу.", - "create-login-file-paragraph-2": "Файл-ключ зберігається у вашому веб-оглядачі. Використовуйте пароль, щоб отримати доступ.", + "create-login-file-paragraph-2": "Файл-ключ зберігається у вашому веб-оглядачі. Розблокуйте його за допомогою пароля.", "create-login-file-paragraph-3": "Ви можете випадково вийти з гаманця. Завантажте та зберігайте файл-ключ щоб не втратити доступ.", "create-login-file-return": "Зрозуміло", - "import-heading-enter-recovery-words": "Уведіть секретні слова", - "import-import-login-file": "Завантажте файл-ключ", - "import-login-to-continue": "Будь ласка, увійдіть ще раз, щоб продовжити.", - "import-unlock-account": "Розблокувати рахунок", - "import-create-account": "Створити новий рахунок", - "import-qr-video-tooltip": "Відскануйте свій файл-ключ за допомогою камери вашого пристрою", - - "import-file-button-words": "Увійти за допомогою секретних слів", - - "import-words-file-available": "Використання секретних слів створює новий файл-ключ. Створіть пароль щоб захистити його.", - "import-words-file-unavailable": "Створіть новий рахунок за допомогою секретних слів. Створіть пароль щоб захистити його.", + "import-words-heading": "Уведіть секретні слова", "import-words-hint": "Натисніть Tab, щоб перейти до наступного поля", "import-words-error": "Це невірний рахунок. Схибили?", "import-words-wrong-seed-phrase": "Ці секретні слова відповідають іншому рахунку", - "import-words-download-loginfile": "Завантажте Файл-ключ", + + "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + + "import-file-heading": "Завантажте файл-ключ", + "import-file-login-to-continue": "Будь ласка, увійдіть ще раз, щоб продовжити.", + "import-file-button-words": "Login with Recovery Words", + "import-file-create-account": "Створити новий рахунок", + "import-file-qr-tooltip": "Відскануйте свій файл-ключ за допомогою камери вашого пристрою", + "import-unlock-account": "Розблокувати рахунок", "file-import-prompt": "Перетягніть сюди або клацніть щоб завантажити", "file-import-error-could-not-read": "Неможливо прочитати файл-ключ.", "file-import-error-invalid": "Невірний файл-ключ.", + "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "qr-video-scanner-cancel": "Скасувати", "qr-video-scanner-no-camera": "Ваш пристрій не має доступної камери.", "qr-video-scanner-enable-camera": "Розблокуйте камеру для цього веб-сайту, щоб сканувати QR-коди.", @@ -74,12 +75,12 @@ "sign-tx-cancel-payment": "Скасувати платіж", "sign-multisig-tx-heading-tx": "Схвалити Multisig переказ", - "sign-multisig-tx-approving-as-name-with-account": "Approving as {userName} with {accountName}", - "sign-multisig-tx-approving-with-account": "Approving with {accountName}", + "sign-multisig-tx-approving-as-name-with-account": "Схвалення від імені {userName} з {accountName}", + "sign-multisig-tx-approving-with-account": "Схвалення з {accountName}", - "sign-staking-heading-stake": "Stake NIM", - "sign-staking-heading-unstake": "Unstake NIM", - "sign-staking-heading-change": "Change Validator", + "sign-staking-heading-stake": "Застейкати NIM", + "sign-staking-heading-unstake": "Вивести NIM зі стейкінгу", + "sign-staking-heading-change": "Змінити валідатора", "sign-msg-heading": "Підписати повідомлення", "sign-msg-signer": "Підписант", @@ -87,7 +88,7 @@ "tab-width-selector-label": "Довжина вкладки", "address-info-new-cashlink": "Новий чек", - "address-info-multisig-badge": "{signerCount} of {totalParticipantCount}", + "address-info-multisig-badge": "{signerCount} з {totalParticipantCount}", "copyable-copied": "Скопійовано", @@ -144,8 +145,9 @@ "identicon-selector-avatars-hint-2": "Адреси це наче реквізити в банку.", "identicon-selector-generate-new": "Нові адреси", + "download-loginfile-heading": "Завантажте Файл-ключ", + "download-loginfile-heading-confirm-download": "Завантажено успішно?", "download-loginfile-download": "Завантажити", - "download-loginfile-successful": "Завантажено успішно?", "download-loginfile-tap-and-hold": "Натисніть і утримуйте на\nмалюнку щоб завантажити", "download-loginfile-continue": "Продовжити", @@ -207,7 +209,6 @@ "export-backup-codes-success-heading": "All set up?", "export-backup-codes-success-text": "You should now have both codes, stored as messages on two separate platforms.", "export-backup-codes-success-recovery": "If you lose your password or Login File, you can regain access by entering both codes in the login screen.", - "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", "go-to-recovery-words": "Створити спосіб відновлення", "export-unlock-heading": "Розблокуйте рахунок", "export-heading-validate-backup": "Перевірте ключові слова", @@ -259,7 +260,7 @@ "derive-btc-xpub-text": "З легкістю обмінюйте NIM - ефективну платіжну монету та BTC - золотий стандарт криптовалюти.", "derive-polygon-address-heading": "Активувати USDC/USDT", - "derive-polygon-address-text": "USDC and USDT are stablecoins pegged to USD and now available in the wallet.", + "derive-polygon-address-text": "USDC та USDT - це стейблкоїни, привʼязані до долара США, які тепер доступні у гаманці.", "sign-swap-heading": "Підтвердити обмін", "sign-swap-fees": "комісія", @@ -278,12 +279,12 @@ "sign-swap-exchange-rate": "Курс обміну", "sign-swap-your-bank": "Ваш банк", - "connect-heading": "Log in to {appName}", - "connect-heading-description": "Connect and use your account to\napprove transactions.", - "connect-address-count-info": "{appName} is requesting access to {addressCount} addresses.", - "connect-bottom-explainer": "The connection is for approving only.\nYour funds remain unaffected.", - "connect-tooltip-multisig-1": "Your account will be used to verify your identity only.", - "connect-tooltip-multisig-2": "The shared wallet has its own address. It can receive transactions from anywhere just like a regular wallet.", - "connect-tooltip-address-info-first-address": "The first address of your account will be visible to all participants in your shared wallet.", - "connect-tooltip-address-info-multiple-addresses": "The addresses for the following derivation paths will be visible to all participants in your shared wallet: {paths}." + "connect-heading": "Увійти в {appName}", + "connect-heading-description": "Підключіть свій рахунок,\nщоб підтверджувати транзакції.", + "connect-address-count-info": "{appName} запитує доступ до {addressCount} адрес.", + "connect-bottom-explainer": "Підключення використовується лише для підтвердження.\nВаші кошти залишаються недоторканими.", + "connect-tooltip-multisig-1": "Ваш рахунок буде використано лише для підтвердження особи.", + "connect-tooltip-multisig-2": "Спільний гаманець має власну адресу. Він може отримувати транзакції, як і звичайний гаманець.", + "connect-tooltip-address-info-first-address": "Перша адреса вашого рахунку буде видима всім учасникам спільного гаманця.", + "connect-tooltip-address-info-multiple-addresses": "Адреси для наступних шляхів деривації будуть видимі всім учасникам спільного гаманця: {paths}." } diff --git a/src/translations/zh.json b/src/translations/zh.json index 5675eb9d2..dc51282b4 100644 --- a/src/translations/zh.json +++ b/src/translations/zh.json @@ -43,26 +43,27 @@ "create-login-file-paragraph-3": "你可能会不小心登出。请下载并安全地存储登录文件,以保持你对帐户的掌控权。", "create-login-file-return": "完成", - "import-heading-enter-recovery-words": "输入助记词", - "import-import-login-file": "导入你的登录文件", - "import-login-to-continue": "请再次登录以继续", - "import-unlock-account": "解锁你的账户", - "import-create-account": "建立新帐户", - "import-qr-video-tooltip": "使用设备的相机扫描你的登录文件", - - "import-file-button-words": "使用助记词登录", - - "import-words-file-available": "使用助记词创建一个新的登录文件,并设置密码保护", - "import-words-file-unavailable": "使用助记词创建一个新的登录文件,并设置密码保护", + "import-words-heading": "输入助记词", "import-words-hint": "按Tab键跳到下一栏", "import-words-error": "这不是有效的帐户,是否有错别字?", "import-words-wrong-seed-phrase": "这些助记词属于另一个帐户", - "import-words-download-loginfile": "Download your Login File", + + "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + + "import-file-heading": "导入你的登录文件", + "import-file-login-to-continue": "请再次登录以继续", + "import-file-button-words": "Login with Recovery Words", + "import-file-create-account": "建立新帐户", + "import-file-qr-tooltip": "使用设备的相机扫描你的登录文件", + "import-unlock-account": "解锁你的账户", "file-import-prompt": "拖拽到此处或点击导入", "file-import-error-could-not-read": "无法读取登录文件", "file-import-error-invalid": "无效的登录文件", + "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "qr-video-scanner-cancel": "取消", "qr-video-scanner-no-camera": "你的设备没有可访问的相机", "qr-video-scanner-enable-camera": "打开此网站相机的使用权限以扫描QR码", @@ -144,8 +145,9 @@ "identicon-selector-avatars-hint-2": "請将它们视为银行帐号。", "identicon-selector-generate-new": "新的头像", + "download-loginfile-heading": "Download your Login File", + "download-loginfile-heading-confirm-download": "是否成功下载?", "download-loginfile-download": "下载", - "download-loginfile-successful": "是否成功下载?", "download-loginfile-tap-and-hold": "点击并按住图片下载", "download-loginfile-continue": "继续", @@ -207,7 +209,6 @@ "export-backup-codes-success-heading": "All set up?", "export-backup-codes-success-text": "You should now have both codes, stored as messages on two separate platforms.", "export-backup-codes-success-recovery": "If you lose your password or Login File, you can regain access by entering both codes in the login screen.", - "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", "go-to-recovery-words": "建立备份", "export-unlock-heading": "解锁你的备份", "export-heading-validate-backup": "验证你的备份", From 440e2d5a76a5de741c994b884e1d9b25d48bbd01 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 19 Jan 2026 17:02:10 +0100 Subject: [PATCH 06/27] Import fixes: fix use of legacy NimiqPoW for legacy Login Files Avoid legacy NimiqPoW staying assigned to window.Nimiq when an exception occurs during import of legacy Login Files, for example due to wrong password entered. --- src/request/import/ImportFile.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/request/import/ImportFile.js b/src/request/import/ImportFile.js index e415666c0..f2a05f2d8 100644 --- a/src/request/import/ImportFile.js +++ b/src/request/import/ImportFile.js @@ -186,15 +186,18 @@ class ImportFile extends Observable { // So we need to temporarily set global `Nimiq` to the PoW library to load the worker correctly. // @ts-expect-error window.Nimiq is not defined const _Nimiq = window.Nimiq; - // @ts-expect-error window.Nimiq is not defined - window.Nimiq = NimiqPoW; - - await NimiqPoW.WasmHelper.doImport(); - const _secret = await NimiqPoW.Secret.fromEncrypted(this._encryptedKey, encryptionKey); - - // After the worker is done, we set the global `Nimiq` back to the PoS library - // @ts-expect-error window.Nimiq is not defined - window.Nimiq = _Nimiq; + let _secret; + try { + // @ts-expect-error window.Nimiq is not defined + window.Nimiq = NimiqPoW; + + await NimiqPoW.WasmHelper.doImport(); + _secret = await NimiqPoW.Secret.fromEncrypted(this._encryptedKey, encryptionKey); + } finally { + // After the worker is done, we set the global `Nimiq` back to the PoS library + // @ts-expect-error window.Nimiq is not defined + window.Nimiq = _Nimiq; + } // Convert PoW Entropy/PrivateKey objects to their PoS equivalent if (_secret instanceof NimiqPoW.Entropy) { From adc7358704b6bea0803fe059d63f87f1bb352dec Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 19 Jan 2026 18:05:38 +0100 Subject: [PATCH 07/27] Import fixes: fix clearing/setting of recovery words RecoveryWords now sets the words correctly regardless of whether it's interactive with input elements or not, and re-evaluates the set recovery words. --- src/components/RecoveryWords.js | 26 +++++++++++++---------- src/components/RecoveryWordsInputField.js | 2 +- src/request/import/ImportWords.js | 12 +++++++++-- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/components/RecoveryWords.js b/src/components/RecoveryWords.js index 15ecbf9fc..4f7323160 100644 --- a/src/components/RecoveryWords.js +++ b/src/components/RecoveryWords.js @@ -28,16 +28,23 @@ class RecoveryWords extends Observable { } /** - * Set the recovery words. Only works when `providesInput` is `false`. + * Set the recovery words. * @param {string[]} words */ setWords(words) { + let providesInput = false; for (let i = 0; i < 24; i++) { const field = this.$fields[i]; - if ('textContent' in field) { - field.textContent = words[i]; + const word = words[i] || ''; + if ('value' in field) { + field.value = word; + providesInput = true; + } else if ('textContent' in field) { + field.textContent = word; } } + if (!providesInput) return; + this._checkPhraseComplete(); } /** @@ -106,16 +113,13 @@ class RecoveryWords extends Observable { return this.$el; } + clear() { + this.setWords(new Array(24)); + } + wrongSeedPhrase() { this._animateError(); - window.setTimeout(() => { - for (let i = 0; i < 24; i++) { - const field = this.$fields[i]; - if ('value' in field) { - field.value = ''; - } - } - }, 500); + window.setTimeout(() => this.clear(), 500); } _onFieldComplete() { diff --git a/src/components/RecoveryWordsInputField.js b/src/components/RecoveryWordsInputField.js index 3744e9ec5..caff8b139 100644 --- a/src/components/RecoveryWordsInputField.js +++ b/src/components/RecoveryWordsInputField.js @@ -93,7 +93,7 @@ class RecoveryWordsInputField extends Observable { set value(value) { this.dom.input.value = value; - this._value = value; + this._onValueChanged(); } get element() { diff --git a/src/request/import/ImportWords.js b/src/request/import/ImportWords.js index 982748cea..3884bda78 100644 --- a/src/request/import/ImportWords.js +++ b/src/request/import/ImportWords.js @@ -28,7 +28,10 @@ class ImportWords extends Observable { RecoveryWords.Events.COMPLETE, (mnemonic, mnemonicType) => this._onRecoveryWordsComplete(mnemonic, mnemonicType), ); - this._recoveryWords.on(RecoveryWords.Events.INCOMPLETE, () => this.fire(ImportWords.Events.RESET)); + this._recoveryWords.on(RecoveryWords.Events.INCOMPLETE, () => { + if (window.location.hash.replace(/^#/, '') !== ImportWords.Pages.ENTER_WORDS) return; + this.fire(ImportWords.Events.RESET); + }); this._recoveryWords.on(RecoveryWords.Events.INVALID, () => this.$wordsPage.classList.add('invalid-words')); this.$wordsPage.querySelectorAll('input').forEach( el => el.addEventListener('focus', @@ -43,7 +46,7 @@ class ImportWords extends Observable { } run() { - this._recoveryWords.setWords(new Array(24)); + this._recoveryWords.clear(); this.$wordsPage.classList.remove('invalid-words', 'wrong-seed-phrase'); window.location.hash = ImportWords.Pages.ENTER_WORDS; } @@ -81,6 +84,11 @@ class ImportWords extends Observable { } this.fire(ImportWords.Events.IMPORT, keys); + + // Imported successfully. Reset view afterward. Delay the change for a small moment, to hopefully perform the + // change unnoticed in the background, while ImportWords should not be visible anymore. + await new Promise(resolve => setTimeout(resolve, 500)); + this._recoveryWords.clear(); } } From 3423635ebabb188afa329d02cb04af15e0616fc8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 19 Jan 2026 18:09:25 +0100 Subject: [PATCH 08/27] Import fixes: avoid unnecessary duplicate validation of recovery words When pasting multiple words at once, avoid re-validating the recovery words for each word entered. This especially also deduplicates the RecoveryWords.Events.COMPLETE event and expensive computations following it. --- src/components/RecoveryWords.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/RecoveryWords.js b/src/components/RecoveryWords.js index 4f7323160..efe8943b2 100644 --- a/src/components/RecoveryWords.js +++ b/src/components/RecoveryWords.js @@ -142,6 +142,11 @@ class RecoveryWords extends Observable { try { const mnemonic = this.$fields.map(field => ('value' in field ? field.value : '-')); + if (this._mnemonic && this._mnemonic.words.join(' ') === mnemonic.join(' ')) { + // The words haven't changed and don't need to be processed again. This happens especially, when pasting + // multiple words at once, which triggers multiple _onFieldComplete and _checkPhaseComplete calls. + return; + } const type = Nimiq.MnemonicUtils.getMnemonicType(mnemonic); // throws on invalid mnemonic this._mnemonic = { words: mnemonic, type }; this.fire(RecoveryWords.Events.COMPLETE, mnemonic, type); From 0b426c23e2e51d63ab5c4cc583d4b8a3613c4335 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 20 Jan 2026 08:58:06 +0100 Subject: [PATCH 09/27] Import fixes: update Login File download headline once initiated This brings the implementation in line with the create and export flows. The same fix is also applied to the change-password flow. --- .../change-password/ChangePassword.css | 6 +++++ src/request/change-password/ChangePassword.js | 25 +++++++++++-------- src/request/change-password/index.html | 1 + src/request/import/Import.css | 6 +++++ src/request/import/Import.js | 11 ++++++-- src/request/import/index.html | 1 + 6 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/request/change-password/ChangePassword.css b/src/request/change-password/ChangePassword.css index 9334b13be..975f4f3d6 100644 --- a/src/request/change-password/ChangePassword.css +++ b/src/request/change-password/ChangePassword.css @@ -45,6 +45,12 @@ padding-bottom: 3rem; } +.page#download-file.loginfile-download-initiated .page-header .nq-h1:not(.confirm-download), +.page#download-file:not(.loginfile-download-initiated) .page-header .confirm-download, +.page#download-file.loginfile-download-initiated .skip { + display: none; +} + .page#download-file .page-body { display: flex; } diff --git a/src/request/change-password/ChangePassword.js b/src/request/change-password/ChangePassword.js index 94b3c1941..237043afc 100644 --- a/src/request/change-password/ChangePassword.js +++ b/src/request/change-password/ChangePassword.js @@ -36,7 +36,7 @@ class ChangePassword { document.getElementById(ChangePassword.Pages.ENTER_PASSWORD)); const $setPassword = /** @type {HTMLFormElement} */ ( document.getElementById(ChangePassword.Pages.SET_PASSWORD)); - const $downloadFile = /** @type {HTMLFormElement} */ ( + this.$downloadFile = /** @type {HTMLFormElement} */ ( document.getElementById(ChangePassword.Pages.DOWNLOAD_FILE)); // Elements @@ -46,12 +46,10 @@ class ChangePassword { $setPassword.querySelector('.password-setter-box')); const $loginFileIcon = /** @type {HTMLDivElement} */ ( $setPassword.querySelector('.login-file-icon')); - this.$setPasswordBackButton = /** @type {HTMLLinkElement} */ ( - $setPassword.querySelector('a.page-header-back-button')); const $downloadLoginFile = /** @type {HTMLDivElement} */ ( - $downloadFile.querySelector('.download-login-file')); - this.$skipDownloadButton = /** @type {HTMLLinkElement} */ ( - $downloadFile.querySelector('.skip')); + this.$downloadFile.querySelector('.download-login-file')); + const $skipDownloadButton = /** @type {HTMLLinkElement} */ ( + this.$downloadFile.querySelector('.skip')); // Components this._passwordSetter = new PasswordSetterBox($passwordSetter); @@ -100,13 +98,18 @@ class ChangePassword { this._passwordSetter.on(PasswordSetterBox.Events.SUBMIT, this._commitChangeAndOfferLoginFile.bind(this)); this._passwordSetter.on(PasswordSetterBox.Events.RESET, this.backToEnterPassword.bind(this)); - this._downloadLoginFile.on(DownloadLoginFile.Events.INITIATED, () => { - this.$skipDownloadButton.style.display = 'none'; - }); + this._downloadLoginFile.on( + DownloadLoginFile.Events.INITIATED, + () => this.$downloadFile.classList.add(DownloadLoginFile.Events.INITIATED), + ); + this._downloadLoginFile.on( + DownloadLoginFile.Events.RESET, + () => this.$downloadFile.classList.remove(DownloadLoginFile.Events.INITIATED), + ); this._downloadLoginFile.on(DownloadLoginFile.Events.DOWNLOADED, () => { this._resolve({ success: true }); }); - this.$skipDownloadButton.addEventListener('click', e => { + $skipDownloadButton.addEventListener('click', e => { e.preventDefault(); this._resolve({ success: true }); }); @@ -198,7 +201,7 @@ class ChangePassword { this._request.keyLabel, ); - this.$skipDownloadButton.style.display = ''; + this.$downloadFile.classList.remove(DownloadLoginFile.Events.INITIATED); window.location.hash = ChangePassword.Pages.DOWNLOAD_FILE; TopLevelApi.setLoading(false); } diff --git a/src/request/change-password/index.html b/src/request/change-password/index.html index 3891493c4..a716075fb 100644 --- a/src/request/change-password/index.html +++ b/src/request/change-password/index.html @@ -137,6 +137,7 @@

Create a new

Download new Login File

+

Download successful?

diff --git a/src/request/import/Import.css b/src/request/import/Import.css index 27a4f82a2..a9b71b3a3 100644 --- a/src/request/import/Import.css +++ b/src/request/import/Import.css @@ -225,6 +225,12 @@ padding-bottom: 3rem; } +.page#download-file.loginfile-download-initiated .page-header .nq-h1:not(.confirm-download), +.page#download-file:not(.loginfile-download-initiated) .page-header .confirm-download, +.page#download-file.loginfile-download-initiated .skip { + display: none; +} + .page#download-file .page-body { display: flex; flex-direction: column; diff --git a/src/request/import/Import.js b/src/request/import/Import.js index 5793b47cc..c2a54a59a 100644 --- a/src/request/import/Import.js +++ b/src/request/import/Import.js @@ -108,13 +108,20 @@ class Import { const encryptedSecret = await entropy.exportEncrypted(password); downloadLoginFile.setEncryptedEntropy(encryptedSecret, this._importedKeys.entropy.defaultAddress); - $skipDownloadButton.style.display = ''; + $downloadFilePage.classList.remove(DownloadLoginFile.Events.INITIATED); window.location.hash = Import.Pages.DOWNLOAD_LOGINFILE; }); // Events for DOWNLOAD_LOGINFILE page - downloadLoginFile.on(DownloadLoginFile.Events.INITIATED, () => { $skipDownloadButton.style.display = 'none'; }); + downloadLoginFile.on( + DownloadLoginFile.Events.INITIATED, + () => $downloadFilePage.classList.add(DownloadLoginFile.Events.INITIATED), + ); + downloadLoginFile.on( + DownloadLoginFile.Events.RESET, + () => $downloadFilePage.classList.remove(DownloadLoginFile.Events.INITIATED), + ); downloadLoginFile.on(DownloadLoginFile.Events.DOWNLOADED, () => resolve(this._importResult)); if (request.wordsOnly && 'expectedKeyId' in request) { diff --git a/src/request/import/index.html b/src/request/import/index.html index 6b6c982e9..8ab9ad281 100644 --- a/src/request/import/index.html +++ b/src/request/import/index.html @@ -147,6 +147,7 @@

Create a password

Download your Login File

+

Download successful?

From 3ad4857287ab8eab885137e48cb889f757b256bc Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 19 Jan 2026 19:51:53 +0100 Subject: [PATCH 10/27] Import fixes: fix and update Import demo --- demos/Export.html | 4 +-- demos/Import.html | 91 +++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 79 insertions(+), 16 deletions(-) diff --git a/demos/Export.html b/demos/Export.html index 273356750..d039a5770 100644 --- a/demos/Export.html +++ b/demos/Export.html @@ -15,11 +15,11 @@
diff --git a/demos/Import.html b/demos/Import.html index a2686e10b..103f768d8 100644 --- a/demos/Import.html +++ b/demos/Import.html @@ -8,34 +8,97 @@ +
+ + +
+
+ + + +


- From 1db1048d4f1d2464058f9faff0300725091e780d Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 19 Jan 2026 18:41:04 +0100 Subject: [PATCH 11/27] Import performance: take encrypted secret from KeyStore instead of re-encrypting Encrypting the secret is expensive, therefore avoid re-encrypting it for the Login File download, and instead take it from the KeyStore, where we already encrypted the secret. --- src/lib/KeyStore.js | 9 ++++----- src/request/import/Import.js | 9 +++++++-- tests/lib/IframeApi.spec.js | 2 +- tests/lib/KeyStore.spec.js | 12 ++++++------ 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/lib/KeyStore.js b/src/lib/KeyStore.js index 1a3b1e433..34ccdd6a2 100644 --- a/src/lib/KeyStore.js +++ b/src/lib/KeyStore.js @@ -70,7 +70,7 @@ class KeyStore { * @returns {Promise} */ async get(id, password) { - const keyRecord = await this._get(id); + const keyRecord = await this.getPlain(id); if (!keyRecord) { return null; } @@ -115,7 +115,7 @@ class KeyStore { * @returns {Promise} */ async getInfo(id) { - const keyRecord = await this._get(id); + const keyRecord = await this.getPlain(id); return keyRecord ? KeyInfo.fromObject(keyRecord, KeyStore.isEncrypted(keyRecord), keyRecord.defaultAddress) : null; @@ -124,9 +124,8 @@ class KeyStore { /** * @param {string} id * @returns {Promise} - * @private */ - async _get(id) { + async getPlain(id) { const db = await this.connect(); const transaction = db.transaction([KeyStore.DB_KEY_STORE_NAME], 'readonly'); const request = transaction.objectStore(KeyStore.DB_KEY_STORE_NAME).get(id); @@ -200,7 +199,7 @@ class KeyStore { * @returns {Promise} */ async setRsaKeypair(key, rsaKeyPair) { - const record = await this._get(key.id); + const record = await this.getPlain(key.id); if (!record) throw new Error('Key does not exist'); record.rsaKeyPair = await KeyStore._exportRsaKeyPair(rsaKeyPair, key); return this.putPlain(record); diff --git a/src/request/import/Import.js b/src/request/import/Import.js index c2a54a59a..c50131608 100644 --- a/src/request/import/Import.js +++ b/src/request/import/Import.js @@ -104,8 +104,13 @@ class Import { return; } - const entropy = /** @type {Nimiq.Entropy} */ (this._importedKeys.entropy.secret); - const encryptedSecret = await entropy.exportEncrypted(password); + // Set the encrypted secret of the Login File. Get it from the KeyStore, as re-encrypting is expensive. + const keyRecord = await KeyStore.instance.getPlain(this._importedKeys.entropy.id); + if (!keyRecord || !KeyStore.isEncrypted(keyRecord)) { + reject(new Errors.KeyguardError('Unexpected: key was not stored correctly')); + return; + } + const encryptedSecret = keyRecord.secret; downloadLoginFile.setEncryptedEntropy(encryptedSecret, this._importedKeys.entropy.defaultAddress); $downloadFilePage.classList.remove(DownloadLoginFile.Events.INITIATED); diff --git a/tests/lib/IframeApi.spec.js b/tests/lib/IframeApi.spec.js index ecb0267e7..04c648992 100644 --- a/tests/lib/IframeApi.spec.js +++ b/tests/lib/IframeApi.spec.js @@ -105,7 +105,7 @@ describe('IframeApi', () => { // check that keys have been copied correctly const ids = (await KeyStore.instance.list()).map(record => record.id); for (let id of ids) { - const keyRecord = await KeyStore.instance['_get'](id); + const keyRecord = await KeyStore.instance.getPlain(id); const expectedKeyRecord = /** @type {KeyRecord} */(Dummy.keyRecords().find(record => record.id === id)); expect(keyRecord).toEqual(expectedKeyRecord); } diff --git a/tests/lib/KeyStore.spec.js b/tests/lib/KeyStore.spec.js index f385dd533..d02327fb9 100644 --- a/tests/lib/KeyStore.spec.js +++ b/tests/lib/KeyStore.spec.js @@ -24,8 +24,8 @@ describe('KeyStore', () => { it('can get plain keys', async () => { const [key1, key2] = await Promise.all([ - KeyStore.instance['_get'](Dummy.keyInfos()[0].id), - KeyStore.instance['_get'](Dummy.keyInfos()[1].id), + KeyStore.instance.getPlain(Dummy.keyInfos()[0].id), + KeyStore.instance.getPlain(Dummy.keyInfos()[1].id), ]); expect(key1).toEqual(Dummy.keyRecords()[0]); expect(key2).toEqual(Dummy.keyRecords()[1]); @@ -75,8 +75,8 @@ describe('KeyStore', () => { // check that we can't get a removed key by address const removedKeys = await Promise.all([ - KeyStore.instance['_get'](Dummy.keyInfos()[0].id), - KeyStore.instance['_get'](Dummy.keyInfos()[1].id), + KeyStore.instance.getPlain(Dummy.keyInfos()[0].id), + KeyStore.instance.getPlain(Dummy.keyInfos()[1].id), ]); expect(removedKeys[0]).toBeUndefined(); expect(removedKeys[1]).toBeUndefined(); @@ -139,7 +139,7 @@ describe('KeyStore', () => { await KeyStore.instance.migrateAccountsToKeys(); expect(cookieSet).toBe(false); - const key1 = await KeyStore.instance['_get'](Dummy.keyInfos()[1].id); + const key1 = await KeyStore.instance.getPlain(Dummy.keyInfos()[1].id); expect(key1).toEqual(Dummy.keyRecords()[1]); const accountsDbAfter = await AccountStore.instance['connect'](); @@ -175,7 +175,7 @@ describe('KeyStore', () => { await KeyStore.instance.migrateAccountsToKeys(); expect(migrationCookieDeleted && accountsCookieDeleted).toBe(true); - const key1 = await KeyStore.instance['_get'](Dummy.keyInfos()[1].id); + const key1 = await KeyStore.instance.getPlain(Dummy.keyInfos()[1].id); expect(key1).toEqual(Dummy.keyRecords()[1]); const accountsDb = await AccountStore.instance['connect'](); From 69fd36effce2dd05d9d556f29b418030c91a4988 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 19 Jan 2026 19:31:21 +0100 Subject: [PATCH 12/27] Import performance: parallelize encryption of secret and derivation of adresses/pub keys Run encryption and storage of key in the background, which is quite expensive but luckily runs on a secondary thread, in parallel to the derivations on the main thread, which are also expensive due to the involved mnemonicToSeed which runs a pbkdf2 on the main thread. There is also more potential for plenty more performance improvements, which we'll not tackle right now though: - (re)encrypting the secret is expensive. If the secret is already encrypted with the latest version in the imported LoginFile, store this already encypted data, instead of re-encrypting. - mnemonicToSeed is duplicated between Key.deriveAddress, BitcoinKey.deriveExtendedPublicKey and (via ethers) in PolygonKey.deriveAddress, and is a very expensive call due to the called pbkdf2, which is on top running synchronously and blocking the main thread. Consider caching the seed in the KeyStore, or at least as variable in the Key. This should also speed up other situations, where many addresses are derived, for example the address detection via Keyguard iframe in the Hub. - change or replace mnemonicToSeed with an implementation of pbkdf2 that doesn't run on the main thread, for example via the web crypto api, and then move the creation of the key results out of here into a separate method, that can already be launched when the _importedKeys are received, and runs in the background while the user can continue setting a password. --- src/request/import/Import.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/request/import/Import.js b/src/request/import/Import.js index c50131608..946eee298 100644 --- a/src/request/import/Import.js +++ b/src/request/import/Import.js @@ -198,6 +198,12 @@ class Import { const key = this._importedKeys.entropy; const keyLabel = labels && labels.entropy ? labels.entropy : undefined; const { requestedKeyPaths, bitcoinXPubPath, polygonAccountPath } = this._request; + + // Start encryption and storage of key in the background, which is quite expensive but luckily runs on a + // secondary thread, in parallel to the derivations on the main thread below, which are also expensive + // due to the involved mnemonicToSeed which runs a pbkdf2 on the main thread. + const keyStorePromise = KeyStore.instance.put(key, encryptionKey || undefined); + /** @type {{keyPath: string, address: Uint8Array}[]} */ const addresses = requestedKeyPaths.map(keyPath => ({ keyPath, @@ -218,7 +224,7 @@ class Import { key.secret.serialize(), ) || undefined; - await KeyStore.instance.put(key, encryptionKey || undefined); // throws on error + await keyStorePromise; // throws on error this._importResult.push({ keyId: key.id, From 6d9c8aaeaf1de076ce1e61444f26c7a2e8e5d15e Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 20 Jan 2026 12:21:50 +0100 Subject: [PATCH 13/27] Backup Codes: BackupCodesInput --- src/components/BackupCodesInput.css | 35 +++++ src/components/BackupCodesInput.js | 233 ++++++++++++++++++++++++++++ src/lib/BackupCodes.js | 22 ++- src/nimiq-style.css | 4 + src/translations/de.json | 7 +- src/translations/en.json | 1 + src/translations/es.json | 7 +- src/translations/fr.json | 7 +- src/translations/nl.json | 7 +- src/translations/pt.json | 7 +- src/translations/ru.json | 7 +- src/translations/uk.json | 7 +- src/translations/zh.json | 7 +- 13 files changed, 326 insertions(+), 25 deletions(-) create mode 100644 src/components/BackupCodesInput.css create mode 100644 src/components/BackupCodesInput.js diff --git a/src/components/BackupCodesInput.css b/src/components/BackupCodesInput.css new file mode 100644 index 000000000..7effbf8ce --- /dev/null +++ b/src/components/BackupCodesInput.css @@ -0,0 +1,35 @@ +/* specific backup-codes-input styles */ + +.backup-codes-input.disable-shake-animation .message-bubble { + animation: none !important; +} + +.backup-codes-input .message-bubble .code { + appearance: unset; + display: block; + width: 100%; + max-height: 12rem; + padding: unset; + border: unset; + outline: unset; + resize: unset; + field-sizing: content; + background: unset; +} + +.backup-codes-input .message-bubble .code::placeholder { + color: rgba(255, 255, 255, 0.4); +} + +/* message-bubble positioning for individual steps */ + +.backup-codes-input.backup-codes-enter-code-1 .message-bubble.code-2 { + transform: translate(12.5rem, 5.25rem); + transform: translate(round(12.5rem, 1px), round(5.25rem, 1px)); + z-index: -1; +} + +.backup-codes-input.backup-codes-enter-code-2 .message-bubble.code-1 { + transform: translate(-12.5rem, -5.25rem); + transform: translate(round(-12.5rem, 1px), round(-5.25rem, 1px)); +} diff --git a/src/components/BackupCodesInput.js b/src/components/BackupCodesInput.js new file mode 100644 index 000000000..caa69c6b1 --- /dev/null +++ b/src/components/BackupCodesInput.js @@ -0,0 +1,233 @@ +/* global I18n */ +/* global AnimationUtils */ +/* global BackupCodes */ +/* global BackupCodesIllustrationBase */ + +/** + * @extends {BackupCodesIllustrationBase} + */ +class BackupCodesInput extends BackupCodesIllustrationBase { + /** + * @param {BackupCodesInput.Steps} step + * @param {?HTMLFormElement} [$el] + */ + constructor(step, $el) { + super(BackupCodesInput.Steps, step, { container: 'form', messageBubble: 'label', code: 'textarea' }, $el); + this.$el.classList.add('backup-codes-input'); + + /** @type {[?string, ?string]} */ + this._backupCodes = [null, null]; // successfully validated codes + this._ignoreInputEvents = false; + this._disableShakeAnimation = false; + + for (const codeIndex of /** @type {[1, 2]} */([1, 2])) { + const $code = this._codes[codeIndex - 1]; + $code.name = `backup-code-${codeIndex}`; + $code.spellcheck = false; + $code.autocapitalize = 'off'; + $code.autocomplete = 'off'; + $code.setAttribute('autocorrect', 'off'); + $code.placeholder = I18n.translatePhrase('backup-codes-input-placeholder'); + $code.setAttribute('data-i18n-placeholder', 'backup-codes-input-placeholder'); // auto-update on lang change + + $code.addEventListener('keydown', event => { + if (this._ignoreInputEvents) return; + const key = event.key; + if (key.length === 1 && !BackupCodes.isValidCharacter(key)) { + // Omit characters outside the allowed alphabet. We don't simply call _sanitizeAndValidateCode to + // avoid a bogus undo history entry getting created while the sanitized code didn't actually change. + event.preventDefault(); + this.fire(BackupCodesInput.Events.INVALID_EDIT, codeIndex); + if (this._disableShakeAnimation) return; + AnimationUtils.animate('shake-only', this._messageBubbles[codeIndex - 1]); + } + if (event.key === 'Enter' || event.key === 'Escape') { + // Interpret as the user trying to submit the code. We don't include Tab to not break the browser's + // default behavior of switching to the next UI element. For Tab, _sanitizeAndValidateCode is called + // via a blur event. + event.preventDefault(); + this._sanitizeAndValidateCode(codeIndex); + } + }); + $code.addEventListener('input', () => { + if (this._ignoreInputEvents) return; + this.fire(BackupCodesInput.Events.CODE_EDIT, codeIndex, $code.value); + this._sanitizeAndValidateCode(codeIndex, /* onlyFeedbackOnInvalidCharacters */ true); + }); + $code.addEventListener('blur', () => this._sanitizeAndValidateCode(codeIndex)); + } + } + + /** + * @override + * @type {BackupCodesInput.Steps} + */ + get step() { + // Just delegate to parent getter. + // Note that we redeclare the getter, because when overwriting a setter, the getter needs to be overridden, too. + return super.step; + } + + /** + * @override + * @param {BackupCodesInput.Steps} step + */ + set step(step) { + super.step = step; + this._updateDisabledStates(); + } + + /** + * @override + * @param {boolean} isLoading + */ + set loading(isLoading) { + super.loading = isLoading; + this._updateDisabledStates(); + } + + /** + * @param {boolean} disabled + */ + set shakeAnimationDisabled(disabled) { + this._disableShakeAnimation = disabled; + this.$el.classList.toggle('disable-shake-animation', disabled); + } + + focus() { + const $codeToFocus = this._codes.find($code => !$code.disabled); + if (!$codeToFocus) return; + $codeToFocus.focus(); + } + + /** + * @override + * @protected + * @param {1 | 2} codeIndex + * @param {string} code + */ + _setCode(codeIndex, code) { + this._codes[codeIndex - 1].value = code; + this.fire(BackupCodesInput.Events.CODE_EDIT, codeIndex, code); + this._sanitizeAndValidateCode(codeIndex); + } + + /** + * @override + * @protected + * @param {HTMLLabelElement} messageBubble + * @param {BackupCodesInput.Steps} step + * @returns {Record<'masked' | 'faded' | 'zoomed' | 'complete', boolean>} + */ + _getMessageBubbleClasses(messageBubble, step) { + const { ENTER_CODE_1 } = BackupCodesInput.Steps; + const codeIndex = this._messageBubbles.indexOf(messageBubble) + 1; + + const masked = false; + const faded = codeIndex === (step === ENTER_CODE_1 ? 2 : 1); + const zoomed = true; + const complete = false; + return { masked, faded, zoomed, complete }; // eslint-disable-line object-curly-newline + } + + /** + * @private + */ + _updateDisabledStates() { + for (let i = 0; i <= 1; i++) { + const $messageBubble = this._messageBubbles[i]; + const $code = this._codes[i]; + $code.disabled = ['masked', 'faded', 'loading'].some(cl => $messageBubble.classList.contains(cl)); + } + } + + /** + * @private + * @param {1 | 2} codeIndex + * @param {boolean} [onlyFeedbackOnInvalidCharacters=false] + */ + _sanitizeAndValidateCode(codeIndex, onlyFeedbackOnInvalidCharacters = false) { + const $code = this._codes[codeIndex - 1]; + const code = $code.value; + if (code === this._backupCodes[codeIndex - 1]) return; // duplicate invocation for successfully validated code + + // Sanitize code + // Iterate code via its iterator to correctly handle astral unicode characters represented as surrogate pairs. + const sanitizedCode = [...code].filter(char => BackupCodes.isValidCharacter(char)).join(''); + if (sanitizedCode !== code) { + // Set the sanitized code. Adapt the cursor position for the omitted characters. We can assume that all + // omitted characters were before the cursor or selection end, as the cursor is typically placed after the + // characters that have been entered, pasted or dragged-dropped, and after the selection start, if a range + // is selected, as the change is typically contained by the range. + const omittedCharacterCount = code.length - sanitizedCode.length; + const selectionStart = $code.selectionStart; + const selectionEnd = $code.selectionEnd; + try { + // Replace text via execCommand, such that the change is added to the undo history. + this._ignoreInputEvents = true; // ignore input events triggered by execCommand + if (!document.execCommand + || !document.execCommand('undo', false) // remove the user's history entry + || !document.execCommand('selectAll', false) + || !document.execCommand('insertText', false, sanitizedCode)) { + // fallback + $code.value = sanitizedCode; + } + } finally { + this._ignoreInputEvents = false; + } + // Restore cursor/selection. + $code.selectionEnd = Math.max(0, selectionEnd - omittedCharacterCount); + $code.selectionStart = Math.min(selectionStart, $code.selectionEnd); + } + + // Validate code + if (BackupCodes.isValidBackupCode(sanitizedCode)) { + if (sanitizedCode === this._backupCodes[codeIndex - 1]) return; // avoid duplicate events + this._backupCodes[codeIndex - 1] = sanitizedCode; + this.fire(BackupCodesInput.Events.CODE_COMPLETE, codeIndex, sanitizedCode); + if (!this._backupCodes[0] || !this._backupCodes[1]) return; + this.fire(BackupCodesInput.Events.CODES_COMPLETE, this._backupCodes); + } else { + this._backupCodes[codeIndex - 1] = null; + if (!code || (onlyFeedbackOnInvalidCharacters && sanitizedCode === code)) return; + this.fire(BackupCodesInput.Events.INVALID_EDIT, codeIndex); + if (this._disableShakeAnimation) return; + AnimationUtils.animate('shake-only', this._messageBubbles[codeIndex - 1]); + } + } + + /** + * @param {ViewTransition} viewTransition + * @param {BackupCodesInput.Steps} oldStep + * @param {BackupCodesInput.Steps} newStep + * @param {HTMLElement} $viewport + * @returns {Promise} + */ + static async customizeViewTransition(viewTransition, oldStep, newStep, $viewport) { + return BackupCodesIllustrationBase._customizeViewTransition( + viewTransition, + oldStep, + newStep, + $viewport, + BackupCodesInput.TopMessageBubbleForStep, + ); + } +} + +/** @enum {'backup-codes-enter-code-1' | 'backup-codes-enter-code-2'} */ +BackupCodesInput.Steps = Object.freeze({ + ENTER_CODE_1: 'backup-codes-enter-code-1', + ENTER_CODE_2: 'backup-codes-enter-code-2', +}); + +BackupCodesInput.TopMessageBubbleForStep = Object.freeze({ + [BackupCodesInput.Steps.ENTER_CODE_1]: 1, + [BackupCodesInput.Steps.ENTER_CODE_2]: 2, +}); + +BackupCodesInput.Events = Object.freeze({ + CODE_EDIT: 'code-edit', + INVALID_EDIT: 'invalid-edit', + CODE_COMPLETE: 'code-complete', + CODES_COMPLETE: 'codes-complete', +}); diff --git a/src/lib/BackupCodes.js b/src/lib/BackupCodes.js index 6b8efb3ab..a4f5f3093 100644 --- a/src/lib/BackupCodes.js +++ b/src/lib/BackupCodes.js @@ -64,11 +64,28 @@ class BackupCodes { // Check whether our recovered key would derive the same codes, as a checksum check. const [checksum1, checksum2] = await BackupCodes.generate(key); - if (checksum1 !== code1 || checksum2 !== code2) throw new Error('Invalid Backup Codes: checksum mismatch'); + if ((checksum1 !== code1 || checksum2 !== code2) + && (checksum1 !== code2 || checksum2 !== code1)) throw new Error('Invalid Backup Codes: checksum mismatch'); return key; } + /** + * @param {string} char + * @returns {boolean} + */ + static isValidCharacter(char) { + return BackupCodes.VALID_CHARACTER_REGEX.test(char); + } + + /** + * @param {string} code + * @returns {boolean} + */ + static isValidBackupCode(code) { + return BackupCodes.VALID_CODE_REGEX.test(code); + } + /** * @private * @param {Uint8Array} code @@ -103,4 +120,7 @@ BackupCodes.FLAGS_BIT_SHIFT = 8 - BackupCodes.FLAGS_COUNT; BackupCodes.FLAGS_BIT_MASK = (0xff << BackupCodes.FLAGS_BIT_SHIFT) & 0xff; BackupCodes.VERSION_BIT_MASK = (~BackupCodes.FLAGS_BIT_MASK) & 0xff; +BackupCodes.VALID_CHARACTER_REGEX = /^[A-Za-z0-9!;]$/u; +BackupCodes.VALID_CODE_REGEX = /^[A-Za-z0-9!;]{44}$/u; + /* eslint-enable no-bitwise */ diff --git a/src/nimiq-style.css b/src/nimiq-style.css index 94e1db376..e452b0901 100644 --- a/src/nimiq-style.css +++ b/src/nimiq-style.css @@ -12,6 +12,10 @@ to { transform: none; } } +.shake-only { + animation: shake .4s ease; +} + @keyframes shake-background { from { opacity: 0; } diff --git a/src/translations/de.json b/src/translations/de.json index a4f723b60..db1ece0f3 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -48,12 +48,12 @@ "import-words-error": "Das ist kein gültiges Konto. Schreibfehler?", "import-words-wrong-seed-phrase": "Diese Wiederherstellungswörter gehören zu einem anderen Konto", - "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", - "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + "import-set-password-file-available": "Die Wiederherstellungswörter erzeugen eine neue Login‑Datei. Setze ein Passwort, um sie zu schützen.", + "import-set-password-file-unavailable": "Die Wiederherstellungswörter erzeugen ein neues Konto. Setze ein Passwort, um es zu schützen.", "import-file-heading": "Mit Login-Datei einloggen", "import-file-login-to-continue": "Bitte logge dich neu ein, um fortzufahren.", - "import-file-button-words": "Login with Recovery Words", + "import-file-button-words": "Mit Wiederherstellungswörtern einloggen", "import-file-create-account": "Erstelle ein neues Konto", "import-file-qr-tooltip": "Scanne deine Login‑Datei mit der Kamera deines Geräts.", "import-unlock-account": "Entsperre dein Konto", @@ -63,6 +63,7 @@ "file-import-error-invalid": "Ungültige Login‑Datei.", "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "backup-codes-input-placeholder": "Enter code...", "qr-video-scanner-cancel": "Abbrechen", "qr-video-scanner-no-camera": "Dein Endgerät verfügt über keine verwendbare Kamera.", diff --git a/src/translations/en.json b/src/translations/en.json index 6424cd728..1226389b7 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -63,6 +63,7 @@ "file-import-error-invalid": "Invalid Login File.", "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "backup-codes-input-placeholder": "Enter code...", "qr-video-scanner-cancel": "Cancel", "qr-video-scanner-no-camera": "Your device does not have an accessible camera.", diff --git a/src/translations/es.json b/src/translations/es.json index 7126e52e4..5e36905e0 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -48,12 +48,12 @@ "import-words-error": "Esta no es una cuenta válida. Revise que escribió todos los caracteres.", "import-words-wrong-seed-phrase": "Estas Palabras de Recuperación le pertenecen a una cuenta diferente.", - "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", - "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + "import-set-password-file-available": "Usando las Palabras de Recuperación se crea una nueva un nuevo Archivo de Sesión. Cree una contraseña para asegurarlo.", + "import-set-password-file-unavailable": "Usando las Palabras de Recuperación se crea una nueva cuenta. Cree una contraseña para asegurarla.", "import-file-heading": "Importe su Archivo de Sesión", "import-file-login-to-continue": "Por favor inicie sesión de nuevo para continuar.", - "import-file-button-words": "Login with Recovery Words", + "import-file-button-words": "Inicie sesión con Palabras de Recuperación", "import-file-create-account": "Crear nueva cuenta", "import-file-qr-tooltip": "Escanee su Archivo de Sesión con la cámara de su dispositivo.", "import-unlock-account": "Desbloquear su Cuenta", @@ -63,6 +63,7 @@ "file-import-error-invalid": "Archivo de Sesión inválido.", "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "backup-codes-input-placeholder": "Enter code...", "qr-video-scanner-cancel": "Cancelar", "qr-video-scanner-no-camera": "Su dispositivo no tiene una cámara accesible.", diff --git a/src/translations/fr.json b/src/translations/fr.json index 7b66bc94b..1b4d0d2d4 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -48,12 +48,12 @@ "import-words-error": "Ce n'est pas un compte valide. Faute de frappe ?", "import-words-wrong-seed-phrase": "Ces Mots de Récupération appartiennent à un autre compte", - "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", - "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + "import-set-password-file-available": "L'utilisation des Mots de Récupération crée un nouveau Fichier de Connexion. Créez un mot de passe pour le sécuriser.", + "import-set-password-file-unavailable": "L'utilisation des Mots de Récupération crée un nouveau compte. Créez un mot de passe pour le sécuriser.", "import-file-heading": "Importez votre Fichier de Connexion", "import-file-login-to-continue": "Veuillez vous reconnecter pour continuer.", - "import-file-button-words": "Login with Recovery Words", + "import-file-button-words": "Se connecter avec les Mots de Récupération", "import-file-create-account": "Créer un nouveau compte", "import-file-qr-tooltip": "Scannez votre Fichier de Connexion avec la caméra de votre appareil.", "import-unlock-account": "Déverrouiller votre Compte", @@ -63,6 +63,7 @@ "file-import-error-invalid": "Fichier de connexion invalide.", "backup-codes-illustration-label": "Code de récupération Nimiq {n}/2", + "backup-codes-input-placeholder": "Enter code...", "qr-video-scanner-cancel": "Annuler", "qr-video-scanner-no-camera": "Votre appareil n'a pas de caméra accessible.", diff --git a/src/translations/nl.json b/src/translations/nl.json index b5790e985..156ab4816 100644 --- a/src/translations/nl.json +++ b/src/translations/nl.json @@ -48,12 +48,12 @@ "import-words-error": "Dit is geen geldig account. Typfoutje?", "import-words-wrong-seed-phrase": "Deze herstelwoorden horen bij een ander account", - "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", - "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + "import-set-password-file-available": "Met behulp van de herstelwoorden wordt een nieuwe Login File gemaakt. Maak een wachtwoord om het te beveiligen.", + "import-set-password-file-unavailable": "Met behulp van de herstelwoorden maak je een nieuw account aan. Maak een wachtwoord om het te beveiligen.", "import-file-heading": "Importeer je Login File", "import-file-login-to-continue": "Log opnieuw in om door te gaan.", - "import-file-button-words": "Login with Recovery Words", + "import-file-button-words": "Inloggen met herstelwoorden", "import-file-create-account": "Creëer nieuw account", "import-file-qr-tooltip": "Scan je Login File met de camera.", "import-unlock-account": "Ontgrendel je account", @@ -63,6 +63,7 @@ "file-import-error-invalid": "Ongeldige Login File.", "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "backup-codes-input-placeholder": "Enter code...", "qr-video-scanner-cancel": "Annuleer", "qr-video-scanner-no-camera": "Je apparaat heeft geen toegankelijke camera.", diff --git a/src/translations/pt.json b/src/translations/pt.json index 5cd23c7e2..dae1ed7ae 100644 --- a/src/translations/pt.json +++ b/src/translations/pt.json @@ -48,12 +48,12 @@ "import-words-error": "Isto não é uma conta válida. Erro de escrita?", "import-words-wrong-seed-phrase": "Estas Palavra de Recuperação pertencem a uma conta diferente", - "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", - "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + "import-set-password-file-available": "Usar as Palavra de Recuperação cria um novo Ficheiro de Login. Cria uma palavra passe para proteger o ficheiro.", + "import-set-password-file-unavailable": "Usar as Palavra de Recuperação cria uma nova conta. Cria uma palavra passe para protegê-la.", "import-file-heading": "Importa o teu Ficheiro de Login", "import-file-login-to-continue": "Por favor faz login outra vez e continua.", - "import-file-button-words": "Login with Recovery Words", + "import-file-button-words": "Inicia sessão com as Palavra de Recuperação", "import-file-create-account": "Criar nova conta", "import-file-qr-tooltip": "Digitalize o teu Ficheiro de Login com a câmara do teu dispositivo.", "import-unlock-account": "Desbloqueia a tua Conta", @@ -63,6 +63,7 @@ "file-import-error-invalid": "Ficheiro de Login inválido.", "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "backup-codes-input-placeholder": "Enter code...", "qr-video-scanner-cancel": "Cancelar", "qr-video-scanner-no-camera": "O teu dispositivo não tem uma câmara acessível.", diff --git a/src/translations/ru.json b/src/translations/ru.json index 224017a90..3c62e3324 100644 --- a/src/translations/ru.json +++ b/src/translations/ru.json @@ -48,12 +48,12 @@ "import-words-error": "Это некорректный аккаунт. Опечатались?", "import-words-wrong-seed-phrase": "Эта Защитная Фраза принадлежит другому аккаунту", - "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", - "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + "import-set-password-file-available": "Каждый раз, когда вы используете для восстановления Защитную Фразу, создаётся новый Файл Авторизации. Чтобы обезопасить его, создайте пароль.", + "import-set-password-file-unavailable": "Каждый раз, когда вы используете для восстановления Защитную Фразу, создаётся новый Файл Авторизации. Чтобы обезопасить его, создайте пароль.", "import-file-heading": "Импортировать ваш Файл Авторизации", "import-file-login-to-continue": "Войдите снова чтобы продолжить.", - "import-file-button-words": "Login with Recovery Words", + "import-file-button-words": "Войти с помощью Защитной Фразы", "import-file-create-account": "Создать новый аккаунт", "import-file-qr-tooltip": "Отсканируйте Файл Авторизации камерой.", "import-unlock-account": "Разблокировать ваш Аккаунт", @@ -63,6 +63,7 @@ "file-import-error-invalid": "Некорректный Файл Авторизации.", "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "backup-codes-input-placeholder": "Enter code...", "qr-video-scanner-cancel": "Отмена", "qr-video-scanner-no-camera": "Камера вашего устройства недоступна.", diff --git a/src/translations/uk.json b/src/translations/uk.json index 6bc8818a2..310ed8571 100644 --- a/src/translations/uk.json +++ b/src/translations/uk.json @@ -48,12 +48,12 @@ "import-words-error": "Це невірний рахунок. Схибили?", "import-words-wrong-seed-phrase": "Ці секретні слова відповідають іншому рахунку", - "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", - "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + "import-set-password-file-available": "Використання секретних слів створює новий файл-ключ. Створіть пароль щоб захистити його.", + "import-set-password-file-unavailable": "Створіть новий рахунок за допомогою секретних слів. Створіть пароль щоб захистити його.", "import-file-heading": "Завантажте файл-ключ", "import-file-login-to-continue": "Будь ласка, увійдіть ще раз, щоб продовжити.", - "import-file-button-words": "Login with Recovery Words", + "import-file-button-words": "Увійти за допомогою секретних слів", "import-file-create-account": "Створити новий рахунок", "import-file-qr-tooltip": "Відскануйте свій файл-ключ за допомогою камери вашого пристрою", "import-unlock-account": "Розблокувати рахунок", @@ -63,6 +63,7 @@ "file-import-error-invalid": "Невірний файл-ключ.", "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "backup-codes-input-placeholder": "Enter code...", "qr-video-scanner-cancel": "Скасувати", "qr-video-scanner-no-camera": "Ваш пристрій не має доступної камери.", diff --git a/src/translations/zh.json b/src/translations/zh.json index dc51282b4..c66a9ad60 100644 --- a/src/translations/zh.json +++ b/src/translations/zh.json @@ -48,12 +48,12 @@ "import-words-error": "这不是有效的帐户,是否有错别字?", "import-words-wrong-seed-phrase": "这些助记词属于另一个帐户", - "import-set-password-file-available": "Using the Recovery Words creates a new Login File. Create a password to secure it.", - "import-set-password-file-unavailable": "Using the Recovery Words creates a new account. Create a password to secure it.", + "import-set-password-file-available": "使用助记词创建一个新的登录文件,并设置密码保护", + "import-set-password-file-unavailable": "使用助记词创建一个新的登录文件,并设置密码保护", "import-file-heading": "导入你的登录文件", "import-file-login-to-continue": "请再次登录以继续", - "import-file-button-words": "Login with Recovery Words", + "import-file-button-words": "使用助记词登录", "import-file-create-account": "建立新帐户", "import-file-qr-tooltip": "使用设备的相机扫描你的登录文件", "import-unlock-account": "解锁你的账户", @@ -63,6 +63,7 @@ "file-import-error-invalid": "无效的登录文件", "backup-codes-illustration-label": "Nimiq Backup Code {n}/2", + "backup-codes-input-placeholder": "Enter code...", "qr-video-scanner-cancel": "取消", "qr-video-scanner-no-camera": "你的设备没有可访问的相机", From 0ecc999b80e26875528cbfb1c6d496500d19afdd Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 20 Jan 2026 14:39:22 +0100 Subject: [PATCH 14/27] Backup Codes: import flow --- src/request/import/Import.css | 95 +++++++++++++ src/request/import/Import.js | 24 +++- src/request/import/ImportBackupCodes.js | 174 ++++++++++++++++++++++++ src/request/import/index.html | 76 ++++++++++- src/translations/de.json | 18 ++- src/translations/en.json | 18 ++- src/translations/es.json | 18 ++- src/translations/fr.json | 18 ++- src/translations/nl.json | 18 ++- src/translations/pt.json | 18 ++- src/translations/ru.json | 18 ++- src/translations/uk.json | 18 ++- src/translations/zh.json | 18 ++- 13 files changed, 497 insertions(+), 34 deletions(-) create mode 100644 src/request/import/ImportBackupCodes.js diff --git a/src/request/import/Import.css b/src/request/import/Import.css index a9b71b3a3..5a1c2ee57 100644 --- a/src/request/import/Import.css +++ b/src/request/import/Import.css @@ -155,6 +155,51 @@ z-index: 3; } +.page#select .page-body { + display: flex; + flex-direction: column; + justify-content: center; + padding-top: 0; + gap: 2.5rem; +} + +.page#select .import-selector-option { + contain: layout paint style; + apprearance: none; + padding: 2.5rem; + border: none; + border-radius: 1.25rem; + font-family: inherit; + cursor: pointer; + text-align: left; + color: inherit; + background-color: rgba(255, 255, 255, .12); + transition: background-color .3s var(--nimiq-ease); +} + +.page#select .import-selector-option:hover { + background-color: rgba(255, 255, 255, .2); +} + +.page#select .import-selector-option img.icon-backup-codes { + content: url('data:image/svg+xml,'); +} + +.page#select .import-selector-option img.icon-recovery-words { + content: url('data:image/svg+xml,'); +} + +.page#select .import-selector-option h2 { + margin: 2.25rem 0 1.5rem; +} + +.page#select .import-selector-option p { + margin: 0; + font-size: 2rem; + line-height: 130%; + color: rgba(255, 255, 255, .7); +} + .page#recovery-words .nq-label { font-weight: bold; margin-top: 4rem; @@ -200,6 +245,56 @@ height: 100%; } +.page#backup-codes .page-header { + padding-top: 3rem; + padding-bottom: 1.5rem; +} + +.page#backup-codes .page-header .nq-notice { + margin-top: 1.75rem; + line-height: 1.4; + color: rgba(255, 255, 255, .6); +} + +.page#backup-codes .page-body { + position: relative; + padding-top: 1rem; +} + +.page#backup-codes .page-body .backup-codes-input { + position: absolute; + height: 100%; + transition: filter .6s var(--nimiq-ease), opacity .6s var(--nimiq-ease); +} + +.page#backup-codes .page-body .backup-codes-input.backup-codes-enter-code-2 { + padding-top: 6rem; +} + +.page#backup-codes .page-body .loading-spinner { + display: none; +} + +body.loading .page#backup-codes .page-body .backup-codes-input { + filter: blur(20px); + opacity: .4; +} + +body.loading .page#backup-codes .page-body .loading-spinner { + display: block; + position: absolute; + top: calc(50% - 1rem); + left: calc(50% - 3.375rem); +} + +.page#backup-codes .page-footer .nq-notice { + margin: 0 3rem 7rem; + font-size: 2.5rem; + font-weight: 500; + text-align: center; + view-transition-name: backup-codes-warning; +} + .page#set-password .page-body { display: flex; flex-direction: column; diff --git a/src/request/import/Import.js b/src/request/import/Import.js index 946eee298..6b7546058 100644 --- a/src/request/import/Import.js +++ b/src/request/import/Import.js @@ -9,6 +9,7 @@ /* global ImportApi */ /* global ImportFile */ /* global ImportWords */ +/* global ImportBackupCodes */ /* global FlippableHandler */ /* global DownloadLoginFile */ /* global LoginFileIcon */ @@ -41,15 +42,21 @@ class Import { const importFileHandler = new ImportFile(request); const importWordsHandler = new ImportWords(request); + const importBackupCodesHandler = new ImportBackupCodes(request); this._initialHandler = request.wordsOnly ? importWordsHandler : importFileHandler; // Pages + const $selectPage = /** @type {HTMLDivElement} */ (document.getElementById(Import.Pages.SELECT)); this.$setPasswordPage = /** @type {HTMLFormElement} */ ( document.getElementById(Import.Pages.SET_PASSWORD)); const $downloadFilePage = /** @type {HTMLFormElement} */ ( document.getElementById(Import.Pages.DOWNLOAD_LOGINFILE)); // Elements + const $optionRecoveryWordsButton = /** @type {HTMLButtonElement} */ ( + $selectPage.querySelector('.option-recovery-words')); + const $optionBackupCodesButton = /** @type {HTMLButtonElement} */ ( + $selectPage.querySelector('.option-backup-codes')); const $passwordSetter = /** @type {HTMLFormElement} */ ( this.$setPasswordPage.querySelector('.password-setter-box')); const $loginFileIcon = /** @type {HTMLDivElement} */ ( @@ -73,10 +80,20 @@ class Import { importFileHandler.on(ImportFile.Events.RESET, () => this._resetKeys()); importWordsHandler.on(ImportWords.Events.IMPORT, keys => this._importKeys(keys)); importWordsHandler.on(ImportWords.Events.RESET, () => this._resetKeys()); + importBackupCodesHandler.on(ImportBackupCodes.Events.IMPORT, keys => this._importKeys(keys)); + importBackupCodesHandler.on(ImportBackupCodes.Events.RESET, () => this._resetKeys()); - importFileHandler.on(ImportFile.Events.GO_TO_OTHER_IMPORT_OPTION, () => importWordsHandler.run()); + importFileHandler.on( + ImportFile.Events.GO_TO_OTHER_IMPORT_OPTION, + () => { window.location.hash = Import.Pages.SELECT; }, + ); importFileHandler.on(ImportFile.Events.GO_TO_CREATE, () => reject(new Errors.GoToCreate())); + // Events for SELECT page + + $optionRecoveryWordsButton.addEventListener('click', () => importWordsHandler.run()); + $optionBackupCodesButton.addEventListener('click', () => importBackupCodesHandler.run()); + // Events for SET_PASSWORD page this._passwordSetter.on(PasswordSetterBox.Events.RESET, () => { @@ -281,8 +298,9 @@ class Import { } Import.Pages = { - // On import of a non-encrypted backup (recovery words), the user is asked to set a password and offered to download - // a new Login File. + SELECT: 'select', + // On import of a non-encrypted backup (recovery words or backup codes), the user is asked to set a password and + // offered to download a new Login File. SET_PASSWORD: 'set-password', DOWNLOAD_LOGINFILE: 'download-file', }; diff --git a/src/request/import/ImportBackupCodes.js b/src/request/import/ImportBackupCodes.js new file mode 100644 index 000000000..58cbf7e31 --- /dev/null +++ b/src/request/import/ImportBackupCodes.js @@ -0,0 +1,174 @@ +/* global I18n */ +/* global Observable */ +/* global Nimiq */ +/* global TopLevelApi */ +/* global BackupCodes */ +/* global ViewTransitionHandler */ +/* global ProgressIndicator */ +/* global BackupCodesInput */ + +class ImportBackupCodes extends Observable { + /** + * @param {Parsed} request + */ + constructor(request) { + super(); + + this._request = request; + this._codesComplete = false; + this._warningText = ''; + + // Pages + this.$codesPage = /** @type {HTMLDivElement} */ (document.getElementById(ImportBackupCodes.Pages.ENTER_CODES)); + + // Elements + const $progressIndicator = /** @type {HTMLDivElement} */ (this.$codesPage.querySelector('.progress-indicator')); + const $backupCodesInput = /** @type {HTMLFormElement} */ (this.$codesPage.querySelector('.backup-codes-input')); + this.$warning = /** @type {HTMLParagraphElement} */ (this.$codesPage.querySelector('.warning')); + + // Components + this._progressIndicator = new ProgressIndicator($progressIndicator, 2, 1); + this._backupCodesInput = new BackupCodesInput(BackupCodesInput.Steps.ENTER_CODE_1, $backupCodesInput); + + // Events + this._backupCodesInput.on(BackupCodesInput.Events.CODE_EDIT, (codeIndex, code) => { + if (!code || window.location.hash.replace(/^#/, '') !== ImportBackupCodes.Pages.ENTER_CODES) return; + if (this._codesComplete) { + this.fire(ImportBackupCodes.Events.RESET); + } + this._codesComplete = false; + this._setWarning(''); + }); + this._backupCodesInput.on( + BackupCodesInput.Events.INVALID_EDIT, + () => this._setWarning(I18n.translatePhrase('import-backup-codes-warning-invalid-characters')), + ); + this._backupCodesInput.on(BackupCodesInput.Events.CODE_COMPLETE, codeIndex => { + if (codeIndex !== 1) return; + this._changeStep(BackupCodesInput.Steps.ENTER_CODE_2); + }); + this._backupCodesInput.on( + BackupCodesInput.Events.CODES_COMPLETE, + backupCodes => this._onBackupCodesComplete(backupCodes), + ); + + // View transitions + this._viewTransitionHandler = new ViewTransitionHandler( + Object.values(BackupCodesInput.Steps), + async (viewTransition, oldState, newState) => { + await BackupCodesInput.customizeViewTransition(viewTransition, oldState, newState, this.$codesPage); + }, + ); + } + + run() { + window.location.hash = ImportBackupCodes.Pages.ENTER_CODES; + this._codesComplete = false; + this._setWarning(''); + this._changeStep(BackupCodesInput.Steps.ENTER_CODE_1, /* transition */ false); + } + + /** + * @private + * @param {[string, string]} backupCodes + */ + async _onBackupCodesComplete(backupCodes) { + // Let the browser render any UI updates, e.g. pasting of codes, before doing expensive computations. + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + + /** @type {{entropy: Key?, privateKey: Key?}} */ + const keys = { entropy: null, privateKey: null }; + try { + TopLevelApi.setLoading(true); + const key = await BackupCodes.recoverKey(backupCodes[0], backupCodes[1]); + + if ('expectedKeyId' in this._request && key.id !== this._request.expectedKeyId) { + throw new Error('expectedKeyId mismatch'); + } + + if (key.secret instanceof Nimiq.Entropy) { + keys.entropy = key; + } else if (key.secret instanceof Nimiq.PrivateKey) { + keys.privateKey = key; + } + this.fire(ImportBackupCodes.Events.IMPORT, keys); + // Imported successfully. Reset view afterward. Delay the change for a small moment, to hopefully perform + // the change unnoticed in the background, while ImportBackupCodes should not be visible anymore. + await new Promise(resolve => setTimeout(resolve, 200)); + this._changeStep(BackupCodesInput.Steps.ENTER_CODE_1, /* transition */ false); + } catch (error) { + console.error(error); + if (error instanceof Error && error.message.includes('expectedKeyId')) { + this._setWarning(I18n.translatePhrase('import-backup-codes-warning-different-account')); + } else { + this._setWarning(I18n.translatePhrase('import-backup-codes-warning-invalid')); + } + this._changeStep(BackupCodesInput.Steps.ENTER_CODE_1); + } finally { + TopLevelApi.setLoading(false); + } + } + + /** + * @private + * @param {BackupCodesInput.Steps} newStep + * @param {boolean} [transition=true] + */ + async _changeStep(newStep, transition = true) { + const steps = Object.values(BackupCodesInput.Steps); + const domUpdateHandler = () => { // eslint-disable-line require-jsdoc-except/require-jsdoc + this._progressIndicator.setStep(steps.indexOf(newStep) + 1); + this._backupCodesInput.step = newStep; + if (newStep === BackupCodesInput.Steps.ENTER_CODE_1) { + // Reset codes + this._backupCodesInput.code1 = ''; + this._backupCodesInput.code2 = ''; + } + this.$warning.textContent = this._warningText; // apply warning text as part of the change transition + }; + if (transition) { + const oldStep = steps.find(step => step !== newStep); + this._backupCodesInput.shakeAnimationDisabled = true; + await this._viewTransitionHandler.transitionView( + domUpdateHandler, + oldStep, + newStep, + /* awaitPreviousTransitions */ true, + ); + this._backupCodesInput.shakeAnimationDisabled = false; + } else { + domUpdateHandler(); + } + this._backupCodesInput.focus(); + } + + /** + * @private + * @param {string} warning + */ + _setWarning(warning) { + this._warningText = warning; + if (this.$warning.textContent === this._warningText) return; + // Apply the changed text via a view transition. Preferably, we do this in the same view transition as a step + // change, so we wait a little for whether such will be triggered. + setTimeout(() => { + if (this.$warning.textContent === this._warningText) return; // Change already applied, e.g. by _changeStep. + // Otherwise, we have to run our own view transition. + this._viewTransitionHandler.transitionView( + () => { this.$warning.textContent = this._warningText; }, + this._backupCodesInput.step, + this._backupCodesInput.step, + /* awaitPreviousTransitions */ true, + ); + }, 200); + } +} + +ImportBackupCodes.Pages = { + ENTER_CODES: 'backup-codes', +}; + +ImportBackupCodes.Events = { + IMPORT: 'import', + RESET: 'reset', +}; diff --git a/src/request/import/index.html b/src/request/import/index.html index 8ab9ad281..4851a60ae 100644 --- a/src/request/import/index.html +++ b/src/request/import/index.html @@ -62,8 +62,11 @@ + + + @@ -72,9 +75,12 @@ + + + @@ -84,12 +90,15 @@ + + + @@ -104,6 +113,37 @@
+ +
+ + +
+ + +
+
+
+ +
+ + +
+
+ + + + + +
+ + +
+