diff --git a/js/background.js b/js/background.js index d5bb0898f..c03e9de53 100644 --- a/js/background.js +++ b/js/background.js @@ -901,6 +901,12 @@ } }); + Whisper.events.on('openInbox', () => { + appView.openInbox({ + initialLoadComplete, + }); + }); + Whisper.events.on('onEditProfile', async () => { const ourNumber = window.storage.get('primaryDevicePubKey'); const conversation = await ConversationController.getOrCreateAndWait( diff --git a/js/views/session_registration_view.js b/js/views/session_registration_view.js index d1e5bed5b..23ad5b8c5 100644 --- a/js/views/session_registration_view.js +++ b/js/views/session_registration_view.js @@ -1,20 +1,5 @@ /* global Whisper, - getAccountManager, - textsecure, - setTimeout, -*/ - -/* - Whisper, - $, - getAccountManager, - textsecure, - i18n, - passwordUtil, - _, - setTimeout, - displayNameRegex */ /* eslint-disable more/no-then */ @@ -25,72 +10,22 @@ window.Whisper = window.Whisper || {}; - /* const REGISTER_INDEX = 0; - /const PROFILE_INDEX = 1; - const currentPageIndex = REGISTER_INDEX; */ - Whisper.SessionRegistrationView = Whisper.View.extend({ className: 'session-fullscreen', initialize() { - this.accountManager = getAccountManager(); - // Clean status in case the app closed unexpectedly - textsecure.storage.remove('secondaryDeviceStatus'); - - const number = textsecure.storage.user.getNumber(); - if (number) { - this.$('input.number').val(number); - } - this.phoneView = new Whisper.PhoneInputView({ - el: this.$('#phone-number-input'), - }); - this.$('#error').hide(); - - this.$('.standalone-mnemonic').hide(); - this.$('.standalone-secondary-device').hide(); this.render(); - // this.onGenerateMnemonic(); - - /* const options = window.mnemonic.get_languages().map(language => { - const text = language - // Split by whitespace or underscore - .split(/[\s_]+/) - // Capitalise each word - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - return ``; - }); - this.$('#mnemonic-language').append(options); - this.$('#mnemonic-language').val('english'); - this.$('#mnemonic-display-language').append(options); - this.$('#mnemonic-display-language').val('english'); + /* this.$passwordInput = this.$('#password'); this.$passwordConfirmationInput = this.$('#password-confirmation'); this.$passwordInputError = this.$('.password-inputs .error'); - this.registrationParams = {}; - this.$pages = this.$('.page'); this.pairingInterval = null; - this.showRegisterPage(); - - this.onValidatePassword(); this.onSecondaryDeviceRegistered = this.onSecondaryDeviceRegistered.bind( this - ); - - this.$('#display-name').get(0).oninput = () => { - this.sanitiseNameInput(); - }; - - this.$('#display-name').get(0).onpaste = () => { - // Sanitise data immediately after paste because it's easier - setTimeout(() => { - this.sanitiseNameInput(); - }); - }; - this.sanitiseNameInput(); */ + ); */ }, render() { this.session_registration_view = new Whisper.ReactWrapperView({ @@ -123,52 +58,8 @@ 'keyup #password-confirmation': 'onValidatePassword', }, - sanitiseNameInput() { - const oldVal = this.$('#display-name').val(); - const newVal = oldVal.replace(displayNameRegex, ''); - this.$('#display-name').val(newVal); - if (_.isEmpty(newVal)) { - this.$('#save-button').attr('disabled', 'disabled'); - return false; - } - this.$('#save-button').removeAttr('disabled'); - - return true; - }, - async showPage(pageIndex) { - // eslint-disable-next-line func-names - this.$pages.each(function(index) { - if (index !== pageIndex) { - $(this).hide(); - } else { - $(this).show(); - currentPageIndex = pageIndex; - } - }); - }, - async showRegisterPage() { - this.registrationParams = {}; - this.showPage(REGISTER_INDEX); - }, - async showProfilePage(mnemonic, language) { - /* this.registrationParams = { - mnemonic, - language, - }; - this.$passwordInput.val(''); - this.$passwordConfirmationInput.val(''); - this.onValidatePassword(); - this.showPage(PROFILE_INDEX); - this.$('#display-name').focus(); - }, + onKeyup(event) { - if ( - currentPageIndex !== PROFILE_INDEX && - currentPageIndex !== REGISTER_INDEX - ) { - // Only want enter/escape keys to work on profile page - return; - } const validName = this.sanitiseNameInput(); switch (event.key) { @@ -188,61 +79,69 @@ default: } }, - async register(mnemonic, language) { - // Make sure the password is valid - if (this.validatePassword()) { - this.showToast(i18n('invalidPassword')); - return; - } + + registerWithoutMnemonic() { + const mnemonic = this.$('#mnemonic-display').text(); + const language = this.$('#mnemonic-display-language').val(); + this.showProfilePage(mnemonic, language); + }, - const input = this.trim(this.$passwordInput.val()); - // Ensure we clear the secondary device registration status - textsecure.storage.remove('secondaryDeviceStatus'); + registerWithMnemonic() { + const mnemonic = this.$('#mnemonic').val(); + const language = this.$('#mnemonic-language').val(); try { - await this.resetRegistration(); - - await window.setPassword(input); - await this.accountManager.registerSingleDevice( - mnemonic, - language, - this.trim(this.$('#display-name').val()) - ); - this.$el.trigger('openInbox'); - } catch (e) { - if (typeof e === 'string') { - this.showToast(e); - } - this.log(e); + window.mnemonic.mn_decode(mnemonic, language); + } catch (error) { + this.$('#mnemonic').addClass('error-input'); + this.$('#error').text(error); + this.$('#error').show(); + return; + } + this.$('#error').hide(); + this.$('#mnemonic').removeClass('error-input'); + if (!mnemonic) { + this.log('Please provide a mnemonic word list'); + } else { + this.showProfilePage(mnemonic, language); } }, - registerWithoutMnemonic() { - const mnemonic = this.$('#mnemonic-display').text(); - const language = this.$('#mnemonic-display-language').val(); - this.showProfilePage(mnemonic, language); + onSaveProfile() { + if (_.isEmpty(this.registrationParams)) { + this.onBack(); + return; + } + + const { mnemonic, language } = this.registrationParams; + this.register(mnemonic, language); }, - async onSecondaryDeviceRegistered() { - clearInterval(this.pairingInterval); - // Ensure the left menu is updated - Whisper.events.trigger('userChanged', { isSecondaryDevice: true }); - // will re-run the background initialisation - Whisper.events.trigger('registration_done'); - this.$el.trigger('openInbox'); + */ + log(s) { + window.log.info(s); + this.$('#status').text(s); }, - async resetRegistration() { - await window.Signal.Data.removeAllIdentityKeys(); - await window.Signal.Data.removeAllPrivateConversations(); - Whisper.Registration.remove(); - // Do not remove all items since they are only set - // at startup. - textsecure.storage.remove('identityKey'); - textsecure.storage.remove('secondaryDeviceStatus'); - window.ConversationController.reset(); - await window.ConversationController.load(); - Whisper.RotateSignedPreKeyListener.stop(Whisper.events); + displayError(error) { + this.$('#error') + .hide() + .text(error) + .addClass('in') + .fadeIn(); }, - async cancelSecondaryDevice() { + + showToast(message) { + const toast = new Whisper.MessageToastView({ + message, + }); + toast.$el.appendTo(this.$el); + toast.render(); + }, + }); +})(); + +/* + + async cancelSecondaryDevice() { Whisper.events.off( 'secondaryDeviceRegistration', this.onSecondaryDeviceRegistered @@ -313,194 +212,15 @@ onError(e); } }, - registerWithMnemonic() { - const mnemonic = this.$('#mnemonic').val(); - const language = this.$('#mnemonic-language').val(); - try { - window.mnemonic.mn_decode(mnemonic, language); - } catch (error) { - this.$('#mnemonic').addClass('error-input'); - this.$('#error').text(error); - this.$('#error').show(); - return; - } - this.$('#error').hide(); - this.$('#mnemonic').removeClass('error-input'); - if (!mnemonic) { - this.log('Please provide a mnemonic word list'); - } else { - this.showProfilePage(mnemonic, language); - } - }, - onSaveProfile() { - if (_.isEmpty(this.registrationParams)) { - this.onBack(); - return; - } - const { mnemonic, language } = this.registrationParams; - this.register(mnemonic, language); - }, - onBack() { - this.showRegisterPage(); - }, - onChangeMnemonic() { - this.$('#status').html(''); - }, - async onGenerateMnemonic() { - const language = this.$('#mnemonic-display-language').val(); - const mnemonic = await this.accountManager.generateMnemonic(language); - this.$('#mnemonic-display').text(mnemonic); - }, - onCopyMnemonic() { - window.clipboard.writeText(this.$('#mnemonic-display').text()); - this.showToast(i18n('copiedMnemonic')); - }, */ - log(s) { - window.log.info(s); - this.$('#status').text(s); - }, - displayError(error) { - this.$('#error') - .hide() - .text(error) - .addClass('in') - .fadeIn(); - }, - /* onValidation() { - if (this.$('#number-container').hasClass('valid')) { - this.$('#request-sms, #request-voice').removeAttr('disabled'); - } else { - this.$('#request-sms, #request-voice').prop('disabled', 'disabled'); - } - }, - onChangeCode() { - if (!this.validateCode()) { - this.$('#code').addClass('invalid'); - } else { - this.$('#code').removeClass('invalid'); - } - }, - requestVoice() { - window.removeSetupMenuItems(); - this.$('#error').hide(); - const number = this.phoneView.validateNumber(); - if (number) { - this.accountManager - .requestVoiceVerification(number) - .catch(this.displayError.bind(this)); - this.$('#step2') - .addClass('in') - .fadeIn(); - } else { - this.$('#number-container').addClass('invalid'); - } - }, - requestSMSVerification() { - window.removeSetupMenuItems(); - $('#error').hide(); - const number = this.phoneView.validateNumber(); - if (number) { - this.accountManager - .requestSMSVerification(number) - .catch(this.displayError.bind(this)); - this.$('#step2') - .addClass('in') - .fadeIn(); - } else { - this.$('#number-container').addClass('invalid'); - } - }, - - toggleSection(e) { - function focusInput() { - const inputs = $(this).find('input'); - if ($(this).is(':visible')) { - if (inputs[0]) { - inputs[0].focus(); - } - } - } - // Expand or collapse this panel - const $target = this.$(e.currentTarget); - const $next = $target.next(); - - // Toggle section visibility - $next.slideToggle('fast', focusInput); - $target.toggleClass('section-toggle-visible'); - - // Hide the other sections - this.$('.section-toggle') - .not($target) - .removeClass('section-toggle-visible'); - this.$('.section-content') - .not($next) - .slideUp('fast'); - }, - validatePassword() { - const input = this.trim(this.$passwordInput.val()); - const confirmationInput = this.trim( - this.$passwordConfirmationInput.val() - ); - - // If user hasn't set a value then skip - if (!input && !confirmationInput) { - return null; - } - - const error = passwordUtil.validatePassword(input, i18n); - if (error) { - return error; - } - - if (input !== confirmationInput) { - return "Password don't match"; - } - - return null; + async onSecondaryDeviceRegistered() { + clearInterval(this.pairingInterval); + // Ensure the left menu is updated + Whisper.events.trigger('userChanged', { isSecondaryDevice: true }); + // will re-run the background initialisation + Whisper.events.trigger('registration_done'); + this.$el.trigger('openInbox'); }, - onValidatePassword() { - const passwordValidation = this.validatePassword(); - if (passwordValidation) { - this.$passwordInput.addClass('error-input'); - this.$passwordConfirmationInput.addClass('error-input'); - this.$passwordInput.removeClass('match-input'); - this.$passwordConfirmationInput.removeClass('match-input'); - - this.$passwordInputError.text(passwordValidation); - this.$passwordInputError.show(); - } else { - this.$passwordInput.removeClass('error-input'); - this.$passwordConfirmationInput.removeClass('error-input'); - - this.$passwordInputError.text(''); - this.$passwordInputError.hide(); - - // Show green box around inputs that match - const input = this.trim(this.$passwordInput.val()); - const confirmationInput = this.trim( - this.$passwordConfirmationInput.val() - ); - if (input && input === confirmationInput) { - this.$passwordInput.addClass('match-input'); - this.$passwordConfirmationInput.addClass('match-input'); - } else { - this.$passwordInput.removeClass('match-input'); - this.$passwordConfirmationInput.removeClass('match-input'); - } - } - }, - trim(value) { - return value ? value.trim() : value; - }, */ - showToast(message) { - const toast = new Whisper.MessageToastView({ - message, - }); - toast.$el.appendTo(this.$el); - toast.render(); - }, - }); -})(); +*/ diff --git a/stylesheets/_session_signin.scss b/stylesheets/_session_signin.scss index 60a9dd491..ff4661816 100644 --- a/stylesheets/_session_signin.scss +++ b/stylesheets/_session_signin.scss @@ -225,7 +225,7 @@ overflow-wrap: break-word; padding: 20px 5px 20px 5px; display: inline-block; - font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; } } diff --git a/ts/components/session/RegistrationTabs.tsx b/ts/components/session/RegistrationTabs.tsx index 62ee99185..8ac23f6e9 100644 --- a/ts/components/session/RegistrationTabs.tsx +++ b/ts/components/session/RegistrationTabs.tsx @@ -4,10 +4,27 @@ import classNames from 'classnames'; import { LocalizerType } from '../../types/Util'; import { SessionInput } from './SessionInput'; import { SessionButton, SessionButtonTypes } from './SessionButton'; +import { trigger } from '../../shims/events'; + +declare global { + interface Window { + getAccountManager: any; + mnemonic: any; + passwordUtil: any; + dcodeIO: any; + libsignal: any; + displayNameRegex: any; + Signal: any; + Whisper: any; + ConversationController: any; + setPassword: any; + } +} + +declare var textsecure: any; interface Props { i18n: LocalizerType; - //onItemClick?: (event: ItemClickEvent) => void; } enum SignInMode { @@ -24,10 +41,13 @@ interface State { selectedTab: 'create' | 'signin'; signInMode: SignInMode; signUpMode: SignUpMode; - seed: string; displayName: string; password: string; validatePassword: string; + passwordErrorString: string; + passwordFieldsMatch: boolean; + mnemonicSeed: string; + hexEncodedPubKey: string; } interface TabSelectEvent { @@ -66,6 +86,8 @@ const Tab = ({ }; export class RegistrationTabs extends React.Component { + private readonly accountManager: any; + constructor(props: any) { super(props); @@ -73,16 +95,27 @@ export class RegistrationTabs extends React.Component { this.onDisplayNameChanged = this.onDisplayNameChanged.bind(this); this.onPasswordChanged = this.onPasswordChanged.bind(this); this.onPasswordVerifyChanged = this.onPasswordVerifyChanged.bind(this); + this.onSignUpGenerateSessionIDClick = this.onSignUpGenerateSessionIDClick.bind( + this + ); + this.onSignUpGetStartedClick = this.onSignUpGetStartedClick.bind(this); this.state = { selectedTab: 'create', signInMode: SignInMode.Default, signUpMode: SignUpMode.Default, - seed: '', displayName: '', password: '', validatePassword: '', + passwordErrorString: '', + passwordFieldsMatch: false, + mnemonicSeed: '', + hexEncodedPubKey: '', }; + + this.accountManager = window.getAccountManager(); + // Clean status in case the app closed unexpectedly + textsecure.storage.remove('secondaryDeviceStatus'); } public render() { @@ -122,15 +155,17 @@ export class RegistrationTabs extends React.Component { }; private onSeedChanged(val: string) { - this.setState({ seed: val }); + this.setState({ mnemonicSeed: val }); } private onDisplayNameChanged(val: string) { - this.setState({ displayName: val }); + const sanitizedName = this.sanitiseNameInput(val); + this.setState({ displayName: sanitizedName }); } private onPasswordChanged(val: string) { this.setState({ password: val }); + this.onValidatePassword(); // FIXME add bubbles or something to help the user know what he did wrong } private onPasswordVerifyChanged(val: string) { @@ -163,7 +198,7 @@ export class RegistrationTabs extends React.Component {
{i18n('yourUniqueSessionID')}
- {this.renderEnterSessionID(false)} + {this.renderEnterSessionID(false, this.state.hexEncodedPubKey)} {this.renderSignUpButton()} {this.getRenderTermsConditionAgreement()} @@ -210,10 +245,12 @@ export class RegistrationTabs extends React.Component { return ( { - this.setState({ - signUpMode: SignUpMode.SessionIDGenerated, - }); + onClick={async () => { + if (signUpMode === SignUpMode.Default) { + await this.onSignUpGenerateSessionIDClick(); + } else { + this.onSignUpGetStartedClick(); + } }} buttonType={buttonType} text={buttonText} @@ -221,6 +258,42 @@ export class RegistrationTabs extends React.Component { ); } + private async onSignUpGenerateSessionIDClick() { + this.setState({ + signUpMode: SignUpMode.SessionIDGenerated, + }); + + const language = 'english'; + const mnemonic = await this.accountManager.generateMnemonic(language); + + let seedHex = window.mnemonic.mn_decode(mnemonic, language); + // handle shorter than 32 bytes seeds + const privKeyHexLength = 32 * 2; + if (seedHex.length !== privKeyHexLength) { + seedHex = seedHex.concat(seedHex); + seedHex = seedHex.substring(0, privKeyHexLength); + } + const privKeyHex = window.mnemonic.sc_reduce32(seedHex); + const privKey = window.dcodeIO.ByteBuffer.wrap( + privKeyHex, + 'hex' + ).toArrayBuffer(); + const keyPair = await window.libsignal.Curve.async.createKeyPair(privKey); + const hexEncodedPubKey = Buffer.from(keyPair.pubKey).toString('hex'); + + this.setState({ + mnemonicSeed: mnemonic, + hexEncodedPubKey, // our 'frontend' sessionID + }); + } + + private onSignUpGetStartedClick() { + this.setState({ + selectedTab: 'signin', + signInMode: SignInMode.UsingSeed, + }); + } + private renderSignIn() { return (
@@ -243,7 +316,7 @@ export class RegistrationTabs extends React.Component { label={i18n('mnemonicSeed')} type="password" placeholder={i18n('enterSeed')} - value={this.state.seed} + value={this.state.mnemonicSeed} enableShowHide={true} onValueChanged={(val: string) => { this.onSeedChanged(val); @@ -266,6 +339,7 @@ export class RegistrationTabs extends React.Component { this.onPasswordChanged(val); }} /> + { } } - private renderEnterSessionID(contentEditable: boolean) { + private renderEnterSessionID(contentEditable: boolean, text?: string) { const { i18n } = this.props; const enterSessionIDHere = i18n('enterSessionIDHere'); @@ -299,7 +373,9 @@ export class RegistrationTabs extends React.Component { className="session-signin-enter-session-id" contentEditable={contentEditable} placeholder={enterSessionIDHere} - /> + > + {text} +
); } @@ -308,43 +384,32 @@ export class RegistrationTabs extends React.Component { const { i18n } = this.props; const or = i18n('or'); - let greenButtonType: any; - let greenText: string; - let whiteButtonText: string; - if (signInMode !== SignInMode.Default) { - greenButtonType = SessionButtonTypes.FullGreen; - greenText = i18n('continueYourSession'); - } else { - greenButtonType = SessionButtonTypes.Green; - greenText = i18n('restoreUsingSeed'); + + if (signInMode === SignInMode.Default) { + return ( +
+ {this.renderRestoreUsingSeedButton(SessionButtonTypes.Green)} +
{or}
+ {this.renderLinkDeviceToExistingAccountButton()} +
+ ); } + if (signInMode === SignInMode.LinkingDevice) { - whiteButtonText = i18n('restoreUsingSeed'); - } else { - whiteButtonText = i18n('linkDeviceToExistingAccount'); + return ( +
+ {this.renderContinueYourSessionButton()} +
{or}
+ {this.renderRestoreUsingSeedButton(SessionButtonTypes.White)} +
+ ); } return (
- { - this.setState({ - signInMode: SignInMode.UsingSeed, - }); - }} - buttonType={greenButtonType} - text={greenText} - /> + {this.renderContinueYourSessionButton()}
{or}
- { - this.setState({ - signInMode: SignInMode.LinkingDevice, - }); - }} - buttonType={SessionButtonTypes.White} - text={whiteButtonText} - /> + {this.renderLinkDeviceToExistingAccountButton()}
); } @@ -360,4 +425,171 @@ export class RegistrationTabs extends React.Component { ); } + + private renderContinueYourSessionButton() { + return ( + { + await this.register('english'); + }} + buttonType={SessionButtonTypes.FullGreen} + text={this.props.i18n('continueYourSession')} + /> + ); + } + + private renderRestoreUsingSeedButton(buttonType: SessionButtonTypes) { + return ( + { + this.setState({ + signInMode: SignInMode.UsingSeed, + hexEncodedPubKey: '', + mnemonicSeed: '', + displayName: '', + signUpMode: SignUpMode.Default, + }); + }} + buttonType={buttonType} + text={this.props.i18n('restoreUsingSeed')} + /> + ); + } + + private renderLinkDeviceToExistingAccountButton() { + return ( + { + this.setState({ + signInMode: SignInMode.LinkingDevice, + hexEncodedPubKey: '', + mnemonicSeed: '', + displayName: '', + signUpMode: SignUpMode.Default, + }); + }} + buttonType={SessionButtonTypes.White} + text={this.props.i18n('linkDeviceToExistingAccount')} + /> + ); + } + + private trim(value: string) { + return value ? value.trim() : value; + } + + private validatePassword() { + const input = this.trim(this.state.password); + const confirmationInput = this.trim(this.state.validatePassword); + + // If user hasn't set a value then skip + if (!input && !confirmationInput) { + return null; + } + + const error = window.passwordUtil.validatePassword(input, this.props.i18n); + if (error) { + return error; + } + + if (input !== confirmationInput) { + return "Password don't match"; + } + + return null; + } + + private onValidatePassword() { + const passwordValidation = this.validatePassword(); + if (passwordValidation) { + this.setState({ passwordErrorString: passwordValidation }); + + /*this.$passwordInput.addClass('error-input'); + this.$passwordConfirmationInput.addClass('error-input'); + + this.$passwordInput.removeClass('match-input'); + this.$passwordConfirmationInput.removeClass('match-input'); + + this.$passwordInputError.text(passwordValidation); + this.$passwordInputError.show();*/ + } else { + // Show green box around inputs that match + const input = this.trim(this.state.password); + const confirmationInput = this.trim(this.state.validatePassword); + const passwordFieldsMatch = + input !== undefined && input === confirmationInput; + + this.setState({ + passwordErrorString: '', + passwordFieldsMatch, + }); + + /* + this.$passwordInput.addClass('match-input'); //if password matches each other + this.$passwordInput.removeClass('error-input'); + this.$passwordConfirmationInput.removeClass('error-input'); + this.$passwordInputError.text(''); + this.$passwordInputError.hide();*/ + } + } + + private sanitiseNameInput(val: string) { + return val.trim().replace(window.displayNameRegex, ''); + + /* if (_.isEmpty(newVal)) { + this.$('#save-button').attr('disabled', 'disabled'); + + } + this.$('#save-button').removeAttr('disabled'); */ + } + + private async resetRegistration() { + await window.Signal.Data.removeAllIdentityKeys(); + await window.Signal.Data.removeAllPrivateConversations(); + window.Whisper.Registration.remove(); + // Do not remove all items since they are only set + // at startup. + textsecure.storage.remove('identityKey'); + textsecure.storage.remove('secondaryDeviceStatus'); + window.ConversationController.reset(); + await window.ConversationController.load(); + window.Whisper.RotateSignedPreKeyListener.stop(window.Whisper.events); + } + + private async register(language: string) { + const { password, mnemonicSeed, displayName } = this.state; + // Make sure the password is valid + if (this.validatePassword()) { + //this.showToast(i18n('invalidPassword')); + + return; + } + if (!mnemonicSeed) { + return; + } + if (!displayName) { + return; + } + + // Ensure we clear the secondary device registration status + textsecure.storage.remove('secondaryDeviceStatus'); + + try { + await this.resetRegistration(); + + await window.setPassword(password); + await this.accountManager.registerSingleDevice( + mnemonicSeed, + language, + displayName + ); + trigger('openInbox'); + } catch (e) { + if (typeof e === 'string') { + //this.showToast(e); + } + //this.log(e); + } + } } diff --git a/ts/components/session/SessionRegistrationView.tsx b/ts/components/session/SessionRegistrationView.tsx index f0144a914..445a27327 100644 --- a/ts/components/session/SessionRegistrationView.tsx +++ b/ts/components/session/SessionRegistrationView.tsx @@ -25,6 +25,7 @@ export class SessionRegistrationView extends React.Component { return (
+