diff --git a/client/package.json b/client/package.json index 581d94a4..29e97377 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@nimiq/keyguard-client", - "version": "1.9.0", + "version": "1.10.0", "description": "Nimiq Keyguard client library", "main": "dist/KeyguardClient.common.js", "module": "dist/KeyguardClient.es.js", diff --git a/client/src/PublicRequest.ts b/client/src/PublicRequest.ts index 98c6820f..14407ec9 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 19411c2e..d039a577 100644 --- a/demos/Export.html +++ b/demos/Export.html @@ -13,6 +13,34 @@
+
+ + +
+
+ + + + +
@@ -21,71 +49,109 @@
- diff --git a/demos/Import.html b/demos/Import.html index a2686e10..103f768d 100644 --- a/demos/Import.html +++ b/demos/Import.html @@ -8,34 +8,97 @@ +
+ + +
+
+ + + +


- diff --git a/karma.conf.js b/karma.conf.js index 40165d95..d7191174 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -16,11 +16,12 @@ module.exports = function (/** @type {any} */ config) { files: [ {pattern: 'src/lib/Nimiq.mjs', type: 'module'}, {pattern: 'node_modules/@nimiq/core/**/*', included: false}, - 'src/lib/Observable.js', + 'src/lib/Observable.js', // Force load of Observable before global use in I18n. 'src/lib/*.js', // Force load of lib files before components and common.js 'src/request/TopLevelApi.js', // Force load of TopLevelApi before BitcoinEnabledTopLevelApi 'src/lib/bitcoin/*.js', 'node_modules/ethers/dist/ethers.umd.js', + 'src/components/BackupCodesIllustrationBase.js', // Force load of parent class before others that extend it 'src/**/*.js', 'tests/**/*.spec.js', ], diff --git a/src/components/BackupCodesIllustration.css b/src/components/BackupCodesIllustration.css new file mode 100644 index 00000000..a5e81bc5 --- /dev/null +++ b/src/components/BackupCodesIllustration.css @@ -0,0 +1,25 @@ +/* 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)); +} diff --git a/src/components/BackupCodesIllustration.js b/src/components/BackupCodesIllustration.js new file mode 100644 index 00000000..b02475be --- /dev/null +++ b/src/components/BackupCodesIllustration.js @@ -0,0 +1,82 @@ +/* global BackupCodesIllustrationBase */ + +/** + * @extends BackupCodesIllustrationBase + */ +class BackupCodesIllustration extends BackupCodesIllustrationBase { + /** + * @param {BackupCodesIllustration.Steps} step + * @param {?HTMLDivElement} [$el] + */ + constructor(step, $el) { + super(BackupCodesIllustration.Steps, step, /* tagNames */ undefined, $el); + this.$el.classList.add('backup-codes-illustration'); + } + + /** + * @override + * @protected + * @param {HTMLDivElement} messageBubble + * @param {BackupCodesIllustration.Steps} step + * @returns {Record<'masked' | 'faded' | 'zoomed' | 'complete', boolean>} + */ + _getMessageBubbleClasses(messageBubble, step) { + const { + INTRO, + SEND_CODE_1, + SEND_CODE_1_CONFIRM, + SEND_CODE_2, + SEND_CODE_2_CONFIRM, + SUCCESS, + } = BackupCodesIllustration.Steps; + const codeIndex = this._messageBubbles.indexOf(messageBubble) + 1; + + const masked = step === INTRO + || ([SEND_CODE_1, SEND_CODE_1_CONFIRM].some(s => s === step) && codeIndex === 2); + const faded = ([SEND_CODE_1, SEND_CODE_1_CONFIRM].some(s => s === step) && codeIndex === 2) + || ([SEND_CODE_2, SEND_CODE_2_CONFIRM].some(s => s === step) && codeIndex === 1); + const zoomed = [SEND_CODE_1, SEND_CODE_1_CONFIRM, SEND_CODE_2, SEND_CODE_2_CONFIRM].some(s => s === step); + const complete = ([SEND_CODE_1_CONFIRM, SEND_CODE_2].some(s => s === step) && codeIndex === 1) + || [SEND_CODE_2_CONFIRM, SUCCESS].some(s => s === step); + return { masked, faded, zoomed, complete }; // eslint-disable-line object-curly-newline + } + + /** + * @param {ViewTransition} viewTransition + * @param {BackupCodesIllustration.Steps} oldStep + * @param {BackupCodesIllustration.Steps} newStep + * @param {HTMLElement} $viewport + * @returns {Promise} + */ + static async customizeViewTransition(viewTransition, oldStep, newStep, $viewport) { + return BackupCodesIllustrationBase._customizeViewTransition( + viewTransition, + oldStep, + newStep, + $viewport, + BackupCodesIllustration.TopMessageBubbleForStep, + ); + } +} + +/** + * @enum {'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'} + */ +BackupCodesIllustration.Steps = Object.freeze({ // Note: these are also used in ExportBackupCodes.Pages + 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', +}); + +BackupCodesIllustration.TopMessageBubbleForStep = Object.freeze({ + [BackupCodesIllustration.Steps.INTRO]: 2, + [BackupCodesIllustration.Steps.SEND_CODE_1]: 1, + [BackupCodesIllustration.Steps.SEND_CODE_1_CONFIRM]: 1, + [BackupCodesIllustration.Steps.SEND_CODE_2]: 2, + [BackupCodesIllustration.Steps.SEND_CODE_2_CONFIRM]: 2, + [BackupCodesIllustration.Steps.SUCCESS]: 2, +}); diff --git a/src/components/BackupCodesIllustrationBase.css b/src/components/BackupCodesIllustrationBase.css new file mode 100644 index 00000000..48c93afb --- /dev/null +++ b/src/components/BackupCodesIllustrationBase.css @@ -0,0 +1,240 @@ +.backup-codes-illustration-base { + 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-base .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-base .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)); + } +} + +/* Code font sizes are optimized for the zoomed state to not break into three lines in most cases. For some codes with +many wide characters the non zoomed code might break into three lines though, because of different rounding of paddings +etc. To avoid this, we slightly reduce the non-zoomed font-size. This might lead to the code breaking at different spots +though, compared to the zoomed state. */ +.backup-codes-illustration-base .message-bubble:not(.zoomed) { + --code-font-size: calc(1.75rem * var(--zoom) * .995); /* no rounding on purpose */ +} + +/* common message-bubble styles */ + +.backup-codes-illustration-base .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-base .message-bubble .background { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + z-index: -1; +} +.backup-codes-illustration-base .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-base .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-base .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-base .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; + overflow-wrap: anywhere; + color: white; +} + +/* counter circle */ + +.backup-codes-illustration-base .message-bubble::after, +.backup-codes-illustration-base .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-base .message-bubble.code-1::after { + content: '1'; + color: #8D3FD4; +} +.backup-codes-illustration-base .message-bubble.code-2::after { + content: '2'; + color: #F33E67; +} +.backup-codes-illustration-base .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-base .message-bubble:is(.masked, .loading) .label { + color: white; +} +.backup-codes-illustration-base .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-base .message-bubble:is(.masked, .loading) .code:empty::after { + /* Placeholder content if no code is set yet */ + content: '----------------------------------------------------------'; +} + +.backup-codes-illustration-base .message-bubble:not(.faded).loading .code { + animation: backup-codes-illustration-base-loading-animation .8s cubic-bezier(.76, .29, .29, .76) alternate infinite; +} + +@keyframes backup-codes-illustration-base-loading-animation { + from { opacity: 1; } + to { opacity: .6; } +} + +.backup-codes-illustration-base .message-bubble.faded { + filter: none; + opacity: .1; +} +.backup-codes-illustration-base .message-bubble.faded .background { + background: white !important; +} +.backup-codes-illustration-base .message-bubble.faded::after, +.backup-codes-illustration-base .message-bubble.faded .background::after { + content: '' !important; + background-image: none !important; + filter: none; +} + +.backup-codes-illustration-base .message-bubble.zoomed { + --zoom: calc(10 / 7); +} +.backup-codes-illustration-base .message-bubble.zoomed .code { + line-height: 1.35; +} + +.backup-codes-illustration-base: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-base: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-base .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; +} + +/* View transition setup */ + +.page:not(.disable-view-transition-names):target .backup-codes-illustration-base .message-bubble.code-1, +.page.enforce-view-transition-names .backup-codes-illustration-base .message-bubble.code-1 { + view-transition-name: backup-codes-illustration-base-code-1; +} +.page:not(.disable-view-transition-names):target .backup-codes-illustration-base .message-bubble.code-2, +.page.enforce-view-transition-names .backup-codes-illustration-base .message-bubble.code-2 { + view-transition-name: backup-codes-illustration-base-code-2; +} +::view-transition-group(*) { + animation-duration: var(--backup-codes-view-transition-duration); /* set in BackupCodesIllustrationBase.js */ +} +::view-transition-old(backup-codes-illustration-base-code-1), +::view-transition-new(backup-codes-illustration-base-code-1), +::view-transition-old(backup-codes-illustration-base-code-2), +::view-transition-new(backup-codes-illustration-base-code-2) { + height: 100%; +} diff --git a/src/components/BackupCodesIllustrationBase.js b/src/components/BackupCodesIllustrationBase.js new file mode 100644 index 00000000..5ed82f61 --- /dev/null +++ b/src/components/BackupCodesIllustrationBase.js @@ -0,0 +1,323 @@ +/* global I18n */ +/* global TemplateTags */ +/* global Observable */ + +/** + * @abstract + * @template {string} Step - a supported step's type, e.g. 'step1' | 'step2' | ... + * @template {keyof HTMLElementTagNameMap} [ContainerTagName='div'] + * @template {keyof HTMLElementTagNameMap} [MessageBubbleTagName='div'] + * @template {keyof HTMLElementTagNameMap} [CodeTagName='code'] + */ +class BackupCodesIllustrationBase extends Observable { // eslint-disable-line no-unused-vars + /** + * @param {Record} Steps + * @param {Step} step + * @param {?{ + * container?: ContainerTagName, + * messageBubble?: MessageBubbleTagName, + * code?: CodeTagName, + * }} [tagNames] + * @param {?HTMLElementTagNameMap[ContainerTagName]} [$el] + */ + constructor(Steps, step, tagNames, $el) { + super(); + this._Steps = Steps; + this.$el = BackupCodesIllustrationBase._createElement($el, tagNames); + this._messageBubbles = /** @type {Array & { length: 2 }} */ ( + Array.from(this.$el.querySelectorAll('.message-bubble'))); + this._codes = /** @type {Array & { length: 2 }} */ ( + Array.from(this.$el.querySelectorAll('.code'))); + this.step = this._step = step; // eslint-disable-line no-multi-assign + } + + /** + * @template {keyof HTMLElementTagNameMap} [ContainerTagName='div'] + * @param {?HTMLElementTagNameMap[ContainerTagName]} [$el] + * @param {?{ + * container?: keyof HTMLElementTagNameMap, + * messageBubble?: keyof HTMLElementTagNameMap, + * code?: keyof HTMLElementTagNameMap + * }} [tagNames] + * @returns {HTMLElementTagNameMap[ContainerTagName]} + */ + static _createElement($el, tagNames) { + const containerTagName = tagNames && tagNames.container ? tagNames.container : 'div'; + const messageBubbleTagName = tagNames && tagNames.messageBubble ? tagNames.messageBubble : 'div'; + const codeTagName = tagNames && tagNames.code ? tagNames.code : 'code'; + + $el = $el || /** @type {HTMLElementTagNameMap[ContainerTagName]} */ (document.createElement(containerTagName)); + $el.classList.add('backup-codes-illustration-base'); + + $el.innerHTML = TemplateTags.hasVars(8)` + <${messageBubbleTagName} class="message-bubble code-1"> +
+
Nimiq Backup Code {n}/2
+ <${codeTagName} class="code"> + + <${messageBubbleTagName} class="message-bubble code-2"> +
+
Nimiq Backup Code {n}/2
+ <${codeTagName} class="code"> + + `; + + $el.querySelectorAll('.label').forEach((label, index) => I18n.translateToHtmlContent( + /** @type {HTMLElement} */ (label), + 'backup-codes-illustration-label', + { n: (index + 1).toString() }, + )); + + return $el; + } + + /** + * @type {Step} + */ + get step() { + return this._step; + } + + /** + * @param {Step} step + */ + set step(step) { + this._step = step; + for (const s of Object.values(this._Steps)) { + this.$el.classList.toggle(s, s === step); + } + for (const messageBubble of this._messageBubbles) { + const classes = this._getMessageBubbleClasses(messageBubble, step); + messageBubble.classList.toggle('masked', classes.masked); + messageBubble.classList.toggle('faded', classes.faded); + messageBubble.classList.toggle('zoomed', classes.zoomed); + messageBubble.classList.toggle('complete', classes.complete); + } + } + + /** + * @param {boolean} isLoading + */ + set loading(isLoading) { + for (const messageBubble of this._messageBubbles) { + messageBubble.classList.toggle('loading', isLoading); + } + } + + /** + * @param {string} code + */ + set code1(code) { + this._setCode(1, code); + } + + /** + * @param {string} code + */ + set code2(code) { + this._setCode(2, code); + } + + /** + * @protected + * @param {1 | 2} codeIndex + * @param {string} code + */ + _setCode(codeIndex, code) { + this._codes[codeIndex - 1].textContent = code; + } + + /** + * @abstract + * @protected + * @param {HTMLElementTagNameMap[MessageBubbleTagName]} messageBubble + * @param {Step} step + * @returns {Record<'masked' | 'faded' | 'zoomed' | 'complete', boolean>} + */ + _getMessageBubbleClasses(messageBubble, step) { // eslint-disable-line no-unused-vars + throw new Error('Abstract method _getMessageBubbleClasses'); + } + + /** + * @protected + * @template {string} Step + * @param {ViewTransition} viewTransition + * @param {Step} oldStep + * @param {Step} newStep + * @param {HTMLElement} $viewport + * @param {Record} topMessageBubbleForStep + * @returns {Promise} + */ + static async _customizeViewTransition(viewTransition, oldStep, newStep, $viewport, topMessageBubbleForStep) { + const TRANSITION_DURATION_LONG = 500; + const TRANSITION_DURATION_SHORT = 300; + + const oldTopMessageBubble = topMessageBubbleForStep[oldStep]; + const newTopMessageBubble = topMessageBubbleForStep[newStep]; + const isSwitchingMessageBubbles = oldTopMessageBubble !== 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 === oldStep) return; // no further customization needed; just go with the browser default + + await viewTransition.ready; + const viewport = $viewport.getBoundingClientRect(); + /** @type {Animation[]} */ + const animations = []; + for (let codeIndex = 1; codeIndex <= 2; codeIndex++) { + const transitionName = `backup-codes-illustration-base-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\((?:[^,]+,){4}|matrix3d\((?:[^,]+,){12}|translateX?\()([^,]+)/, + /(?<=matrix\((?:[^,]+,){5}|matrix3d\((?:[^,]+,){13}|translate\([^,]+,|translateY\()([^,]+)/, + ].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 + animations.push(document.documentElement.animate([{ + ...transitionGroupConstantStyles, + zIndex: codeIndex === oldTopMessageBubble ? 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 (oldTopMessageBubble === newTopMessageBubble) { + // No switch of which message bubble is on top. + animations.push(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})`, + }; + animations.push(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 === oldTopMessageBubble; + const opacityMid = (isZooming ? isZoomingIn : isStartingInForeground) ? opacityStart : opacityEnd; + animations.push(document.documentElement.animate( + { + opacity: [opacityStart, opacityMid, opacityMid, opacityEnd], + }, { + ...transitionOptions, + pseudoElement: `::view-transition-${image}(${transitionName})`, + }, + )); + } + } + } + + // Clear all applied animations manually after transition end (without awaiting it), because Firefox doesn't do + // it properly itself. + viewTransition.finished.finally(() => { + for (const animation of animations) { + animation.cancel(); + } + }); + } +} diff --git a/src/components/BackupCodesInput.css b/src/components/BackupCodesInput.css new file mode 100644 index 00000000..7effbf8c --- /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 00000000..71aaa78e --- /dev/null +++ b/src/components/BackupCodesInput.js @@ -0,0 +1,239 @@ +/* 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(); + } + + blur() { + const $codeToBlur = this._codes.find($code => !$code.disabled); + if (!$codeToBlur) return; + $codeToBlur.blur(); + } + + /** + * @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/components/FlippableHandler.css b/src/components/FlippableHandler.css index 8105241c..5e8afbf6 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 3cdda5fb..b8ac3c9f 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/components/PasswordSetterBox.js b/src/components/PasswordSetterBox.js index ed542ae7..9de237cb 100644 --- a/src/components/PasswordSetterBox.js +++ b/src/components/PasswordSetterBox.js @@ -1,5 +1,6 @@ /* global Observable */ /* global I18n */ +/* global HistoryState */ /* global PasswordInput */ /* global AnimationUtils */ /* global PasswordStrength */ @@ -42,12 +43,12 @@ class PasswordSetterBox extends Observable { this._onInputChangeValidity(false); - window.onpopstate = /** @param {PopStateEvent} ev */ ev => { - if (ev.state && ev.state.isPasswordBoxInitialStep === true) { - this.fire(PasswordSetterBox.Events.RESET); - this.$el.classList.remove('repeat-short', 'repeat-long'); - } - }; + window.addEventListener('popstate', () => { + const isPasswordBoxInitialStep = HistoryState.get('isPasswordBoxInitialStep'); + if (!isPasswordBoxInitialStep) return; + this.fire(PasswordSetterBox.Events.RESET); + this.$el.classList.remove('repeat-short', 'repeat-long'); + }); } /** @@ -198,8 +199,8 @@ class PasswordSetterBox extends Observable { this._passwordInput.reset(); this.$el.classList.add('repeat'); this.fire(PasswordSetterBox.Events.ENTERED); - window.history.replaceState({ isPasswordBoxInitialStep: true }, 'Keyguard'); - window.history.pushState({ isPasswordBoxRepeatStep: true }, 'Keyguard'); + HistoryState.set('isPasswordBoxInitialStep', true); + HistoryState.push('isPasswordBoxRepeatStep', true); this._passwordInput.focus(); return; } diff --git a/src/components/RecoveryWords.js b/src/components/RecoveryWords.js index 15ecbf9f..efe8943b 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() { @@ -138,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); diff --git a/src/components/RecoveryWordsInputField.js b/src/components/RecoveryWordsInputField.js index 3744e9ec..caff8b13 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/lib/BackupCodes.js b/src/lib/BackupCodes.js new file mode 100644 index 00000000..819c58b9 --- /dev/null +++ b/src/lib/BackupCodes.js @@ -0,0 +1,165 @@ +/* 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. Also note that the deterministic nature of the + // codes has the side effect that codes leak equality: a matching code can be unambiguously identified given the + // other code, and backup codes of a different key can be identified as belonging to a different key, which is + // strictly more information than would be available with random codes. For example, if a user leaks a code in + // one email account, a potential second hacked email account can be identified as belonging to the same user if + // two Backup Codes can be matched. However, this is as consequence of the deterministic codes, which are chosen + // by design, and is not considered an issue. The situation would also be the same, if using random codes, but + // including a checksum. + 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. + const derivationUseCase = `BackupCodes - ${versionAndFlags}`; // include metadata in derivation / checksum + const code1Bytes = await key.deriveSecret(derivationUseCase, 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. Do a constant time check, + // to avoid side-channel attacks, although unlikely that exploitable here. + const [checksum1, checksum2] = await BackupCodes.generate(key); + const checksum1EqualsCode1 = BackupCodes._constantTimeEqualityCheck(checksum1, code1); + const checksum2EqualsCode2 = BackupCodes._constantTimeEqualityCheck(checksum2, code2); + const checksum1EqualsCode2 = BackupCodes._constantTimeEqualityCheck(checksum1, code2); + const checksum2EqualsCode1 = BackupCodes._constantTimeEqualityCheck(checksum2, code1); + if ((!checksum1EqualsCode1 || !checksum2EqualsCode2) && (!checksum1EqualsCode2 || !checksum2EqualsCode1)) { + 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 + * @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, '+')); + } + + /** + * @private + * @param {string} a + * @param {string} b + * @returns {boolean} + */ + static _constantTimeEqualityCheck(a, b) { + const lenA = a.length; + const lenB = b.length; + const maxLen = Math.max(lenA, lenB); + let result = 0; + + if (lenA !== lenB) { + result |= 1; + } + + for (let i = 0; i < maxLen; i++) { + const charCodeA = a.charCodeAt(i) || 0; + const charCodeB = b.charCodeAt(i) || 0; + + // XOR is 0 if the char codes are identical. + result |= (charCodeA ^ charCodeB); + } + + return result === 0; + } +} + +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; + +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/lib/HistoryState.js b/src/lib/HistoryState.js new file mode 100644 index 00000000..39de82a3 --- /dev/null +++ b/src/lib/HistoryState.js @@ -0,0 +1,32 @@ +class HistoryState { // eslint-disable-line no-unused-vars + /** + * @param {string} key + * @returns {unknown | undefined} + */ + static get(key) { + const state = window.history.state; + return state && typeof state === 'object' ? state[key] : undefined; + } + + /** + * @param {string} key + * @param {unknown} value - can be anything that can be structurally cloned, see + * developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm + */ + static set(key, value) { + const oldState = window.history.state; + window.history.replaceState({ + ...(typeof oldState === 'object' ? oldState : null), + [key]: value, + }, ''); + } + + /** + * @param {string} key + * @param {unknown} value - can be anything that can be structurally cloned, see + * developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm + */ + static push(key, value) { + window.history.pushState({ [key]: value }, ''); + } +} diff --git a/src/lib/Key.js b/src/lib/Key.js index a63f507b..01b394e3 100644 --- a/src/lib/Key.js +++ b/src/lib/Key.js @@ -156,6 +156,218 @@ class Key { : this._secret; } + /** + * @overload + * Deterministically derive a secret from the key's secret via a HKDF key derivation. The use of HKDF ensures that + * derived secrets can be exposed securely without exposing the underlying key's secret. + * @param {string} useCase - Allows to generate a separate secret per use case. + * @param {number} derivedSecretLength - Size in bytes. + * @returns {Promise} + */ + /** + * @overload + * Deterministically derive a secret from the key's secret via a HKDF key derivation. The use of HKDF ensures that + * derived secrets can be exposed securely without exposing the underlying key's secret. + * To further protect the underlying secret reducing the risk of making brute-forcing the key's secret more feasible + * by serving as a cheap hint for the correct secret, the generated secret can be made computationally expensive to + * generate, by applying an additional, computationally expensive kdf. While such practice has been done in the past + * for example for the RSA Seed, it is rather to be categorized as security theater, and not really needed due to + * the high entropy of the seed. + * @param {string} useCase - Allows to generate a separate secret per use case. + * @param {number} derivedSecretLength - Size in bytes. + * @param {'PBKDF2-SHA512'} additionalKdfAlgorithm + * @param {number} additionalKdfIterations + * @returns {Promise} + */ + /** + * @param {string} useCase + * @param {number} derivedSecretLength + * @param {'PBKDF2-SHA512'} [additionalKdfAlgorithm] + * @param {number} [additionalKdfIterations] + * @returns {Promise} + */ + async deriveSecret(useCase, derivedSecretLength, additionalKdfAlgorithm, additionalKdfIterations) { + const seedBytes = this.secret.serialize(); + + if (!!additionalKdfAlgorithm && !additionalKdfIterations) { + throw new Error('additionalKdfIterations must be specified if specifying additionalKdfAlgorithm'); + } + + if (useCase === 'RSA Seed' && additionalKdfAlgorithm === 'PBKDF2-SHA512') { + // Legacy implementation for the RSA Seed kept for compatibility. Does not involve an additional HKDF. + const salt = this._defaultAddress.serialize(); + const pbkdf2Params = { + name: 'PBKDF2', + hash: 'SHA-512', + salt, + iterations: additionalKdfIterations, + }; + 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, + )); + } + + // As we want to deterministically derive secrets, we can not use a random salt. We could derive a salt from + // the seed, which might work out here as 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 (like + // typically password reuse). However, a dependence between the salt and key material is generally not desirable + // for key derivation functions, requiring extra care to not make the salt attacker controllable (for example + // the useCase must not be part of the salt), and can be harmful while not really providing any benefits. On the + // other hand, the HKDF specification (RFC 5869) was specifically designed to handle cases where a random salt + // is unavailable, and can still derive strong secrets even in the absence of a salt. + // See datatracker.ietf.org/doc/html/rfc5869#section-3.4, eprint.iacr.org/2010/264.pdf#page=10.33 and + // blog.trailofbits.com/2025/01/28/best-practices-for-key-derivation. + // However, as some salt is still required, we pass a fixed dummy salt, which we can get away with as our seed + // is already of high entropy. We go with HKDF's default of HashLen (i.e. 64 for our used SHA-512) 0 bytes, see + // datatracker.ietf.org/doc/html/rfc5869#section-2.2. + const salt = new Uint8Array(64); + + let finalKeyMaterial; + // length in bytes + const finalKeyMaterialLength = additionalKdfAlgorithm + ? 64 // if we apply an additional kdf, we might as well stretch the key material + : seedBytes.length; + switch (additionalKdfAlgorithm) { + case undefined: + // No additional kdf to apply. The key is derived directly from the seed via the final HKDF. + finalKeyMaterial = seedBytes; + break; + case 'PBKDF2-SHA512': { + const pbkdf2Params = { + name: 'PBKDF2', + hash: 'SHA-512', + salt, + iterations: additionalKdfIterations, + }; + 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'], + ); + finalKeyMaterial = new Uint8Array(await window.crypto.subtle.deriveBits( + /* algorithm */ pbkdf2Params, + /* baseKey */ pbkdf2KeyMaterial, + /* length */ finalKeyMaterialLength * 8, + )); + break; + } + /* Currently unused key derivation functions: + 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(finalKeyMaterialLength); + const iterations = /** @type {number} * / (additionalKdfIterations); + finalKeyMaterial = Nimiq.BufferUtils.xor( + await Nimiq.CryptoUtils.otpKdf(dummyData, seedBytes, salt, iterations), + dummyData, + ); + break; + } + 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, length } = event.data; + await Nimiq.default(); + const response = Nimiq.Hash.computeNimiqArgon2id( + seedBytes, + salt, + kdfIterations, + length, + ); + 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 { + finalKeyMaterial = 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: additionalKdfIterations, length: finalKeyMaterialLength }, + { transfer: [seedBytes.buffer, salt.buffer] }, + ); + }); + } finally { + URL.revokeObjectURL(workerUrl); + worker.terminate(); + } + break; + } + */ + default: + throw new Error(`Unsupported KDF algorithm: ${additionalKdfAlgorithm}`); + } + + // Derive the final key specific to the useCase via a HKDF. + const finalHkdfParams = { + name: 'HKDF', + // Use SHA-512 for cheaper derivation of longer derivedSecretLengths and better quantum resistance. + hash: 'SHA-512', + salt, + info: Utf8Tools.stringToUtf8ByteArray([ + useCase, + derivedSecretLength, + ...(additionalKdfAlgorithm ? [additionalKdfAlgorithm, additionalKdfIterations] : []), + // Derive different secrets for legacy PrivateKey based accounts and modern Entropy based accounts, even + // if their underlying secret bytes are the same. + this.secret instanceof Nimiq.PrivateKey ? 'PrivateKey' : 'Entropy', + ].join()), + }; + const finalHkdfKeyMaterial = await window.crypto.subtle.importKey( + /* format */ 'raw', + /* keyData */ finalKeyMaterial, + /* algorithm */ finalHkdfParams, // The key material is to be used in a HKDF derivation. + /* extractable */ false, + /* keyUsages */ ['deriveBits'], + ); + return new Uint8Array(await window.crypto.subtle.deriveBits( + /* algorithm */ finalHkdfParams, + /* baseKey */ finalHkdfKeyMaterial, + /* length */ derivedSecretLength * 8, + )); + } + /** * @param {Uint8Array} hkdfSalt * @param {string} useCase - Allows to generate a separate AES key per use case. @@ -281,28 +493,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', 1024, keyParams.kdf, keyParams.iterations); 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/lib/KeyStore.js b/src/lib/KeyStore.js index 1a3b1e43..34ccdd6a 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/lib/ViewTransitionHandler.js b/src/lib/ViewTransitionHandler.js new file mode 100644 index 00000000..667f146c --- /dev/null +++ b/src/lib/ViewTransitionHandler.js @@ -0,0 +1,118 @@ +/** + * @template {string | number} State - a supported state's type, e.g. 'state1' | 'state2' | ... + */ +class ViewTransitionHandler { // eslint-disable-line no-unused-vars + /** + * @param {Array} transitionableStates + * @param {( + * viewTransition: ViewTransition, + * oldState: State, + * newState: State, + * ) => Promise} [customizeViewTransition] + */ + constructor(transitionableStates, customizeViewTransition) { + this._transitionableStates = transitionableStates; + this._customizeViewTransition = customizeViewTransition; + /** @type {State | null} */ + this._currentlyTransitioningNewState = null; + /** @type {ViewTransition | null} */ + this._currentViewTransition = null; + /** @type {Array>} */ + this._pendingViewTransitionPromises = []; + } + + /** @type {State | null} */ + get currentlyTransitioningNewState() { + return this._currentlyTransitioningNewState; + } + + /** @type {ViewTransition | null} */ + get currentViewTransition() { + return this._currentViewTransition; + } + + /** + * @param {unknown} state + * @returns {state is State} + */ + isTransitionableState(state) { + return this._transitionableStates.includes(/** @type {State} */ (state)); + } + + /** + * @param {unknown} oldState + * @param {unknown} newState + * @returns {boolean} + */ + shouldTransitionView(oldState, newState) { + return !!document.startViewTransition // view transitions supported + && newState !== this._currentlyTransitioningNewState // transition not already running or scheduled + && this.isTransitionableState(oldState) && this.isTransitionableState(newState); + } + + /** + * @param {() => Promise | void} domUpdateHandler + * @param {unknown} oldState + * @param {unknown} newState + * @param {boolean} [awaitPreviousTransitions=false] + * @returns {Promise} + */ + async transitionView(domUpdateHandler, oldState, newState, awaitPreviousTransitions = false) { + /** @type {Promise<[ViewTransition | null, Promise | null]>} */ + const initializationAndDomUpdatePromise = (async () => { + if (awaitPreviousTransitions) { + await Promise.allSettled(this._pendingViewTransitionPromises); + } + if (!this.isTransitionableState(oldState) + || !this.isTransitionableState(newState) + || !this.shouldTransitionView(oldState, newState)) { + // Go to new state without a view transition. + await domUpdateHandler(); + return [null, null]; + } + this._currentlyTransitioningNewState = newState; + // Note that starting a new view transition cancels the animation of a previous one, if still running. + const viewTransition = document.startViewTransition(domUpdateHandler); + this._currentViewTransition = viewTransition; + const viewTransitionFinishAndCleanupPromise = Promise.all([ + this._customizeViewTransition + ? this._customizeViewTransition(viewTransition, oldState, newState) + : null, + viewTransition.finished, + ]).catch(e => { + // Catch exceptions to avoid unhandled promise rejections for non-essential exceptions. + console.error(e); + }).then(() => { + if (this._currentlyTransitioningNewState !== newState) return; + // Reached target state, and it hasn't changed in the meantime. + this._currentlyTransitioningNewState = null; + this._currentViewTransition = null; + }); + // Await the actual DOM update of domUpdateHandler (i.e. the important part), and throw if it fails. + await viewTransition.updateCallbackDone; + return [viewTransition, viewTransitionFinishAndCleanupPromise]; + })(); + + // Note that the promise is pushed/queued immediately, without awaiting any asynchronous code before, such that + // it is visible immediately to any following transitionView calls. + // initializationAndDomUpdatePromise includes waiting for our turn (if awaitPreviousTransitions is requested), + // creating the view transition (if it should be transitioned), and the DOM update. + // viewTransitionFinishAndCleanupPromise includes waiting for the view transition to finish or be skipped (if + // it should be transitioned), customization of the view transition, and cleanup in the end. + const pendingViewTransitionPromise = initializationAndDomUpdatePromise + .then(([, viewTransitionFinishAndCleanupPromise]) => viewTransitionFinishAndCleanupPromise) + .catch(() => {}) + .then(() => { + // While _pendingViewTransitions pretty much works like a queue, we can not simply assume that we can + // remove the first/oldest entry, because calls with awaitPreviousTransitions === false can execute out + // of order. + const index = this._pendingViewTransitionPromises.indexOf(pendingViewTransitionPromise); + if (index === -1) return; + this._pendingViewTransitionPromises.splice(index, 1); + }); + this._pendingViewTransitionPromises.push(pendingViewTransitionPromise); + + const [viewTransition] = await initializationAndDomUpdatePromise; + return viewTransition; + } +} diff --git a/src/nimiq-style.css b/src/nimiq-style.css index 94e1db37..e452b090 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/request/change-password/ChangePassword.css b/src/request/change-password/ChangePassword.css index 9334b13b..975f4f3d 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 94b3c194..237043af 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 3891493c..ad180606 100644 --- a/src/request/change-password/index.html +++ b/src/request/change-password/index.html @@ -47,6 +47,7 @@ + @@ -137,6 +138,7 @@

Create a new

Download new Login File

+

Download successful?

diff --git a/src/request/create/Create.js b/src/request/create/Create.js index a2b6a945..6af46bad 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/create/index.html b/src/request/create/index.html index 25658b50..2f0900a0 100644 --- a/src/request/create/index.html +++ b/src/request/create/index.html @@ -55,6 +55,7 @@ + @@ -170,8 +171,8 @@

-

Download your Login File

-

Download successful?

+

Download your Login File

+

Download successful?

diff --git a/src/request/export/Export.css b/src/request/export/Export.css index b22bfab6..2202b9c6 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 f657663d..86138ca1 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 aef42dcb..fe274135 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 00000000..aabbb105 --- /dev/null +++ b/src/request/export/ExportBackupCodes.js @@ -0,0 +1,295 @@ +/* global FlippableHandler */ +/* global I18n */ +/* global PasswordBox */ +/* global ProgressIndicator */ +/* global BackupCodesIllustration */ +/* global BackupCodes */ +/* global ViewTransitionHandler */ +/* 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(); + + // 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].code2 = 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')); + } + } + + // View transitions + this._viewTransitionHandler = new ViewTransitionHandler( + // Disable view transitions for the unlock page, as it has its own, separate transition effect. + /** @type {Array>} */ ( + this._pageIds.filter(page => page !== ExportBackupCodes.Pages.UNLOCK)), + async (viewTransition, oldState, newState) => { + const $viewport = this._pagesById[newState]; + await BackupCodesIllustration.customizeViewTransition(viewTransition, oldState, newState, $viewport); + }, + ); + + // Augment browser navigations with view transition animations. + let currentPageId = window.location.hash.replace(/^#/, ''); + window.addEventListener('popstate', event => { + const hasUAVisualTransition = 'hasUAVisualTransition' in event && !!event.hasUAVisualTransition; + const oldPageId = currentPageId; + // At the time of a popstate event, location.hash is already updated, even if the document / DOM might not + // yet, and the hashchange event has not fired yet. + const newPageId = window.location.hash.replace(/^#/, ''); + currentPageId = newPageId; + + if (!this._viewTransitionHandler.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 [pageId, $page] of Object.entries(this._pagesById)) { + if (pageId === oldPageId) { + $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._viewTransitionHandler.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.loading = 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. + const currentPageId = window.location.hash.replace(/^#/, ''); + this._viewTransitionHandler.transitionView(() => { + setGeneratingCodes(false); + for (const [step, illustration] of Object.entries(this._illustrationsByStep)) { + illustration.code1 = 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.code2 = code2; + } + }, currentPageId, currentPageId, /* awaitPreviousTransitions */ true); + }); + + // 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._viewTransitionHandler.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); + } +} + +/** @enum {'backup-codes-unlock' | BackupCodesIllustration.Steps} */ +ExportBackupCodes.Pages = Object.freeze({ + UNLOCK: 'backup-codes-unlock', + ...BackupCodesIllustration.Steps, +}); diff --git a/src/request/export/ExportFile.js b/src/request/export/ExportFile.js index 48e5eb17..bc1c42fb 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 15c2bc8e..ff180216 100644 --- a/src/request/export/index.html +++ b/src/request/export/index.html @@ -43,12 +43,16 @@ + + + + @@ -57,8 +61,11 @@ + + + @@ -76,6 +83,8 @@ + + @@ -137,7 +146,7 @@

There is no Password Re -

Unlock your Backup

+

Unlock your Backup

@@ -311,14 +320,181 @@

Take 5 Minutes for a B -

Download your Login File

-

Download successful?

+

Download your Login File

+

Download successful?

+ +
+ + +
+ + + +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ + + +
@@ -120,6 +162,34 @@

Enter Recovery

+ +
+ + +
+
+ + + + + +
+ + +
+
@@ -165,27 +236,27 @@

Download your Logi -

Import your Login File

+

Import your Login File

- +
diff --git a/src/request/remove-key/index.html b/src/request/remove-key/index.html index e36f635b..4f4225d7 100644 --- a/src/request/remove-key/index.html +++ b/src/request/remove-key/index.html @@ -49,6 +49,7 @@ + @@ -188,7 +189,7 @@

There is no Password Re -

Unlock your Backup

+

Unlock your Backup

@@ -324,8 +325,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 7e20340a..3d65ba0c 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -43,26 +43,40 @@ "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-selector-heading": "Art des Backups", + "import-selector-title-backup-codes": "Backup-Codes", + "import-selector-description-backup-codes": "Erfordert die beiden Codes, die du an dich selbst geschickt hast.", + "import-selector-title-recovery-words": "24 Wiederherstellungswörter", + "import-selector-description-recovery-words": "Erfordert deine aufgeschriebenen Wiederherstellungswörter. ", - "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", - "file-import-prompt": "Zum Hochladen hierher ziehen oder klicken", + "import-backup-codes-heading": "Codes eingeben", + "import-backup-codes-instructions": "Du solltest die Codes an dich selbst über zwei verschiedene Plattformen geschickt haben.\nDurchsuche deine Nachrichten nach \"Nimiq Backup-Code\" oder \"Nimiq Backup Code\".", + "import-backup-codes-warning-invalid": "Dein Backup ist fehlerhaft. Versuche es erneut", + "import-backup-codes-warning-invalid-characters": "Gültige Codes bestehen nur aus A-Z, a-z, 0-9, den Zeichen ; und ! und sind 44 Zeichen lang.", + "import-backup-codes-warning-different-account": "Diese Backup-Codes gehören zu einem anderen Konto", + + "import-set-password-file-available": "Bei Wiederherstellung aus einem Backup wird eine neue Login-Datei erzeugt. Schütze sie mit einem Passwort.", + "import-set-password-file-unavailable": "Schütze dein importieres Konto mit einem Passwort.", + + "import-file-heading": "Mit Login-Datei einloggen", + "import-file-login-to-continue": "Bitte logge dich neu ein, um fortzufahren.", + "import-file-login-with-backup": "Mit einem Backup 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", + + "file-import-prompt": "Login-Datei hierher ziehen oder hier 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", + "backup-codes-input-placeholder": "Code eingeben…", + "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 +158,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", @@ -188,9 +203,27 @@ "export-file-success-heading": "Nimm dir 5 Minuten Zeit für ein Backup", "export-file-success-words-intro": "Es gibt keine Möglichkeit der Passwortwiederherstellung. Schreib dir 24 Wörter auf, um ein sicheres Backup zu erstellen.", "export-words-intro-heading": "Es gibt keine Passwortwiederherstellung!", - "export-words-unlock-heading": "Wiederherstellungswörter entsperren", "export-words-hint": "Scroll runter, um fortzufahren", + "export-backup-codes-intro-heading": "Schicke zwei Backup-Codes an dich selbst", + "export-backup-codes-intro-text": "Kombiniert erlauben die Codes Zugriff auf dein Konto. Sende sie an dich selbst über zwei separate Plattformen.", + "export-backup-codes-intro-warning": "Wer Zugriff auf beide Codes erlangt, erlangt auch Zugriff auf dein Konto!\nTeile sie mit niemandem und verwahre sie sicher.", + "export-backup-codes-lets-go": "Auf geht's", + "export-backup-codes-send-code-heading": "Sende Code {n} an dich selbst", + "export-backup-codes-send-code-instructions-code-1": "Sende diesen Code an dich selbst, beispielsweise via E-Mail oder Messenger. Stelle sicher, dass du ihn wieder findest, wenn benötigt.", + "export-backup-codes-send-code-instructions-code-2": "Sende diesen Code an eine andere E-Mail-Adresse oder einen anderen Messenger von dir. Stelle sicher, dass die Codes getrennt voneinander verwahrt werden.", + "export-backup-codes-send-code-loading": "Generiere Codes…", + "export-backup-codes-send-code-copy": "Code {n} kopieren", + "export-backup-codes-send-code-instructions-link": "Wie man die Codes an sich selbst schickt", + "export-backup-codes-send-code-confirm-heading-code-1": "Hast du Code 1 an dich selbst geschickt?", + "export-backup-codes-send-code-confirm-heading-code-2": "Hast du Code 2 über eine andere Methode als Code 1 an dich selbst geschickt?", + "export-backup-codes-send-code-confirm-continue-code-1": "Ja, weiter zu Code 2", + "export-backup-codes-send-code-confirm-continue-code-2": "Ja, abgehakt", + "export-backup-codes-send-code-confirm-go-back": "Nein, gehe zurück", + "export-backup-codes-success-heading": "Alles erledigt?", + "export-backup-codes-success-text": "Du solltest nun zwei Codes haben, die du als Nachrichen über zwei separate Plattformen an dich selbst geschickt hast.", + "export-backup-codes-success-recovery": "Falls du je dein Passwort oder deine Login‑Datei verlierst, kannst du durch Eingabe der Codes wieder Zugang erhalten.", "go-to-recovery-words": "Erstelle ein Backup", + "export-unlock-heading": "Wiederherstellungswörter entsperren", "export-heading-validate-backup": "Überprüfe dein Backup", "export-continue-to-login-file": "Weiter zur Login‑Datei", "export-show-recovery-words": "Wiederherstellungswörter anzeigen", diff --git a/src/translations/en.json b/src/translations/en.json index c85a83c2..db497297 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -43,26 +43,40 @@ "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-selector-heading": "Choose your backup", + "import-selector-title-backup-codes": "Backup Codes", + "import-selector-description-backup-codes": "Requires the two codes you have sent to yourself.", + "import-selector-title-recovery-words": "24 Recovery Words", + "import-selector-description-recovery-words": "Requires your written down 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-backup-codes-heading": "Enter the two codes", + "import-backup-codes-instructions": "You should have sent the codes to yourself using two different platforms.\nSearch for the codes using \"Nimiq Backup Code\".", + "import-backup-codes-warning-invalid": "Your backup is invalid. Try again", + "import-backup-codes-warning-invalid-characters": "Valid codes consist of only A-Z, a-z, 0-9, symbols ; and !, and are 44 characters long.", + "import-backup-codes-warning-different-account": "These Backup Codes belong to a different account", + + "import-set-password-file-available": "Using a recovery creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using a recovery 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-login-with-backup": "Login with backup", + "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", + "backup-codes-input-placeholder": "Enter code...", + "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 +158,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", @@ -188,9 +203,27 @@ "export-file-success-heading": "Take 5 Minutes for a Backup", "export-file-success-words-intro": "There is no 'forgot password' option. Write down 24 words to create a secure backup.", "export-words-intro-heading": "There is no Password Recovery!", - "export-words-unlock-heading": "Unlock your Backup", "export-words-hint": "Scroll to continue", + "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.", "go-to-recovery-words": "Create backup", + "export-unlock-heading": "Unlock your Backup", "export-heading-validate-backup": "Validate your Backup", "export-continue-to-login-file": "Continue to Login File", "export-show-recovery-words": "Show Recovery Words", diff --git a/src/translations/es.json b/src/translations/es.json index 139de05f..d2b083f4 100644 --- a/src/translations/es.json +++ b/src/translations/es.json @@ -43,26 +43,40 @@ "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-selector-heading": "Choose your backup", + "import-selector-title-backup-codes": "Backup Codes", + "import-selector-description-backup-codes": "Requires the two codes you have sent to yourself.", + "import-selector-title-recovery-words": "24 Recovery Words", + "import-selector-description-recovery-words": "Requires your written down recovery words.", - "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-backup-codes-heading": "Enter the two codes", + "import-backup-codes-instructions": "You should have sent the codes to yourself using two different platforms.\nSearch for the codes using \"Nimiq Backup Code\".", + "import-backup-codes-warning-invalid": "Your backup is invalid. Try again", + "import-backup-codes-warning-invalid-characters": "Valid codes consist of only A-Z, a-z, 0-9, symbols ; and !, and are 44 characters long.", + "import-backup-codes-warning-different-account": "These Backup Codes belong to a different account", + + "import-set-password-file-available": "Using a recovery creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using a recovery 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-login-with-backup": "Login with backup", + "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", + "backup-codes-input-placeholder": "Enter code...", + "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 +158,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", @@ -188,9 +203,27 @@ "export-file-success-heading": "Tome 5 minutos para crear el respaldo", "export-file-success-words-intro": "No hay opción de \"olvide mi contraseña\". Escriba las 24 palabras para crear un respaldo seguro.", "export-words-intro-heading": "¡No hay Recuperación de Contraseña!", - "export-words-unlock-heading": "Desbloquear su Respaldo", "export-words-hint": "Desplácese hacia abajo para continuar", + "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.", "go-to-recovery-words": "Crear respaldo", + "export-unlock-heading": "Desbloquear su Respaldo", "export-heading-validate-backup": "Valide su Respaldo", "export-continue-to-login-file": "Continuar", "export-show-recovery-words": "Muestre Palabras de Recuperación", diff --git a/src/translations/fr.json b/src/translations/fr.json index db7f490e..0550d6d5 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -43,26 +43,40 @@ "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-selector-heading": "Choisissez votre sauvegarde", + "import-selector-title-backup-codes": "Codes de secours", + "import-selector-description-backup-codes": "Nécessite les deux codes que vous vous êtes envoyés.", + "import-selector-title-recovery-words": "24 mots de récupération", + "import-selector-description-recovery-words": "Nécessite vos mots de récupération écrits.", - "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-backup-codes-heading": "Entrez les deux codes", + "import-backup-codes-instructions": "Vous devriez vous être envoyé les codes à vous-même en utilisant deux plateformes différentes. \nRecherchez les codes à l'aide de « Nimiq Backup Code ».", + "import-backup-codes-warning-invalid": "Votre sauvegarde n'est pas valide. Veuillez réessayer.", + "import-backup-codes-warning-invalid-characters": "Les codes valides sont composés uniquement des lettres A à Z, a à z, des chiffres de 0 à 9, des symboles ; et !, et comportent 44 caractères.", + "import-backup-codes-warning-different-account": "Ces codes de récupération appartiennent à un autre compte.", + + "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-login-with-backup": "Se connecter avec une sauvegarde", + "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", + "backup-codes-input-placeholder": "Entrez le code...", + "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 +158,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", @@ -188,9 +203,27 @@ "export-file-success-heading": "Prenez 5 Minutes pour une Sauvegarde", "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-unlock-heading": "Déverrouillez votre Sauvegarde", "export-words-hint": "Faites défiler pour continuer", + "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", "export-continue-to-login-file": "Continuer", "export-show-recovery-words": "Afficher les Mots", diff --git a/src/translations/nl.json b/src/translations/nl.json index dac0cd4e..f877f703 100644 --- a/src/translations/nl.json +++ b/src/translations/nl.json @@ -43,26 +43,40 @@ "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-selector-heading": "Choose your backup", + "import-selector-title-backup-codes": "Backup Codes", + "import-selector-description-backup-codes": "Requires the two codes you have sent to yourself.", + "import-selector-title-recovery-words": "24 Recovery Words", + "import-selector-description-recovery-words": "Requires your written down recovery words.", - "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-backup-codes-heading": "Enter the two codes", + "import-backup-codes-instructions": "You should have sent the codes to yourself using two different platforms.\nSearch for the codes using \"Nimiq Backup Code\".", + "import-backup-codes-warning-invalid": "Your backup is invalid. Try again", + "import-backup-codes-warning-invalid-characters": "Valid codes consist of only A-Z, a-z, 0-9, symbols ; and !, and are 44 characters long.", + "import-backup-codes-warning-different-account": "These Backup Codes belong to a different account", + + "import-set-password-file-available": "Using a recovery creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using a recovery 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-login-with-backup": "Login with backup", + "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", + "backup-codes-input-placeholder": "Enter code...", + "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 +158,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", @@ -188,9 +203,27 @@ "export-file-success-heading": "Neem 5 minuten voor een back-up", "export-file-success-words-intro": "De 'wachtwoord vergeten' optie bestaat niet. Schrijf de 24 woorden op om een veilige back-up te maken.", "export-words-intro-heading": "Er is geen wachtwoordherstel!", - "export-words-unlock-heading": "Back-up ontgrendelen", "export-words-hint": "Scroll om door te gaan", + "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.", "go-to-recovery-words": "Back-up maken", + "export-unlock-heading": "Back-up ontgrendelen", "export-heading-validate-backup": "Valideer je back-up", "export-continue-to-login-file": "Ga naar Login File", "export-show-recovery-words": "Laat herstelwoorden zien", diff --git a/src/translations/pt.json b/src/translations/pt.json index be36025c..08477174 100644 --- a/src/translations/pt.json +++ b/src/translations/pt.json @@ -43,26 +43,40 @@ "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-selector-heading": "Choose your backup", + "import-selector-title-backup-codes": "Backup Codes", + "import-selector-description-backup-codes": "Requires the two codes you have sent to yourself.", + "import-selector-title-recovery-words": "24 Recovery Words", + "import-selector-description-recovery-words": "Requires your written down recovery words.", - "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-backup-codes-heading": "Enter the two codes", + "import-backup-codes-instructions": "You should have sent the codes to yourself using two different platforms.\nSearch for the codes using \"Nimiq Backup Code\".", + "import-backup-codes-warning-invalid": "Your backup is invalid. Try again", + "import-backup-codes-warning-invalid-characters": "Valid codes consist of only A-Z, a-z, 0-9, symbols ; and !, and are 44 characters long.", + "import-backup-codes-warning-different-account": "These Backup Codes belong to a different account", + + "import-set-password-file-available": "Using a recovery creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using a recovery 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-login-with-backup": "Login with backup", + "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", + "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.", "qr-video-scanner-enable-camera": "Desbloquear a câmara para este site para digitalizar os códigos QR.", @@ -144,8 +158,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", @@ -188,9 +203,27 @@ "export-file-success-heading": "Um Backup leva 5 minutos", "export-file-success-words-intro": "Não existe a opção \"esqueci-me da palavra passe\". Escreve as 24 palavras para criar um backup seguro.", "export-words-intro-heading": "Não há Recuperação de Palavra Passe!", - "export-words-unlock-heading": "Desbloqueia o teu Backup", "export-words-hint": "Deslizar para continuar", + "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.", "go-to-recovery-words": "Criar backup", + "export-unlock-heading": "Desbloqueia o teu Backup", "export-heading-validate-backup": "Valida o teu Backup", "export-continue-to-login-file": "Continuar", "export-show-recovery-words": "Mostrar Palavra de Recuperação", diff --git a/src/translations/ru.json b/src/translations/ru.json index b862a453..cf55e7a5 100644 --- a/src/translations/ru.json +++ b/src/translations/ru.json @@ -43,26 +43,40 @@ "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-selector-heading": "Choose your backup", + "import-selector-title-backup-codes": "Backup Codes", + "import-selector-description-backup-codes": "Requires the two codes you have sent to yourself.", + "import-selector-title-recovery-words": "24 Recovery Words", + "import-selector-description-recovery-words": "Requires your written down recovery 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-backup-codes-heading": "Enter the two codes", + "import-backup-codes-instructions": "You should have sent the codes to yourself using two different platforms.\nSearch for the codes using \"Nimiq Backup Code\".", + "import-backup-codes-warning-invalid": "Your backup is invalid. Try again", + "import-backup-codes-warning-invalid-characters": "Valid codes consist of only A-Z, a-z, 0-9, symbols ; and !, and are 44 characters long.", + "import-backup-codes-warning-different-account": "These Backup Codes belong to a different account", + + "import-set-password-file-available": "Using a recovery creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using a recovery creates a new account. Create a password to secure it.", + + "import-file-heading": "Импортировать ваш Файл Авторизации", + "import-file-login-to-continue": "Войдите снова чтобы продолжить.", + "import-file-login-with-backup": "Login with backup", + "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", + "backup-codes-input-placeholder": "Enter code...", + "qr-video-scanner-cancel": "Отмена", "qr-video-scanner-no-camera": "Камера вашего устройства недоступна.", "qr-video-scanner-enable-camera": "Разблокируйте камеру для этого сайта чтобы сканировать QR-коды.", @@ -144,8 +158,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": "Продолжить", @@ -188,9 +203,27 @@ "export-file-success-heading": "Потратьте 5 минут и создайте Резервную Копию", "export-file-success-words-intro": "Пароль забывать нельзя! Запишите 24 фразы восстановления доступа.", "export-words-intro-heading": "Утерянный пароль не восстановить!", - "export-words-unlock-heading": "Разблокировать резервную копию", "export-words-hint": "Прокрутите, чтобы продолжить", + "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.", "go-to-recovery-words": "Создать резервную копию", + "export-unlock-heading": "Разблокировать резервную копию", "export-heading-validate-backup": "Проверить вашу резервную копию", "export-continue-to-login-file": "Далее к Файлу Авторизации", "export-show-recovery-words": "Показать Защитную Фразу", diff --git a/src/translations/uk.json b/src/translations/uk.json index fcbbd1a9..54910630 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,44 @@ "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-selector-heading": "Choose your backup", + "import-selector-title-backup-codes": "Backup Codes", + "import-selector-description-backup-codes": "Requires the two codes you have sent to yourself.", + "import-selector-title-recovery-words": "24 Recovery Words", + "import-selector-description-recovery-words": "Requires your written down recovery 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-backup-codes-heading": "Enter the two codes", + "import-backup-codes-instructions": "You should have sent the codes to yourself using two different platforms.\nSearch for the codes using \"Nimiq Backup Code\".", + "import-backup-codes-warning-invalid": "Your backup is invalid. Try again", + "import-backup-codes-warning-invalid-characters": "Valid codes consist of only A-Z, a-z, 0-9, symbols ; and !, and are 44 characters long.", + "import-backup-codes-warning-different-account": "These Backup Codes belong to a different account", + + "import-set-password-file-available": "Using a recovery creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using a recovery creates a new account. Create a password to secure it.", + + "import-file-heading": "Завантажте файл-ключ", + "import-file-login-to-continue": "Будь ласка, увійдіть ще раз, щоб продовжити.", + "import-file-login-with-backup": "Login with backup", + "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", + "backup-codes-input-placeholder": "Enter code...", + "qr-video-scanner-cancel": "Скасувати", "qr-video-scanner-no-camera": "Ваш пристрій не має доступної камери.", "qr-video-scanner-enable-camera": "Розблокуйте камеру для цього веб-сайту, щоб сканувати QR-коди.", @@ -74,12 +88,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 +101,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 +158,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": "Продовжити", @@ -188,9 +203,27 @@ "export-file-success-heading": "Використайте 5 хв. щоб створити спосіб відновлення", "export-file-success-words-intro": "Функція відновлення паролю не існує. Запишіть 24 ключові слова, щоб не втратити доступ до рахунку.", "export-words-intro-heading": "Функція відновлення паролю НЕ ІСНУЄ!", - "export-words-unlock-heading": "Розблокуйте рахунок", "export-words-hint": "Пролистайте щоб продовжити", + "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.", "go-to-recovery-words": "Створити спосіб відновлення", + "export-unlock-heading": "Розблокуйте рахунок", "export-heading-validate-backup": "Перевірте ключові слова", "export-continue-to-login-file": "Продовжити з файлом", "export-show-recovery-words": "Показати секретні слова", @@ -240,7 +273,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": "комісія", @@ -259,12 +292,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 d838a766..a1083d6d 100644 --- a/src/translations/zh.json +++ b/src/translations/zh.json @@ -43,26 +43,40 @@ "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-selector-heading": "Choose your backup", + "import-selector-title-backup-codes": "Backup Codes", + "import-selector-description-backup-codes": "Requires the two codes you have sent to yourself.", + "import-selector-title-recovery-words": "24 Recovery Words", + "import-selector-description-recovery-words": "Requires your written down recovery 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-backup-codes-heading": "Enter the two codes", + "import-backup-codes-instructions": "You should have sent the codes to yourself using two different platforms.\nSearch for the codes using \"Nimiq Backup Code\".", + "import-backup-codes-warning-invalid": "Your backup is invalid. Try again", + "import-backup-codes-warning-invalid-characters": "Valid codes consist of only A-Z, a-z, 0-9, symbols ; and !, and are 44 characters long.", + "import-backup-codes-warning-different-account": "These Backup Codes belong to a different account", + + "import-set-password-file-available": "Using a recovery creates a new Login File. Create a password to secure it.", + "import-set-password-file-unavailable": "Using a recovery creates a new account. Create a password to secure it.", + + "import-file-heading": "导入你的登录文件", + "import-file-login-to-continue": "请再次登录以继续", + "import-file-login-with-backup": "Login with backup", + "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", + "backup-codes-input-placeholder": "Enter code...", + "qr-video-scanner-cancel": "取消", "qr-video-scanner-no-camera": "你的设备没有可访问的相机", "qr-video-scanner-enable-camera": "打开此网站相机的使用权限以扫描QR码", @@ -144,8 +158,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": "继续", @@ -188,9 +203,27 @@ "export-file-success-heading": "请花5分钟进行备份", "export-file-success-words-intro": "这里没有“忘记密码”的选项,请写下24个单词来创建安全备份", "export-words-intro-heading": "没有恢复密码这个选项!", - "export-words-unlock-heading": "解锁你的备份", "export-words-hint": "滚动以继续", + "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.", "go-to-recovery-words": "建立备份", + "export-unlock-heading": "解锁你的备份", "export-heading-validate-backup": "验证你的备份", "export-continue-to-login-file": "继续登录文件", "export-show-recovery-words": "显示助记词", diff --git a/tests/lib/IframeApi.spec.js b/tests/lib/IframeApi.spec.js index ecb0267e..04c64899 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 f385dd53..d02327fb 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'](); diff --git a/tools/build.sh b/tools/build.sh index 57a4e3a2..dfabd559 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -59,6 +59,14 @@ fi output "🎩 Using config file src/config/config.$BUILD.js" +# replace Nimiq PoS import path in file $1 +replace_nimiq_import_path() { + OLD_PATH="\.\.\/\.\.\/node_modules\/@nimiq\/core" + NEW_PATH="\.\.\/assets\/nimiq-pos" + + inplace_sed "s/$OLD_PATH/$NEW_PATH/g" $1 +} + # replace icon sprite URL in file $1 replace_icon_sprite_url() { OLD_PATH="\.\.\/\.\.\/\.\.\/node_modules\/@nimiq\/style\/nimiq-style.icons.svg" @@ -243,6 +251,7 @@ for url in $LIST_JS_COMMON; do cat src/request/create/$url >> dist/request/$JS_COMMON_BUNDLE done +replace_nimiq_import_path dist/request/$JS_COMMON_BUNDLE replace_icon_sprite_url dist/request/$JS_COMMON_BUNDLE for url in $LIST_JS_TOPLEVEL; do @@ -283,7 +292,7 @@ NIMIQ_POW_LIB_HASH=$(make_file_hash node_modules/@nimiq/core-web/web-offline.js) # copy Nimiq PoS loader, replace import path and calculate the integrity hash cp -v src/lib/Nimiq.mjs dist/lib/ -inplace_sed 's/\.\.\/\.\.\/node_modules\/@nimiq\/core/\.\.\/assets\/nimiq-pos/' dist/lib/Nimiq.mjs +replace_nimiq_import_path dist/lib/Nimiq.mjs NIMIQ_POS_LOADER_HASH=$(make_file_hash dist/lib/Nimiq.mjs) # process index.html scripts and links for each request diff --git a/types/ViewTransition.d.ts b/types/ViewTransition.d.ts new file mode 100644 index 00000000..cd8d6222 --- /dev/null +++ b/types/ViewTransition.d.ts @@ -0,0 +1,15 @@ +// Taken from http://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/dom-view-transitions/index.d.ts +interface Document { + startViewTransition(updateCallback: () => Promise | void): ViewTransition; +} + +interface ViewTransition { + readonly ready: Promise; + readonly finished: Promise; + readonly updateCallbackDone: Promise; + skipTransition(): void; +} + +interface CSSStyleDeclaration { + viewTransitionName: string; +}