diff --git a/e2e/cypress/integration/signup.ts b/e2e/cypress/integration/signup.ts index 7764e4eed..7874678f7 100644 --- a/e2e/cypress/integration/signup.ts +++ b/e2e/cypress/integration/signup.ts @@ -24,7 +24,11 @@ describe('Signup', () => { cy.intercept('GET', '/rest/v1/runbox_domains', { statusCode: 200, body: { - results: ['runbox.com', 'runbox.no', 'rbx.email'], + results: [ + { id: 1, name: 'runbox.com' }, + { id: 2, name: 'runbox.no' }, + { id: 3, name: 'rbx.email' }, + ], }, }).as('runboxDomains'); @@ -37,7 +41,7 @@ describe('Signup', () => { cy.intercept('GET', 'https://hcaptcha.com/1/api.js?render=explicit', { statusCode: 200, - body: 'window.hcaptcha = { render: function() { return "test-widget"; } };', + body: 'window.hcaptcha = { render: function(c, p) { window.__hcaptchaParams = p; return "test-widget"; } };', headers: { 'content-type': 'application/javascript', }, diff --git a/src/app/rmmapi/rbwebmail.ts b/src/app/rmmapi/rbwebmail.ts index 3e044af0c..89d9b8d01 100644 --- a/src/app/rmmapi/rbwebmail.ts +++ b/src/app/rmmapi/rbwebmail.ts @@ -633,7 +633,7 @@ export class RunboxWebmailAPI { public getSignupHCaptchaSiteKey(): Observable { return this.http.get('/rest/v1/signup/hcaptcha/site_key').pipe( - map((res: any) => res.result), + map((res: any) => res?.result ?? ''), ); } diff --git a/src/app/signup/signup.component.html b/src/app/signup/signup.component.html index 71251d534..3bdf5e372 100644 --- a/src/app/signup/signup.component.html +++ b/src/app/signup/signup.component.html @@ -333,7 +333,6 @@

Preferences and terms

- diff --git a/src/app/signup/signup.component.scss b/src/app/signup/signup.component.scss index f4b2f6854..e12efb83f 100644 --- a/src/app/signup/signup.component.scss +++ b/src/app/signup/signup.component.scss @@ -76,7 +76,7 @@ along with Runbox 7. If not, see . } .signup-main { - width: min(1380px, calc(100% - 2rem)); + width: min(1380px, calc(100% - clamp(0.75rem, 2vw + 0.4rem, 2rem))); margin: 0 auto; padding: 1.5rem 0 2rem; display: grid; @@ -105,7 +105,7 @@ along with Runbox 7. If not, see . } .hero-copy { - padding: 2rem 1.75rem 1.5rem; + padding: 2rem clamp(0.9rem, calc(1.5vw + 0.45rem), 1.75rem) 1.5rem; } .eyebrow { @@ -124,7 +124,7 @@ along with Runbox 7. If not, see . .hero-text { margin: 1rem 0 0; - font-size: 1.04rem; + font-size: clamp(0.92rem, calc(0.4vw + 0.84rem), 1.04rem); line-height: 1.6; color: rgba(255, 255, 255, 0.88); } @@ -144,7 +144,7 @@ along with Runbox 7. If not, see . } .note { - padding: 1.15rem 1.75rem; + padding: 1.15rem clamp(0.9rem, calc(1.5vw + 0.45rem), 1.75rem); background: transparent; border-top: 1px solid rgba(255, 255, 255, 0.14); } @@ -156,6 +156,7 @@ along with Runbox 7. If not, see . .note p { margin: 0; + font-size: clamp(0.92rem, calc(0.4vw + 0.8rem), 1rem); line-height: 1.5; color: rgba(255, 255, 255, 0.78); } @@ -170,7 +171,7 @@ along with Runbox 7. If not, see . grid-template-columns: minmax(0, 1fr); gap: 0; align-items: center; - padding: 1.4rem 1.5rem; + padding: 1.4rem clamp(0.9rem, calc(1.2vw + 0.45rem), 1.5rem); border-bottom: 1px solid rmm.$rmm-gray; background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(240, 246, 252, 0.92)); } @@ -182,12 +183,13 @@ along with Runbox 7. If not, see . .form-heading p { margin: 0.35rem 0 0; - color: #486175; + font-size: clamp(0.92rem, calc(0.4vw + 0.8rem), 1rem); + color: rmm.$rmm-signup-slate-900; line-height: 1.5; } .signup-form { - padding: 1.4rem 1.5rem 1.6rem; + padding: 1.4rem clamp(0.9rem, calc(1.2vw + 0.45rem), 1.5rem) 1.6rem; } .form-section + .form-section { @@ -196,7 +198,7 @@ along with Runbox 7. If not, see . .form-section { padding-top: 1.35rem; - border-top: 1px solid #dde7f0; + border-top: 1px solid rmm.$rmm-signup-slate-200; } .form-section:first-child { @@ -215,7 +217,8 @@ along with Runbox 7. If not, see . .section-head p { margin: 0.28rem 0 0; - color: #5a7187; + font-size: clamp(0.92rem, calc(0.4vw + 0.8rem), 1rem); + color: rmm.$rmm-signup-slate-800; line-height: 1.5; } @@ -230,9 +233,9 @@ along with Runbox 7. If not, see . gap: 0.32rem; padding: 1rem 1rem 1rem 2.8rem; position: relative; - border: 1px solid #bfd0e1; + border: 1px solid rmm.$rmm-signup-slate-300; border-radius: 8px; - background: #f8fbfe; + background: rmm.$rmm-signup-slate-050; cursor: pointer; } @@ -254,7 +257,7 @@ along with Runbox 7. If not, see . } .choice-copy { - color: #587085; + color: rmm.$rmm-signup-slate-700; line-height: 1.45; font-weight: 400; } @@ -322,13 +325,13 @@ along with Runbox 7. If not, see . } .field > small { - color: #637b90; + color: rmm.$rmm-signup-slate-600; font-size: 0.82rem; } .field.is-invalid > span, .checkbox-row.is-invalid span { - color: #a12020; + color: mat.get-color-from-palette(rmm.$rmm-default-error, 700); } .info-chip { @@ -457,15 +460,15 @@ along with Runbox 7. If not, see . .field.is-invalid input, .field.is-invalid select, .checkbox-row.is-invalid input { - border-color: #d04848; - background: #fff7f7; + border-color: mat.get-color-from-palette(rmm.$rmm-default-error, 500); + background: mat.get-color-from-palette(rmm.$rmm-default-error, 50); } .field.is-invalid input:focus, .field.is-invalid select:focus, .checkbox-row.is-invalid input:focus { - border-color: #c23030; - box-shadow: 0 0 0 3px rgba(210, 72, 72, 0.14); + border-color: mat.get-color-from-palette(rmm.$rmm-default-error, 700); + box-shadow: 0 0 0 3px mat.get-color-from-palette(rmm.$rmm-default-error, 500, 0.14); } .strength-meter { @@ -489,8 +492,8 @@ along with Runbox 7. If not, see . .captcha-box { padding: 1rem; border-radius: 8px; - background: #f6fafd; - border: 1px solid #d6e2ec; + background: rmm.$rmm-signup-slate-030; + border: 1px solid rmm.$rmm-signup-slate-150; overflow-x: auto; } @@ -505,23 +508,23 @@ along with Runbox 7. If not, see . .captcha-host:empty::before { content: 'Loading CAPTCHA...'; display: inline-block; - color: #587085; + color: rmm.$rmm-signup-slate-700; font-size: 0.92rem; } .captcha-missing { margin: 0; padding: 0.8rem 0.9rem; - border: 1px solid #d6b087; - background: #fff3e4; + border: 1px solid rmm.$rmm-signup-warning-border; + background: rmm.$rmm-signup-warning-bg; border-radius: 8px; line-height: 1.5; } .policy-block { - background: linear-gradient(180deg, #f8fbfe 0%, #f2f7fb 100%); + background: linear-gradient(180deg, rmm.$rmm-signup-slate-050 0%, rmm.$rmm-signup-slate-020 100%); padding: 1.25rem; - border: 1px solid #dce6ef; + border: 1px solid rmm.$rmm-signup-slate-100; border-radius: 8px; } @@ -573,16 +576,16 @@ along with Runbox 7. If not, see . margin: 1.1rem 0 0; padding: 0.85rem 1rem; border-radius: 8px; - border: 1px solid #d79a9a; - background: #fff2f2; - color: #8b1d1d; + border: 1px solid mat.get-color-from-palette(rmm.$rmm-default-error, 300); + background: mat.get-color-from-palette(rmm.$rmm-default-error, 50); + color: mat.get-color-from-palette(rmm.$rmm-default-error, 900); line-height: 1.5; font-weight: 600; } .field-error { display: block; - color: #a12020; + color: mat.get-color-from-palette(rmm.$rmm-default-error, 700); font-size: 0.82rem; line-height: 1.4; } @@ -641,7 +644,11 @@ along with Runbox 7. If not, see . .signup-footer { padding: 1.2rem 1.25rem 1.8rem; - background-image: linear-gradient(170deg, #014f89, #001e35); + background-image: linear-gradient( + 170deg, + mat.get-color-from-palette(rmm.$rmm-default-primary), + rmm.$rmm-dark-background + ); color: rgba(255, 255, 255, 0.82); text-align: center; } @@ -684,18 +691,9 @@ along with Runbox 7. If not, see . } .signup-main { - width: min(100% - 1.5rem, 1380px); gap: 1.1rem; } - .hero-copy, - .note, - .signup-form, - .form-heading { - padding-left: 1.2rem; - padding-right: 1.2rem; - } - .form-heading { grid-template-columns: 1fr; } @@ -723,7 +721,6 @@ along with Runbox 7. If not, see . } .signup-main { - width: min(100% - 1rem, 1380px); padding-top: 0.75rem; padding-bottom: 1.2rem; gap: 1rem; @@ -733,14 +730,6 @@ along with Runbox 7. If not, see . grid-template-columns: 1fr; } - .hero-copy, - .note, - .signup-form, - .form-heading { - padding-left: 1rem; - padding-right: 1rem; - } - .news-choice { align-items: flex-start; flex-direction: column; @@ -794,21 +783,6 @@ along with Runbox 7. If not, see . line-height: 1.08; } - .hero-text, - .hero-subtext, - .note p, - .section-head p { - font-size: 0.95rem; - } - - .signup-form, - .form-heading, - .hero-copy, - .note { - padding-left: 0.9rem; - padding-right: 0.9rem; - } - .field input, .field select { padding: 0.72rem 0.82rem; @@ -838,7 +812,6 @@ along with Runbox 7. If not, see . @media (max-width: 420px) { .signup-main { - width: calc(100% - 0.75rem); gap: 0.85rem; } @@ -846,14 +819,6 @@ along with Runbox 7. If not, see . font-size: 1.85rem; } - .hero-text, - .hero-subtext, - .note p, - .form-heading p, - .section-head p { - font-size: 0.92rem; - } - .form-heading { padding-top: 1.15rem; padding-bottom: 1.15rem; diff --git a/src/app/signup/signup.component.spec.ts b/src/app/signup/signup.component.spec.ts index c34b0effd..29cb8a4b7 100644 --- a/src/app/signup/signup.component.spec.ts +++ b/src/app/signup/signup.component.spec.ts @@ -37,7 +37,14 @@ describe('SignupComponent', () => { 'getRunboxDomains', 'getSignupHCaptchaSiteKey', ]); - rmmapi.getRunboxDomains.and.returnValue(of(['runbox.com', 'runbox.no', 'rbx.email'])); + // Real backend returns {id, name}[]; service signature is currently + // typed as string[]. Cast so the mock exercises the runtime shape + // production actually delivers. + rmmapi.getRunboxDomains.and.returnValue(of([ + { id: 1, name: 'runbox.com' }, + { id: 2, name: 'runbox.no' }, + { id: 3, name: 'rbx.email' }, + ]) as any); rmmapi.getSignupHCaptchaSiteKey.and.returnValue(of('test-site-key')); await TestBed.configureTestingModule({ @@ -61,6 +68,28 @@ describe('SignupComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(SignupComponent); component = fixture.componentInstance; + // Minimal hCaptcha stub so renderHCaptcha completes on first try when + // a test exercises the script-loaded path. Tests that don't reach + // renderHCaptcha (loadResult=false) ignore this. + (window as any).hcaptcha = { render: () => 'test-widget-id' }; + }); + + afterEach(() => { + try { + if (fixture) { + fixture.destroy(); + } + } finally { + delete (window as any).hcaptcha; + } + }); + + // Defensive: signup adds body classes in ngOnInit; if a previous test's + // fixture.destroy failed to run (e.g. spec aborted), the classes could leak + // into siblings. Strip them unconditionally after the describe block. + afterAll(() => { + document.body.classList.remove('signup-page'); + document.getElementById('main')?.classList.remove('signup-page-shell'); }); function stubCaptchaInitialization(loadResult = false): jasmine.Spy { @@ -105,7 +134,7 @@ describe('SignupComponent', () => { } it('loads signup metadata from explicit API sources', async () => { - await initComponent(); + await initComponent(true); expect(component.signupAction).toBe('/signup/signup'); expect(component.hCaptchaSiteKey).toBe('test-site-key'); @@ -185,8 +214,27 @@ describe('SignupComponent', () => { expect(fixture.nativeElement.textContent).toContain('Enter a valid domain such as example.com.'); }); - it('blocks submit if captcha is missing even when required fields are valid', async () => { - await initComponent(); + it('blocks submit if captcha script failed to load even when required fields are valid', async () => { + await initComponent(); // loadResult = false: simulates script failure + await fillRequiredFields(); + + const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement; + const submitSpy = spyOn(formElement, 'submit'); + + component.onSubmit(getForm(), formElement); + fixture.detectChanges(); + + // After the S3 fix, a failed script load clears hCaptchaSiteKey so the + // submit guard reports CAPTCHA unavailable rather than blaming the user + // for not completing a widget that does not exist. + expect(component.hCaptchaSiteKey).toBe(''); + expect(component.showCaptchaValidationError).toBeFalse(); + expect(component.submitError).toBe('CAPTCHA is unavailable right now. Please try again shortly.'); + expect(submitSpy).not.toHaveBeenCalled(); + }); + + it('blocks submit if user skipped captcha after script loaded', async () => { + await initComponent(true); // script loaded, widget render attempted await fillRequiredFields(); const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement; @@ -200,8 +248,37 @@ describe('SignupComponent', () => { expect(submitSpy).not.toHaveBeenCalled(); }); + it('blocks submit with a loading message when captcha script is still loading', async () => { + // Simulate a slow CDN: site key arrives but the script never settles. + let resolveScriptLoad!: (v: boolean) => void; + spyOn(component, 'loadHCaptchaScript').and.returnValue( + new Promise((resolve) => { resolveScriptLoad = resolve; }), + ); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.hCaptchaSiteKey).toBe('test-site-key'); + expect((component as any).hCaptchaReady).toBeFalse(); + + await fillRequiredFields(); + + const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement; + const submitSpy = spyOn(formElement, 'submit'); + + component.onSubmit(getForm(), formElement); + fixture.detectChanges(); + + expect(component.submitError).toBe('CAPTCHA is still loading. Please wait a moment and try again.'); + expect(submitSpy).not.toHaveBeenCalled(); + + // Release the paused async chain so the component instance can be GC'd. + resolveScriptLoad(false); + }); + it('submits the native form when validation and captcha both pass', async () => { - await initComponent(); + await initComponent(true); await fillRequiredFields(); const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement; diff --git a/src/app/signup/signup.component.ts b/src/app/signup/signup.component.ts index bda410c08..4b0061a61 100644 --- a/src/app/signup/signup.component.ts +++ b/src/app/signup/signup.component.ts @@ -20,6 +20,7 @@ import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { NgForm, NgModel } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; +import { Subscription } from 'rxjs'; import { RunboxWebmailAPI } from '../rmmapi/rbwebmail'; type AccountType = 'person' | 'business'; @@ -75,6 +76,9 @@ export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { private hCaptchaReady = false; private nativeSubmitting = false; private pendingCaptchaRender = false; + private destroyed = false; + private captchaRenderTimer: number | null = null; + private readonly subs = new Subscription(); private readonly customDomainPattern = /^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$/; constructor( @@ -103,7 +107,8 @@ export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { if (domainType === 'user' || domainType === 'runbox') { this.domainType = domainType; } - this.accountNumber = params.get('account_number') || params.get('accountNumber') || ''; + const rawAccountNumber = params.get('account_number') || params.get('accountNumber') || ''; + this.accountNumber = /^[0-9]{0,12}$/.test(rawAccountNumber) ? rawAccountNumber : ''; }); this.initializeHCaptcha(); @@ -116,6 +121,12 @@ export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { } ngOnDestroy(): void { + this.destroyed = true; + this.subs.unsubscribe(); + if (this.captchaRenderTimer !== null) { + window.clearTimeout(this.captchaRenderTimer); + this.captchaRenderTimer = null; + } document.body.classList.remove('signup-page'); document.getElementById('main')?.classList.remove('signup-page-shell'); } @@ -125,7 +136,7 @@ export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { if (this.password.length >= 8) { score++; } - if (/[a-z]/.test(this.password) && /[A-Z]/.test(this.password)) { + if (/\p{Ll}/u.test(this.password) && /\p{Lu}/u.test(this.password)) { score++; } if (/\d/.test(this.password)) { @@ -168,8 +179,10 @@ export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { return; } - if (!this.hCaptchaSiteKey) { - this.submitError = 'CAPTCHA is unavailable right now. Please try again shortly.'; + if (!this.hCaptchaReady) { + this.submitError = this.hCaptchaSiteKey + ? 'CAPTCHA is still loading. Please wait a moment and try again.' + : 'CAPTCHA is unavailable right now. Please try again shortly.'; return; } @@ -182,7 +195,13 @@ export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { this.submitInProgress = true; this.nativeSubmitting = true; - formElement.submit(); + try { + formElement.submit(); + } catch (err) { + this.submitInProgress = false; + this.nativeSubmitting = false; + this.submitError = 'Signup could not be submitted. Please try again.'; + } } showFieldError(control?: NgModel | null, form?: NgForm): boolean { @@ -237,10 +256,15 @@ export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { } private loadRunboxDomains(): void { - this.rmmapi.getRunboxDomains().subscribe({ + this.subs.add(this.rmmapi.getRunboxDomains().subscribe({ next: (domains) => { - if (domains.length > 0) { - this.runboxDomains = domains; + // Backend returns {results: [{id, name}, ...]}; tolerate either shape + // since other consumers historically treat the result as string[]. + const names = (Array.isArray(domains) ? domains : []) + .map((d: any) => typeof d === 'string' ? d : d?.name) + .filter((name: string | undefined): name is string => Boolean(name)); + if (names.length > 0) { + this.runboxDomains = names; if (!this.runboxDomains.includes(this.runboxDomain)) { this.runboxDomain = this.runboxDomains[0]; } @@ -249,11 +273,11 @@ export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { error: () => { // Keep safe defaults if the domain list cannot be loaded. }, - }); + })); } private loadHCaptchaMetadata(): void { - this.rmmapi.getSignupHCaptchaSiteKey().subscribe({ + this.subs.add(this.rmmapi.getSignupHCaptchaSiteKey().subscribe({ next: async (siteKey) => { this.hCaptchaSiteKey = siteKey; if (!this.hCaptchaSiteKey) { @@ -262,7 +286,14 @@ export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { } const captchaLoaded = await this.loadHCaptchaScript(); + if (this.destroyed) { + return; + } if (!captchaLoaded) { + // Site key was set above but the script never loaded; clear it so the + // submit guard correctly reports CAPTCHA unavailable instead of telling + // the user to complete a widget that does not exist. + this.hCaptchaSiteKey = ''; return; } @@ -273,10 +304,17 @@ export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { this.hCaptchaSiteKey = ''; this.hCaptchaError = 'CAPTCHA is temporarily unavailable. Please try again shortly.'; }, - }); + })); } private loadHCaptchaScript(): Promise { + // Ad blockers, privacy extensions, and strict CSP can silently drop the + // script request without ever firing a `load` or `error` event. Without + // a deadline the polling interval (existing-script path) and the promise + // itself (new-script path) would hang indefinitely, leaking resources + // and leaving the user with no widget and no error message. + const SCRIPT_LOAD_TIMEOUT_MS = 15_000; + return new Promise((resolve, reject) => { const existingScript = document.querySelector('script[data-runbox-hcaptcha="1"]'); if (existingScript) { @@ -288,15 +326,26 @@ export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { const pollForHCaptcha = window.setInterval(() => { if ((window as WindowWithHCaptcha).hcaptcha) { window.clearInterval(pollForHCaptcha); + window.clearTimeout(timeoutId); resolve(true); } }, 100); + const timeoutId = window.setTimeout(() => { + window.clearInterval(pollForHCaptcha); + reject(new Error('hCaptcha load timed out.')); + }, SCRIPT_LOAD_TIMEOUT_MS); + existingScript.addEventListener('load', () => { window.clearInterval(pollForHCaptcha); + window.clearTimeout(timeoutId); resolve(true); }, { once: true }); - existingScript.addEventListener('error', () => reject(new Error('Failed to load hCaptcha.')), { once: true }); + existingScript.addEventListener('error', () => { + window.clearInterval(pollForHCaptcha); + window.clearTimeout(timeoutId); + reject(new Error('Failed to load hCaptcha.')); + }, { once: true }); return; } @@ -305,17 +354,33 @@ export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { script.async = true; script.defer = true; script.setAttribute('data-runbox-hcaptcha', '1'); - script.addEventListener('load', () => resolve(true), { once: true }); - script.addEventListener('error', () => reject(new Error('Failed to load hCaptcha.')), { once: true }); + + const timeoutId = window.setTimeout(() => { + reject(new Error('hCaptcha load timed out.')); + }, SCRIPT_LOAD_TIMEOUT_MS); + + script.addEventListener('load', () => { + window.clearTimeout(timeoutId); + resolve(true); + }, { once: true }); + script.addEventListener('error', () => { + window.clearTimeout(timeoutId); + reject(new Error('Failed to load hCaptcha.')); + }, { once: true }); document.body.appendChild(script); }).catch((): boolean => { + if (this.destroyed) { return false; } + // Remove a failed/stale script element so the next attempt takes the + // "create new script" branch instead of polling a settled element + // whose load/error events will never re-fire. + document.querySelector('script[data-runbox-hcaptcha="1"]')?.remove(); this.hCaptchaError = 'CAPTCHA could not be loaded. Please try again shortly.'; return false; }); } private renderHCaptcha(): void { - if (!this.hCaptchaReady || !this.hCaptchaSiteKey) { + if (this.destroyed || !this.hCaptchaReady || !this.hCaptchaSiteKey) { return; } @@ -323,11 +388,15 @@ export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { const hcaptcha = (window as WindowWithHCaptcha).hcaptcha; if (!container || !hcaptcha) { this.pendingCaptchaRender = true; - window.setTimeout(() => this.renderHCaptcha(), 0); + this.captchaRenderTimer = window.setTimeout(() => { + this.captchaRenderTimer = null; + this.renderHCaptcha(); + }, 0); return; } this.pendingCaptchaRender = false; + this.captchaRenderTimer = null; if (this.hCaptchaWidgetId !== null) { return; @@ -354,9 +423,9 @@ export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { } private focusFirstInvalidField(formElement: HTMLFormElement): void { - const firstInvalidField = formElement.querySelector( - 'input.ng-invalid, select.ng-invalid, textarea.ng-invalid, input:invalid, select:invalid, textarea:invalid', - ); + const firstInvalidField = + formElement.querySelector('input.ng-invalid, select.ng-invalid, textarea.ng-invalid') || + formElement.querySelector('input:invalid, select:invalid, textarea:invalid'); firstInvalidField?.focus(); firstInvalidField?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } diff --git a/src/build/gen-env.js b/src/build/gen-env.js index ffc3ceeea..8db3a977b 100644 --- a/src/build/gen-env.js +++ b/src/build/gen-env.js @@ -13,7 +13,6 @@ process.env.BUILD_TIMESTAMP ??= new Date().toJSON() const env = [ ['BUILD_TIMESTAMP', assertString], ['SENTRY_DSN', tryCatch(assertString, orNull)], - ['SIGNUP_HCAPTCHA_SITE_KEY', tryCatch(assertString, orEmptyString)], ] function assertString(input) { @@ -38,10 +37,6 @@ function orNull () { return null } -function orEmptyString () { - return '' -} - fs.writeFileSync('src/environments/env.ts', ` /* eslint-disable @typescript-eslint/quotes */ diff --git a/src/styles/_rmm-theme-vars.scss b/src/styles/_rmm-theme-vars.scss index 37f0ac6c5..adb25ae19 100644 --- a/src/styles/_rmm-theme-vars.scss +++ b/src/styles/_rmm-theme-vars.scss @@ -41,3 +41,23 @@ $rmm-default-lighter-gray: #eeeeee; $rmm-default-light-gray: #f4f4f4; $rmm-default-dark-gray: #949494; $rmm-default-black: #444444; + +// Signup form chrome — slate-blue palette used only by the marketing signup +// surface. Intentionally distinct from the in-app Material theme tokens above; +// kept here so future theme changes have one canonical source. +$rmm-signup-slate-900: #486175; +$rmm-signup-slate-800: #5a7187; +$rmm-signup-slate-700: #587085; +$rmm-signup-slate-600: #637b90; +$rmm-signup-slate-300: #bfd0e1; +$rmm-signup-slate-200: #dde7f0; +$rmm-signup-slate-150: #d6e2ec; +$rmm-signup-slate-100: #dce6ef; +$rmm-signup-slate-050: #f8fbfe; +$rmm-signup-slate-030: #f6fafd; +$rmm-signup-slate-020: #f2f7fb; + +// Signup warning callout — warm tan/peach for advisory banners (e.g. CAPTCHA +// unavailable). Kept here for the same reason as the slate palette above. +$rmm-signup-warning-border: #d6b087; +$rmm-signup-warning-bg: #fff3e4;