From 6a2ba64804d3e7a9fb4293dcd58e17be17686f8f Mon Sep 17 00:00:00 2001 From: Geir Thomas Andersen Date: Wed, 29 Apr 2026 00:51:33 +0200 Subject: [PATCH 01/24] feat(signup): Angular version of signup page using existing backend. --- e2e/cypress/integration/signup.ts | 45 ++ package.json | 1 + src/app/app.module.ts | 2 +- src/app/login/login.component.html | 2 +- src/app/signup/signup.component.html | 318 ++++++++++ src/app/signup/signup.component.scss | 781 ++++++++++++++++++++++++ src/app/signup/signup.component.spec.ts | 232 +++++++ src/app/signup/signup.component.ts | 319 ++++++++++ src/app/signup/signup.module.ts | 37 ++ src/build/gen-env.js | 5 + src/styles.scss | 22 + 11 files changed, 1762 insertions(+), 2 deletions(-) create mode 100644 e2e/cypress/integration/signup.ts create mode 100644 src/app/signup/signup.component.html create mode 100644 src/app/signup/signup.component.scss create mode 100644 src/app/signup/signup.component.spec.ts create mode 100644 src/app/signup/signup.component.ts create mode 100644 src/app/signup/signup.module.ts diff --git a/e2e/cypress/integration/signup.ts b/e2e/cypress/integration/signup.ts new file mode 100644 index 000000000..793b28050 --- /dev/null +++ b/e2e/cypress/integration/signup.ts @@ -0,0 +1,45 @@ +/// + +describe('Signup', () => { + it('should render the deployed Angular signup page', () => { + cy.visit('/app/signup?runbox7=1'); + + cy.location('pathname').should('eq', '/app/signup'); + cy.location('search').should('contain', 'runbox7=1'); + cy.contains('h1', 'Create a Runbox Account').should('exist'); + cy.get('form.signup-form').should('have.attr', 'action').and('match', /signup/); + cy.get('input[name="user"]').should('exist'); + cy.get('input[name="first_name"]').should('exist'); + cy.get('input[name="last_name"]').should('exist'); + cy.get('input[name="password"]').should('exist'); + cy.get('select[name="runboxDomain"]').find('option').should('have.length.greaterThan', 1); + cy.get('select[name="runboxDomain"]').find('option').then((options) => { + const domains = Array.from(options).map((option) => option.value); + expect(domains).to.include('runbox.com'); + }); + cy.get('div.captcha-host').should('exist'); + cy.contains('button.submit', 'Set up my Runbox account').should('exist'); + }); + + it('should show the public trust and transparency content', () => { + cy.visit('/app/signup?runbox7=1'); + + cy.get('header.signup-header .brand img').should('be.visible'); + cy.get('header.signup-header .brand').should('not.contain', 'Runbox 7'); + + cy.contains('.hero-panel h2', 'Privacy by business model').should('exist'); + cy.contains('.hero-panel h2', 'Hosted in Norway').should('exist'); + cy.contains('.hero-panel h2', 'Sustainable and secure').should('exist'); + cy.contains('.hero-panel h2', 'How the trial works').should('exist'); + + cy.contains('.hero-panel', 'customer email content is private').should('exist'); + cy.contains('.form-section', 'default sender name recipients will see').should('exist'); + + cy.get('.info-chip').should('have.length.at.least', 3); + cy.contains('.field-label, .field small', 'Existing email address').should('exist'); + cy.contains('.field-label, .field small', 'How did you hear about Runbox?').should('exist'); + + cy.contains('.form-actions button.submit', 'Set up my Runbox account').should('exist'); + cy.contains('a', 'Use legacy signup page').should('not.exist'); + }); +}); diff --git a/package.json b/package.json index 5a2cf1c08..2e38704ba 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "build": "node src/build/pre-build.js && ng build --configuration production --base-href=/app/ runbox7; RES=$?; node src/build/post-build.js; exit $RES", "policy": "node policy-tests/run-all.js", "test": "ng test", + "test:signup:firefox": "ng test --watch=false --browsers Firefox --include src/app/signup/signup.component.spec.ts", "lint": "ng lint", "e2e": "ng e2e", "start-e2e-server": "start-test mockserver 15000 start-use-mockserver", diff --git a/src/app/app.module.ts b/src/app/app.module.ts index bced66edb..d0d138852 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -94,6 +94,7 @@ window.addEventListener('dragover', (event) => event.preventDefault()); window.addEventListener('drop', (event) => event.preventDefault()); const routes: Routes = [ + { path: 'signup', loadChildren: () => import('./signup/signup.module').then(m => m.SignupModule) }, { path: '', canActivateChild: [RMMAuthGuardService], @@ -213,4 +214,3 @@ export class AppModule { ); } } - diff --git a/src/app/login/login.component.html b/src/app/login/login.component.html index c9d3d2425..bbc047927 100644 --- a/src/app/login/login.component.html +++ b/src/app/login/login.component.html @@ -21,7 +21,7 @@

The fastest webmail app on the planet

Runbox 7 -

Log in below or .

+

Log in below or .

diff --git a/src/app/signup/signup.component.html b/src/app/signup/signup.component.html new file mode 100644 index 000000000..1de38c2b2 --- /dev/null +++ b/src/app/signup/signup.component.html @@ -0,0 +1,318 @@ + diff --git a/src/app/signup/signup.component.scss b/src/app/signup/signup.component.scss new file mode 100644 index 000000000..6da4eae54 --- /dev/null +++ b/src/app/signup/signup.component.scss @@ -0,0 +1,781 @@ +:host { + display: block; + min-height: 100vh; + color: #0f2740; + background: + radial-gradient(circle at top left, rgba(143, 198, 255, 0.34), transparent 34%), + radial-gradient(circle at bottom right, rgba(0, 88, 153, 0.18), transparent 28%), + linear-gradient(180deg, #f5f9fc 0%, #dfeaf3 100%); +} + +.signup-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.signup-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + padding: 1rem 1.5rem; + background-image: linear-gradient(145deg, #0068b7, #003156); + color: #fff; + box-shadow: 0 8px 20px rgba(0, 25, 46, 0.12); +} + +.brand { + display: inline-flex; + align-items: center; + color: inherit; + text-decoration: none; +} + +.brand img { + height: 34px; + width: auto; +} + +.header-tagline { + margin: 0; + color: rgba(255, 255, 255, 0.9); + font-size: 1rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.footer-links a:hover { + text-decoration: underline; +} + +.signup-main { + width: min(1380px, calc(100% - 2rem)); + margin: 0 auto; + padding: 1.5rem 0 2rem; + display: grid; + grid-template-columns: minmax(280px, 420px) minmax(0, 1fr); + gap: 1.5rem; + align-items: start; + flex: 1 0 auto; +} + +.hero-panel, +.form-panel { + border: 1px solid rgba(112, 145, 176, 0.28); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 18px 48px rgba(9, 33, 58, 0.1); +} + +.hero-panel { + display: grid; + background: linear-gradient(180deg, #0068b7 0%, #003156 100%); + color: #fff; +} + +.hero-copy { + padding: 2rem 1.75rem 1.5rem; +} + +.eyebrow { + margin: 0 0 0.75rem; + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255, 255, 255, 0.72); +} + +.hero-copy h1 { + margin: 0; + font-size: clamp(2rem, 3vw, 3.2rem); + line-height: 1.02; +} + +.hero-text { + margin: 1rem 0 0; + font-size: 1.04rem; + line-height: 1.6; + color: rgba(255, 255, 255, 0.88); +} + +.hero-subtext { + margin: 1rem 0 0; + max-width: 40rem; + font-size: 0.96rem; + line-height: 1.6; + color: rgba(255, 255, 255, 0.78); +} + +.hero-notes { + display: grid; + gap: 0; + background: transparent; +} + +.note { + padding: 1.15rem 1.75rem; + background: transparent; + border-top: 1px solid rgba(255, 255, 255, 0.14); +} + +.note h2 { + margin: 0 0 0.4rem; + font-size: 1rem; +} + +.note p { + margin: 0; + line-height: 1.5; + color: rgba(255, 255, 255, 0.78); +} + +.form-panel { + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(4px); +} + +.form-heading { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0; + align-items: center; + padding: 1.4rem 1.5rem; + border-bottom: 1px solid #d9e4ef; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(240, 246, 252, 0.92)); +} + +.form-heading h2 { + margin: 0; + font-size: 1.7rem; +} + +.form-heading p { + margin: 0.35rem 0 0; + color: #486175; + line-height: 1.5; +} + +.signup-form { + padding: 1.4rem 1.5rem 1.6rem; +} + +.form-section + .form-section { + margin-top: 1.35rem; +} + +.form-section { + padding-top: 1.35rem; + border-top: 1px solid #dde7f0; +} + +.form-section:first-child { + padding-top: 0; + border-top: 0; +} + +.section-head { + margin-bottom: 0.9rem; +} + +.section-head h3 { + margin: 0; + font-size: 1.08rem; +} + +.section-head p { + margin: 0.28rem 0 0; + color: #5a7187; + line-height: 1.5; +} + +.choice-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; +} + +.choice-card { + display: grid; + gap: 0.32rem; + padding: 1rem 1rem 1rem 2.8rem; + position: relative; + border: 1px solid #bfd0e1; + border-radius: 8px; + background: #f8fbfe; + cursor: pointer; +} + +.choice-card input[type='radio'] { + position: absolute; + top: 1.08rem; + left: 1rem; + margin: 0; +} + +.choice-card.active { + border-color: #0068b7; + background: linear-gradient(180deg, #eef7ff 0%, #f8fbff 100%); + box-shadow: inset 0 0 0 1px rgba(0, 104, 183, 0.18); +} + +.choice-title { + font-weight: 700; +} + +.choice-copy { + color: #587085; + line-height: 1.45; + font-weight: 400; +} + +.field-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.9rem 1rem; +} + +.field-grid.single, +.field.field-wide { + grid-column: 1 / -1; +} + +.field { + display: grid; + gap: 0.38rem; + align-content: start; +} + +.field > span { + font-weight: 700; +} + +.field-label { + display: inline-flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; +} + +.field > small { + color: #637b90; + font-size: 0.82rem; +} + +.field.is-invalid > span, +.checkbox-row.is-invalid span { + color: #a12020; +} + +.field > small .info-chip { + margin-left: 0.3rem; +} + +.info-chip { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.15rem; + height: 1.15rem; + padding: 0; + border: 0; + border-radius: 999px; + background: #d9ebf8; + color: #005897; + font: inherit; + font-size: 0.76rem; + font-weight: 700; + line-height: 1; + cursor: help; +} + +.info-popover { + position: absolute; + left: 50%; + top: calc(100% + 0.55rem); + z-index: 5; + width: min(18rem, 80vw); + padding: 0.7rem 0.8rem; + border-radius: 8px; + background: #0f2740; + color: #fff; + font-size: 0.82rem; + font-weight: 400; + line-height: 1.5; + text-align: left; + box-shadow: 0 16px 32px rgba(9, 33, 58, 0.22); + opacity: 0; + pointer-events: none; + transform: translate(-50%, 0.2rem); + transition: opacity 120ms ease, transform 120ms ease; +} + +.info-popover::before { + content: ''; + position: absolute; + left: 50%; + top: -0.35rem; + width: 0.7rem; + height: 0.7rem; + background: #0f2740; + transform: translateX(-50%) rotate(45deg); +} + +.info-chip:hover .info-popover, +.info-chip:focus-visible .info-popover, +.info-chip:active .info-popover { + opacity: 1; + transform: translate(-50%, 0); +} + +.info-chip:focus-visible { + outline: 2px solid #0068b7; + outline-offset: 2px; +} + +.field input, +.field select { + width: 100%; + min-width: 0; + padding: 0.78rem 0.9rem; + border: 1px solid #9eb5ca; + border-radius: 8px; + background: #fff; + font: inherit; + color: #0f2740; + box-sizing: border-box; +} + +.field input:focus, +.field select:focus { + outline: 0; + border-color: #0068b7; + box-shadow: 0 0 0 3px rgba(0, 104, 183, 0.12); +} + +.field.is-invalid input, +.field.is-invalid select, +.checkbox-row.is-invalid input { + border-color: #d04848; + background: #fff7f7; +} + +.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); +} + +.strength-meter { + display: grid; + grid-template-columns: auto minmax(120px, 220px) auto; + align-items: center; + gap: 0.8rem; + margin-top: 0.9rem; +} + +.strength-meter span, +.strength-meter strong { + white-space: nowrap; +} + +.strength-meter progress { + width: 100%; + height: 10px; +} + +.captcha-box { + padding: 1rem; + border-radius: 8px; + background: #f6fafd; + border: 1px solid #d6e2ec; + overflow-x: auto; +} + +.captcha-host { + min-height: 78px; +} + +.captcha-host.is-hidden { + display: none; +} + +.captcha-host:empty::before { + content: 'Loading CAPTCHA...'; + display: inline-block; + color: #587085; + font-size: 0.92rem; +} + +.captcha-missing { + margin: 0; + padding: 0.8rem 0.9rem; + border: 1px solid #d6b087; + background: #fff3e4; + border-radius: 8px; + line-height: 1.5; +} + +.policy-block { + background: linear-gradient(180deg, #f8fbfe 0%, #f2f7fb 100%); + padding: 1.25rem; + border: 1px solid #dce6ef; + border-radius: 8px; +} + +.policy-block .section-head { + margin-bottom: 1rem; +} + +.news-choice { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.9rem 1.2rem; +} + +.news-choice > span { + font-weight: 700; +} + +.inline-options { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.inline-options label, +.checkbox-row { + display: flex; + align-items: flex-start; + gap: 0.65rem; +} + +.inline-options label { + font-weight: 600; +} + +.inline-options input, +.checkbox-row input { + margin: 0.15rem 0 0; + flex: 0 0 auto; +} + +.checkbox-row { + margin-top: 1rem; + line-height: 1.55; +} + +.form-error { + margin: 1.1rem 0 0; + padding: 0.85rem 1rem; + border-radius: 8px; + border: 1px solid #d79a9a; + background: #fff2f2; + color: #8b1d1d; + line-height: 1.5; + font-weight: 600; +} + +.field-error { + display: block; + color: #a12020; + font-size: 0.82rem; + line-height: 1.4; +} + +.captcha-error { + margin-top: 0.75rem; +} + +.form-actions { + display: flex; + align-items: center; + margin-top: 1.5rem; +} + +.submit { + border: 0; + border-radius: 4px; + min-width: 132px; + min-height: 42px; + background: #01579b; + color: #fff; + padding: 0 1.7rem; + font: inherit; + font-weight: 700; + font-size: 1rem; + cursor: pointer; + line-height: 36px; + box-shadow: + 0 3px 1px -2px rgba(0, 0, 0, 0.2), + 0 2px 2px 0 rgba(0, 0, 0, 0.14), + 0 1px 5px 0 rgba(0, 0, 0, 0.12); + transition: background-color 120ms ease, box-shadow 120ms ease; +} + +.submit:hover { + background: #0068b7; + box-shadow: + 0 4px 5px -2px rgba(0, 0, 0, 0.2), + 0 7px 10px 1px rgba(0, 0, 0, 0.14), + 0 2px 16px 1px rgba(0, 0, 0, 0.12); +} + +.submit.is-submitting { + opacity: 0.84; + cursor: wait; +} + +.honeypot { + position: absolute; + left: -10000px; + top: auto; + width: 1px; + height: 1px; + overflow: hidden; +} + +.signup-footer { + padding: 1.2rem 1.25rem 1.8rem; + background-image: linear-gradient(170deg, #014f89, #001e35); + color: rgba(255, 255, 255, 0.82); + text-align: center; +} + +.footer-links { + display: flex; + justify-content: center; + flex-wrap: wrap; + gap: 1rem 1.2rem; + margin-bottom: 0.75rem; +} + +.footer-links a { + color: rgba(255, 255, 255, 0.86); + text-decoration: none; +} + +.signup-footer p { + margin: 0; + font-size: 0.92rem; +} + +@media (max-width: 1100px) { + .signup-main { + grid-template-columns: 1fr; + } + + .hero-panel { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 900px) { + .signup-header { + padding: 0.9rem 1.15rem; + } + + .header-tagline { + font-size: 0.94rem; + } + + .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; + } + + .choice-grid, + .field-grid { + grid-template-columns: 1fr; + } + + .strength-meter { + grid-template-columns: 1fr; + justify-items: start; + } + + .captcha-box { + padding: 0.9rem; + } +} + +@media (max-width: 720px) { + .signup-header { + flex-direction: column; + align-items: flex-start; + gap: 0.55rem; + } + + .signup-main { + width: min(100% - 1rem, 1380px); + padding-top: 0.75rem; + padding-bottom: 1.2rem; + gap: 1rem; + } + + .hero-panel { + 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; + } + + .form-actions { + align-items: stretch; + } + + .submit { + width: 100%; + min-height: 46px; + } + + .info-popover { + left: 0; + transform: translate(0, 0.2rem); + } + + .info-popover::before { + left: 1rem; + transform: rotate(45deg); + } + + .info-chip:hover .info-popover, + .info-chip:focus-visible .info-popover, + .info-chip:active .info-popover { + transform: translate(0, 0); + } +} + +@media (max-width: 560px) { + .signup-header { + padding: 0.85rem 1rem 0.9rem; + } + + .brand img { + height: 28px; + } + + .header-tagline { + font-size: 0.88rem; + line-height: 1.4; + } + + .signup-footer p, + .footer-links a { + font-size: 0.9rem; + } + + .hero-copy h1 { + font-size: clamp(1.8rem, 9vw, 2.4rem); + 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; + font-size: 16px; + } + + .policy-block { + padding: 1rem; + } + + .inline-options { + gap: 0.8rem; + } + + .captcha-box { + padding: 0.75rem; + } + + .submit { + padding: 0 1.2rem; + } + + .signup-footer { + padding: 1rem 0.9rem 1.4rem; + } +} + +@media (max-width: 420px) { + .signup-main { + width: calc(100% - 0.75rem); + gap: 0.85rem; + } + + .hero-copy h1 { + 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; + } + + .signup-form { + padding-top: 1.1rem; + padding-bottom: 1.25rem; + } + + .note { + padding-top: 1rem; + padding-bottom: 1rem; + } + + .policy-block { + padding: 1rem; + } + + .footer-links { + gap: 0.75rem 1rem; + } +} diff --git a/src/app/signup/signup.component.spec.ts b/src/app/signup/signup.component.spec.ts new file mode 100644 index 000000000..c8155746e --- /dev/null +++ b/src/app/signup/signup.component.spec.ts @@ -0,0 +1,232 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2026 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule, NgForm, NgModel } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import { SignupComponent } from './signup.component'; + +describe('SignupComponent', () => { + let component: SignupComponent; + let fixture: ComponentFixture; + let httpMock: HttpTestingController; + let queryParamMap$: BehaviorSubject>; + + beforeEach(async () => { + queryParamMap$ = new BehaviorSubject(convertToParamMap({ runbox7: '1' })); + + await TestBed.configureTestingModule({ + imports: [FormsModule, HttpClientTestingModule], + declarations: [SignupComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + queryParamMap: queryParamMap$.asObservable(), + }, + }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SignupComponent); + component = fixture.componentInstance; + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + function stubCaptchaInitialization(loadResult = false): jasmine.Spy { + return spyOn(component, 'loadHCaptchaScript').and.resolveTo(loadResult); + } + + function flushLegacyMetadata(html?: string): void { + const request = httpMock.expectOne('/signup?legacy=1&runbox7=1'); + expect(request.request.method).toBe('GET'); + request.flush(html || ` + + +
+ +
+
+ + + `); + } + + async function initComponent(html?: string, loadResult = false): Promise { + stubCaptchaInitialization(loadResult); + fixture.detectChanges(); + flushLegacyMetadata(html); + await fixture.whenStable(); + fixture.detectChanges(); + } + + function getForm(): NgForm { + return fixture.debugElement.query(By.css('form')).injector.get(NgForm); + } + + function setInputValue(selector: string, value: string): HTMLInputElement { + const input = fixture.nativeElement.querySelector(selector) as HTMLInputElement; + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + return input; + } + + function setCheckboxValue(selector: string, checked: boolean): HTMLInputElement { + const input = fixture.nativeElement.querySelector(selector) as HTMLInputElement; + input.checked = checked; + input.dispatchEvent(new Event('change', { bubbles: true })); + return input; + } + + async function fillRequiredFields(): Promise { + setInputValue('input[name="first_name"]', 'Joe'); + setInputValue('input[name="last_name"]', 'Bond'); + setInputValue('input[name="user"]', 'joebond'); + setInputValue('input[name="password"]', 'S3cret!Pass'); + setInputValue('input[name="email_alternative"]', 'joe@example.com'); + setCheckboxValue('input[name="tos_accepted"]', true); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + } + + it('loads signup metadata from the legacy signup page', async () => { + await initComponent(); + + expect(component.signupAction).toBe('/mail/signup'); + expect(component.hCaptchaSiteKey).toBe('test-site-key'); + expect(component.runboxDomains).toEqual(['runbox.com', 'runbox.no', 'rbx.email']); + expect(component.runboxDomain).toBe('runbox.com'); + }); + + it('applies query parameters during initialization', async () => { + queryParamMap$.next(convertToParamMap({ + accountType: 'business', + domainType: 'user', + account_number: '12345', + runbox7: '7', + })); + + await initComponent(); + + expect(component.accountType).toBe('business'); + expect(component.domainType).toBe('user'); + expect(component.accountNumber).toBe('12345'); + expect(component.runbox7).toBe('7'); + }); + + it('keeps safe defaults if legacy metadata cannot be fetched', async () => { + stubCaptchaInitialization(false); + fixture.detectChanges(); + + const request = httpMock.expectOne('/signup?legacy=1&runbox7=1'); + request.flush('backend unavailable', { status: 500, statusText: 'Server Error' }); + + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.signupAction).toBe('/signup'); + expect(component.runboxDomains).toEqual(['runbox.com', 'runbox.no']); + expect(component.hCaptchaSiteKey).toBe(''); + expect(component.hCaptchaError).toContain('CAPTCHA is temporarily unavailable'); + }); + + it('shows field-level validation feedback after submit', async () => { + await initComponent(); + + const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement; + const focusSpy = spyOn(component, 'focusFirstInvalidField'); + + component.onSubmit(getForm(), formElement); + fixture.detectChanges(); + + const fieldErrors = Array.from(fixture.nativeElement.querySelectorAll('.field-error')) + .map((el: HTMLElement) => el.textContent?.trim()); + + expect(component.submitError).toBe('Complete the required fields before continuing.'); + expect(focusSpy).toHaveBeenCalledWith(formElement); + expect(fieldErrors).toContain('Enter your first name.'); + expect(fieldErrors).toContain('Enter your last name.'); + expect(fieldErrors).toContain('Choose a username for your mailbox.'); + expect(fieldErrors).toContain('Enter a password for your account.'); + expect(fieldErrors).toContain('Enter an email address for recovery and account notices.'); + expect(fieldErrors).toContain('You must accept the terms to create an account.'); + }); + + it('shows a field-level validation error for an invalid custom domain', async () => { + await initComponent(); + + component.domainType = 'user'; + fixture.detectChanges(); + + const domainControl = fixture.debugElement.query(By.css('input[name="userdomain"]')).injector.get(NgModel); + setInputValue('input[name="userdomain"]', 'invalid domain'); + fixture.detectChanges(); + + expect(component.showFieldError(domainControl, getForm())).toBeTrue(); + 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(); + await fillRequiredFields(); + + const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement; + const submitSpy = spyOn(formElement, 'submit'); + + component.onSubmit(getForm(), formElement); + fixture.detectChanges(); + + expect(component.showCaptchaValidationError).toBeTrue(); + expect(component.submitError).toBe('Complete the CAPTCHA verification before submitting.'); + expect(submitSpy).not.toHaveBeenCalled(); + }); + + it('submits the native form when validation and captcha both pass', async () => { + await initComponent(); + await fillRequiredFields(); + + const formElement = fixture.nativeElement.querySelector('form') as HTMLFormElement; + const captchaResponse = document.createElement('textarea'); + captchaResponse.name = 'h-captcha-response'; + captchaResponse.value = 'captcha-token'; + formElement.appendChild(captchaResponse); + const submitSpy = spyOn(formElement, 'submit'); + + component.onSubmit(getForm(), formElement); + + expect(component.submitInProgress).toBeTrue(); + expect(submitSpy).toHaveBeenCalled(); + expect(component.submitError).toBe(''); + expect(component.showCaptchaValidationError).toBeFalse(); + }); +}); diff --git a/src/app/signup/signup.component.ts b/src/app/signup/signup.component.ts new file mode 100644 index 000000000..67a31bcdd --- /dev/null +++ b/src/app/signup/signup.component.ts @@ -0,0 +1,319 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2026 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { AfterViewInit, Component, ElementRef, OnDestroy, OnInit, ViewChild } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { NgForm, NgModel } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; +import { environment } from '../../environments/environment'; + +type AccountType = 'person' | 'business'; +type DomainType = 'runbox' | 'user'; + +@Component({ + selector: 'app-signup', + templateUrl: './signup.component.html', + styleUrls: ['./signup.component.scss'], +}) +export class SignupComponent implements OnInit, AfterViewInit, OnDestroy { + @ViewChild('captchaContainer') captchaContainer?: ElementRef; + + accountType: AccountType = 'person'; + domainType: DomainType = 'runbox'; + + user = ''; + userDomain = ''; + runboxDomain = 'runbox.com'; + firstName = ''; + lastName = ''; + company = ''; + password = ''; + emailAlternative = ''; + phoneNumberCellular = ''; + referrer = ''; + sendNewsOffers = ''; + tosAccepted = false; + passwordStrength = 0; + accountNumber = ''; + runbox7 = '1'; + timezone = 'UTC'; + signupAction = '/signup'; + + runboxDomains = ['runbox.com', 'runbox.no']; + readonly referrers = [ + 'Advertisement', + 'Friend or family', + 'News media', + 'Review website', + 'Search engine', + 'Social media', + 'Other', + ]; + + hCaptchaSiteKey = environment.SIGNUP_HCAPTCHA_SITE_KEY || ''; + hCaptchaError = ''; + submitError = ''; + submitInProgress = false; + showCaptchaValidationError = false; + + private hCaptchaWidgetId: string | null = null; + private hCaptchaReady = false; + private nativeSubmitting = false; + private pendingCaptchaRender = false; + + constructor( + private route: ActivatedRoute, + private http: HttpClient, + ) {} + + ngOnInit(): void { + document.body.classList.add('signup-page'); + document.getElementById('main')?.classList.add('signup-page-shell'); + + const host = window?.location?.hostname || ''; + if (host.endsWith('.no')) { + this.runboxDomain = 'runbox.no'; + } + const resolvedTz = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (resolvedTz) { + this.timezone = resolvedTz; + } + this.route.queryParamMap.subscribe((params) => { + const accountType = params.get('accountType'); + if (accountType === 'business' || accountType === 'person') { + this.accountType = accountType; + } + const domainType = params.get('domainType'); + if (domainType === 'user' || domainType === 'runbox') { + this.domainType = domainType; + } + this.accountNumber = params.get('account_number') || params.get('accountNumber') || ''; + this.runbox7 = params.get('runbox7') || '1'; + }); + + void this.initializeHCaptcha(); + } + + ngAfterViewInit(): void { + if (this.pendingCaptchaRender) { + this.renderHCaptcha(); + } + } + + ngOnDestroy(): void { + document.body.classList.remove('signup-page'); + document.getElementById('main')?.classList.remove('signup-page-shell'); + } + + onPasswordChange(): void { + let score = 0; + if (this.password.length >= 8) { + score++; + } + if (/[a-z]/.test(this.password) && /[A-Z]/.test(this.password)) { + score++; + } + if (/\d/.test(this.password)) { + score++; + } + if (/[^A-Za-z0-9]/.test(this.password)) { + score++; + } + this.passwordStrength = score; + } + + onSubmit(form: NgForm, formElement: HTMLFormElement): void { + this.submitError = ''; + this.showCaptchaValidationError = false; + + if (this.nativeSubmitting) { + return; + } + + if (!form.valid) { + this.submitError = 'Complete the required fields before continuing.'; + this.focusFirstInvalidField(formElement); + return; + } + + if (!this.hCaptchaSiteKey) { + this.submitError = 'CAPTCHA is unavailable right now. Use the legacy signup page or try again shortly.'; + return; + } + + if (!this.hasCaptchaResponse(formElement)) { + this.showCaptchaValidationError = true; + this.submitError = 'Complete the CAPTCHA verification before submitting.'; + this.captchaContainer?.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + return; + } + + this.submitInProgress = true; + this.nativeSubmitting = true; + formElement.submit(); + } + + showFieldError(control?: NgModel | null, form?: NgForm): boolean { + if (!control) { + return false; + } + + return control.invalid && (control.touched || control.dirty || Boolean(form?.submitted)); + } + + private async initializeHCaptcha(): Promise { + await this.loadLegacySignupMetadata(); + + if (!this.hCaptchaSiteKey) { + this.hCaptchaError = 'CAPTCHA is temporarily unavailable. Please use the legacy signup page below.'; + return; + } + + const captchaLoaded = await this.loadHCaptchaScript(); + if (!captchaLoaded) { + return; + } + + this.hCaptchaReady = true; + this.renderHCaptcha(); + } + + private loadLegacySignupMetadata(): Promise { + return new Promise((resolve) => { + this.http + .get('/signup?legacy=1&runbox7=1', { responseType: 'text' }) + .subscribe({ + next: (html) => { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const legacyWidget = doc.querySelector('.h-captcha'); + const legacyForm = doc.querySelector('form[name="signup"], form[action*="signup"]'); + const legacyDomains = Array.from( + doc.querySelectorAll('select[name="runboxDomain"] option'), + ) + .map((option) => option.value.trim()) + .filter((domain, index, domains) => Boolean(domain) && domains.indexOf(domain) === index); + + this.hCaptchaSiteKey = legacyWidget?.getAttribute('data-sitekey') || this.hCaptchaSiteKey; + this.signupAction = legacyForm?.getAttribute('action') || this.signupAction; + if (legacyDomains.length > 0) { + this.runboxDomains = legacyDomains; + if (!this.runboxDomains.includes(this.runboxDomain)) { + this.runboxDomain = this.runboxDomains[0]; + } + } + resolve(); + }, + error: () => resolve(), + }); + }); + } + + private loadHCaptchaScript(): Promise { + return new Promise((resolve, reject) => { + const existingScript = document.querySelector('script[data-runbox-hcaptcha="1"]'); + if (existingScript) { + if ((window as WindowWithHCaptcha).hcaptcha) { + resolve(true); + return; + } + + const pollForHCaptcha = window.setInterval(() => { + if ((window as WindowWithHCaptcha).hcaptcha) { + window.clearInterval(pollForHCaptcha); + resolve(true); + } + }, 100); + + existingScript.addEventListener('load', () => { + window.clearInterval(pollForHCaptcha); + resolve(true); + }, { once: true }); + existingScript.addEventListener('error', () => reject(new Error('Failed to load hCaptcha.')), { once: true }); + return; + } + + const script = document.createElement('script'); + script.src = 'https://hcaptcha.com/1/api.js?render=explicit'; + 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 }); + document.body.appendChild(script); + }).catch((): boolean => { + this.hCaptchaError = 'CAPTCHA could not be loaded. Please use the legacy signup page below.'; + return false; + }); + } + + private renderHCaptcha(): void { + if (!this.hCaptchaReady || !this.hCaptchaSiteKey) { + return; + } + + const container = this.captchaContainer?.nativeElement; + const hcaptcha = (window as WindowWithHCaptcha).hcaptcha; + if (!container || !hcaptcha) { + this.pendingCaptchaRender = true; + window.setTimeout(() => this.renderHCaptcha(), 0); + return; + } + + this.pendingCaptchaRender = false; + + if (this.hCaptchaWidgetId !== null) { + return; + } + + this.hCaptchaWidgetId = hcaptcha.render(container, { + sitekey: this.hCaptchaSiteKey, + callback: () => { + this.hCaptchaError = ''; + this.submitError = ''; + }, + 'expired-callback': () => { + this.hCaptchaError = 'CAPTCHA expired. Complete it again before submitting.'; + }, + 'error-callback': () => { + this.hCaptchaError = 'CAPTCHA failed to load correctly. Try again or use the legacy signup page.'; + }, + }); + } + + private hasCaptchaResponse(formElement: HTMLFormElement): boolean { + const response = formElement.querySelector('textarea[name="h-captcha-response"], input[name="h-captcha-response"]'); + return Boolean(response?.value?.trim()); + } + + private focusFirstInvalidField(formElement: HTMLFormElement): void { + const firstInvalidField = formElement.querySelector( + 'input.ng-invalid, select.ng-invalid, textarea.ng-invalid, input:invalid, select:invalid, textarea:invalid', + ); + firstInvalidField?.focus(); + firstInvalidField?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +} + +interface HCaptchaApi { + render(container: string | HTMLElement, params: Record): string; +} + +interface WindowWithHCaptcha extends Window { + hcaptcha?: HCaptchaApi; +} diff --git a/src/app/signup/signup.module.ts b/src/app/signup/signup.module.ts new file mode 100644 index 000000000..53daff9ac --- /dev/null +++ b/src/app/signup/signup.module.ts @@ -0,0 +1,37 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2026 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { SignupComponent } from './signup.component'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + RouterModule.forChild([ + { path: '', component: SignupComponent }, + ]), + ], + declarations: [SignupComponent], +}) +export class SignupModule {} + diff --git a/src/build/gen-env.js b/src/build/gen-env.js index 8db3a977b..ffc3ceeea 100644 --- a/src/build/gen-env.js +++ b/src/build/gen-env.js @@ -13,6 +13,7 @@ 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) { @@ -37,6 +38,10 @@ function orNull () { return null } +function orEmptyString () { + return '' +} + fs.writeFileSync('src/environments/env.ts', ` /* eslint-disable @typescript-eslint/quotes */ diff --git a/src/styles.scss b/src/styles.scss index ed7a85188..553d71bee 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -404,6 +404,28 @@ mat-grid-tile.tableTitle { justify-content: center; } +body.signup-page { + height: auto; + min-height: 100%; + overflow-y: auto; + overscroll-behavior: auto; +} + +#main.signup-page-shell { + position: static; + width: 100%; + height: auto; + min-height: 100vh; + overflow: visible; + display: block; +} + +body.signup-page app-rmm { + display: block !important; + width: 100% !important; + min-height: 100vh; +} + /* Snackbar */ .mat-snack-bar-container { From 52e593fe8bc1f5626cc8af772c7393604e918f85 Mon Sep 17 00:00:00 2001 From: Geir Thomas Andersen Date: Tue, 5 May 2026 21:30:26 +0200 Subject: [PATCH 02/24] fix(signup): Add missing license header. --- e2e/cypress/integration/signup.ts | 19 +++++++++++++++++++ src/app/signup/signup.component.html | 19 +++++++++++++++++++ src/app/signup/signup.component.scss | 19 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/e2e/cypress/integration/signup.ts b/e2e/cypress/integration/signup.ts index 793b28050..5a49db8b9 100644 --- a/e2e/cypress/integration/signup.ts +++ b/e2e/cypress/integration/signup.ts @@ -1,3 +1,22 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2026 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + /// describe('Signup', () => { diff --git a/src/app/signup/signup.component.html b/src/app/signup/signup.component.html index 1de38c2b2..9c8e29594 100644 --- a/src/app/signup/signup.component.html +++ b/src/app/signup/signup.component.html @@ -1,3 +1,22 @@ + +