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;