diff --git a/e2e/cypress/integration/signup.ts b/e2e/cypress/integration/signup.ts new file mode 100644 index 000000000..9282cb200 --- /dev/null +++ b/e2e/cypress/integration/signup.ts @@ -0,0 +1,100 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-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', () => { + beforeEach(() => { + cy.intercept('GET', '/signup?legacy=1&runbox7=1', { + statusCode: 200, + body: ` + + +
+ +
+
+ + + `, + headers: { + 'content-type': 'text/html', + }, + }).as('legacySignup'); + + cy.intercept('GET', 'https://hcaptcha.com/1/api.js?render=explicit', { + statusCode: 200, + body: 'window.hcaptcha = { render: function() { return "test-widget"; } };', + headers: { + 'content-type': 'application/javascript', + }, + }).as('hcaptchaScript'); + }); + + it('should render the Angular signup page in the local mock-backed environment', () => { + cy.visit('/signup?runbox7=1'); + cy.wait('@legacySignup'); + cy.wait('@hcaptchaScript'); + + cy.location('pathname').should('eq', '/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', '/mail/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', 3); + cy.get('select[name="runboxDomain"]').find('option').then((options) => { + const domains = Array.from(options).map((option) => option.value); + expect(domains).to.include('runbox.com'); + expect(domains).to.include('rbx.email'); + }); + 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('/signup?runbox7=1'); + cy.wait('@legacySignup'); + cy.wait('@hcaptchaScript'); + + 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/aliases/aliases.lister.spec.ts b/src/app/aliases/aliases.lister.spec.ts index 8a3ffb893..3fef53f94 100644 --- a/src/app/aliases/aliases.lister.spec.ts +++ b/src/app/aliases/aliases.lister.spec.ts @@ -17,10 +17,16 @@ // along with Runbox 7. If not, see . // ---------- END RUNBOX LICENSE ---------- -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; import { AliasesListerComponent } from './aliases.lister'; -import { MatLegacyDialogModule } from '@angular/material/legacy-dialog'; +import { MatLegacyButtonModule } from '@angular/material/legacy-button'; +import { MatLegacyCardModule } from '@angular/material/legacy-card'; +import { MatLegacyDialog as MatDialog, MatLegacyDialogModule } from '@angular/material/legacy-dialog'; +import { MatLegacyInputModule } from '@angular/material/legacy-input'; import { MatLegacySnackBarModule } from '@angular/material/legacy-snack-bar'; +import { MatLegacySelectModule } from '@angular/material/legacy-select'; +import { MatExpansionModule } from '@angular/material/expansion'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { RunboxWebmailAPI } from '../rmmapi/rbwebmail'; import { RMM } from '../rmm'; @@ -34,6 +40,7 @@ import { HttpClient } from '@angular/common/http'; describe('AliasesListerComponent', () => { let component: AliasesListerComponent; let fixture: ComponentFixture; + let dialog: MatDialog; const DEFAULT_EMAIL = 'a.kalou@shadowcat.co.uk'; const ALLOWED_DOMAINS = ['runbox.com', 'shadowcat.co.uk']; @@ -47,10 +54,16 @@ describe('AliasesListerComponent', () => { TestBed.configureTestingModule({ imports: [ CommonModule, + FormsModule, HttpClientTestingModule, + MatLegacyButtonModule, + MatLegacyCardModule, MatLegacyCommonModule, + MatLegacyInputModule, + MatLegacySelectModule, MatLegacySnackBarModule, MatLegacyDialogModule, + MatExpansionModule, NoopAnimationsModule, ], providers: [ @@ -94,6 +107,7 @@ describe('AliasesListerComponent', () => { }); fixture = TestBed.createComponent(AliasesListerComponent); component = fixture.componentInstance; + dialog = TestBed.inject(MatDialog); }); it('loads aliases through RMM', () => { @@ -126,7 +140,7 @@ describe('AliasesListerComponent', () => { expect(forwards.length).toBe(component.aliases.length, 'all forwards should be shown'); }); - it('sets the default email to the current users email', fakeAsync(() => { + it('sets the default email to the current users email', () => { expect(component.defaultEmail).toBe(DEFAULT_EMAIL); // spawn a modal @@ -143,22 +157,21 @@ describe('AliasesListerComponent', () => { // FIXME: doesn't work, value isn't set, probably because of ngModel // expect(forwardTo.value) // .toBe(DEFAULT_EMAIL, "Forward to should default to the user's email"); - })); + }); - it('dialog loads allowed domains', () => { + it('dialog loads allowed domains', async () => { // spawn a modal component.create(); fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); const modal = fixture.nativeElement.nextSibling.querySelector('app-aliases-edit'); expect(modal).toBeTruthy(); - const domain: HTMLSelectElement = - modal.querySelector('mat-select[name=\'domain\']'); - - ALLOWED_DOMAINS.forEach(allowed_domain => { - expect(domain.textContent).toContain(allowed_domain); - }); + const dialogRef = dialog.openDialogs[0]; + expect(dialogRef).toBeTruthy(); + expect(dialogRef.componentInstance.allowedDomains).toEqual(ALLOWED_DOMAINS); }); }); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index bced66edb..278d4b93f 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], @@ -136,7 +137,8 @@ const routes: Routes = [ { path: 'domainregistration', component: DomainRegisterRedirectComponent }, ] }, - { path: 'login', component: LoginComponent } + { path: 'login', component: LoginComponent }, + { path: '**', redirectTo: '' } ]; @NgModule({ @@ -213,4 +215,3 @@ export class AppModule { ); } } - diff --git a/src/app/common/preferences.service.spec.ts b/src/app/common/preferences.service.spec.ts index de5eac85d..49d0de4c6 100644 --- a/src/app/common/preferences.service.spec.ts +++ b/src/app/common/preferences.service.spec.ts @@ -19,12 +19,12 @@ import { PreferencesService, DefaultPrefGroups, PreferencesResult } from './preferences.service'; import { ScreenSize } from '../mobile-query.service'; -import { of, Subject, firstValueFrom, Observable } from 'rxjs'; +import { of, Subject, ReplaySubject, firstValueFrom, Observable } from 'rxjs'; import { take } from 'rxjs/operators'; class MockStorageService { private store = new Map(); - me: Observable = of({ uid: 42 }); + uid: Observable = of(42); get(key: string): Promise { return Promise.resolve(this.store.get(key)); @@ -241,9 +241,9 @@ describe('PreferencesService', () => { describe('Integration with StorageService uid AsyncSubject', () => { it('should handle operations that depend on uid being loaded', async () => { - const uidSubject = new Subject<{ uid: number }>(); + const uidSubject = new ReplaySubject(1); const uidStorage = { - me: uidSubject.asObservable(), + uid: uidSubject.asObservable(), get: (key: string) => mockStorage.get(key), set: (key: string, value: any) => mockStorage.set(key, value) } as any; @@ -254,7 +254,7 @@ describe('PreferencesService', () => { testService.set(DefaultPrefGroups.Desktop, 'uidTestKey', 'uidTestValue'); // Now emit uid - uidSubject.next({ uid: 999 }); + uidSubject.next(999); uidSubject.complete(); await new Promise(resolve => setTimeout(resolve, 50)); diff --git a/src/app/compose/compose.component.scss b/src/app/compose/compose.component.scss index 3b28d9c44..99540e515 100644 --- a/src/app/compose/compose.component.scss +++ b/src/app/compose/compose.component.scss @@ -1,6 +1,6 @@ -@import '../../../node_modules/@angular/material/theming'; +@use '@angular/material' as mat; -$rmm-default-accent: mat-palette($mat-blue, 100, 50, 200); +$rmm-default-accent: mat.define-palette(mat.$blue-palette, 100, 50, 200); #messageTextArea { box-sizing: border-box; diff --git a/src/app/help/help.component.spec.ts b/src/app/help/help.component.spec.ts index 78805b537..a8de86db8 100644 --- a/src/app/help/help.component.spec.ts +++ b/src/app/help/help.component.spec.ts @@ -18,6 +18,7 @@ // ---------- END RUNBOX LICENSE ---------- import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; import { HelpComponent } from './help.component'; @@ -27,7 +28,8 @@ describe('HelpComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ HelpComponent ] + declarations: [ HelpComponent ], + schemas: [ NO_ERRORS_SCHEMA ] }) .compileComponents(); }); 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/mailviewer/singlemailviewer.component.spec.ts b/src/app/mailviewer/singlemailviewer.component.spec.ts index d4bc44f93..13a60cda5 100644 --- a/src/app/mailviewer/singlemailviewer.component.spec.ts +++ b/src/app/mailviewer/singlemailviewer.component.spec.ts @@ -30,6 +30,7 @@ import { MatExpansionModule } from '@angular/material/expansion'; import { MatGridListModule } from '@angular/material/grid-list'; import { MatIcon, MatIconModule } from '@angular/material/icon'; import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { MatLegacyListModule as MatListModule } from '@angular/material/legacy-list'; import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu'; import { MatLegacyRadioModule as MatRadioModule } from '@angular/material/legacy-radio'; import { MatToolbarModule } from '@angular/material/toolbar'; @@ -105,6 +106,7 @@ describe('SingleMailViewerComponent', () => { ResizerModule, MatIconModule, MatIconTestingModule, + MatListModule, MatGridListModule, MatToolbarModule, MatTooltipModule, diff --git a/src/app/signup/signup.component.html b/src/app/signup/signup.component.html new file mode 100644 index 000000000..d8b5ee53e --- /dev/null +++ b/src/app/signup/signup.component.html @@ -0,0 +1,362 @@ + + + diff --git a/src/app/signup/signup.component.scss b/src/app/signup/signup.component.scss new file mode 100644 index 000000000..f4b2f6854 --- /dev/null +++ b/src/app/signup/signup.component.scss @@ -0,0 +1,879 @@ +@use '@angular/material' as mat; +@use '../../styles/rmm-theme-vars' as rmm; + +/* --------- BEGIN RUNBOX LICENSE --------- +Copyright (C) 2016-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 ---------- */ + +:host { + display: block; + min-height: 100vh; + color: rmm.$rmm-dark-background; + 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, + mat.get-color-from-palette(rmm.$rmm-default-primary), + rmm.$rmm-dark-background + ); + 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, + mat.get-color-from-palette(rmm.$rmm-default-primary) 0%, + rmm.$rmm-dark-background 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 rmm.$rmm-gray; + 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: mat.get-color-from-palette(rmm.$rmm-default-primary); + 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; +} + +.password-input-wrap { + position: relative; + display: block; +} + +.field-label { + display: inline-flex; + align-items: center; + width: fit-content; + max-width: 100%; +} + +.field-label-gap { + margin-top: 0.15rem; +} + +.field-label-text { + display: inline; +} + +.field-help-row { + display: inline-flex; + align-items: center; + gap: 0.35rem; + width: fit-content; + max-width: 100%; +} + +.domain-choice-field { + padding-top: 0.15rem; +} + +.info-trigger { + position: relative; + display: inline-flex; + align-items: center; + gap: 0.45rem; + width: fit-content; + max-width: 100%; +} + +.field > small { + color: #637b90; + font-size: 0.82rem; +} + +.field.is-invalid > span, +.checkbox-row.is-invalid span { + color: #a12020; +} + +.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: mat.get-color-from-palette(rmm.$rmm-default-highlight); + color: mat.get-color-from-palette(rmm.$rmm-default-primary); + font: inherit; + font-size: 0.76rem; + font-weight: 700; + line-height: 1; + cursor: help; +} + +.info-popover { + position: absolute; + left: 0; + top: calc(100% + 0.55rem); + z-index: 5; + width: 18rem; + max-width: min(18rem, calc(100vw - 5rem)); + padding: 0.7rem 0.8rem; + border-radius: 8px; + background: rmm.$rmm-dark-background; + 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(0, 0.2rem); + transition: opacity 120ms ease, transform 120ms ease; +} + +.info-trigger-end .info-popover { + left: auto; + right: 0; +} + +.info-popover::before { + content: ''; + position: absolute; + left: 0.95rem; + top: -0.35rem; + width: 0.7rem; + height: 0.7rem; + background: rmm.$rmm-dark-background; + transform: rotate(45deg); +} + +.info-trigger-end .info-popover::before { + left: auto; + right: 0.95rem; +} + +.info-trigger:hover .info-popover, +.info-trigger:focus-within .info-popover { + opacity: 1; + transform: translate(0, 0); +} + +.info-chip:focus-visible { + outline: 2px solid mat.get-color-from-palette(rmm.$rmm-default-primary); + outline-offset: 2px; +} + +.field input, +.field select { + width: 100%; + min-width: 0; + padding: 0.78rem 0.9rem; + border: 1px solid rmm.$rmm-default-dark-gray; + border-radius: 8px; + background: #fff; + font: inherit; + color: rmm.$rmm-dark-background; + box-sizing: border-box; +} + +.password-input-wrap input { + padding-right: 5.5rem; +} + +.password-toggle { + position: absolute; + top: 50%; + right: 0.7rem; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 3.4rem; + padding: 0.2rem 0.4rem; + border: 0; + background: transparent; + color: mat.get-color-from-palette(rmm.$rmm-default-primary); + font: inherit; + font-size: 0.84rem; + font-weight: 700; + line-height: 1; + cursor: pointer; +} + +.password-toggle:focus-visible { + outline: 2px solid mat.get-color-from-palette(rmm.$rmm-default-primary); + outline-offset: 2px; + border-radius: 4px; +} + +.field input:focus, +.field select:focus { + outline: 0; + border-color: mat.get-color-from-palette(rmm.$rmm-default-primary); + 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: mat.get-color-from-palette(rmm.$rmm-default-primary); + 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: mat.get-color-from-palette(rmm.$rmm-default-foreground); + 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 { + width: 16rem; + max-width: min(16rem, calc(100vw - 3rem)); + } + + .info-popover::before { + left: 0.9rem; + } + + .info-trigger-end .info-popover::before { + left: auto; + right: 0.9rem; + } +} + +@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..e4ef27e60 --- /dev/null +++ b/src/app/signup/signup.component.spec.ts @@ -0,0 +1,237 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-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'); + + formElement.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await fixture.whenStable(); + 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(); + await fixture.whenStable(); + fixture.detectChanges(); + + const domainControl = fixture.debugElement.query(By.css('input[name="userdomain"]')).injector.get(NgModel); + setInputValue('input[name="userdomain"]', 'invalid domain'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(component.showUserDomainError(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..8cbb75795 --- /dev/null +++ b/src/app/signup/signup.component.ts @@ -0,0 +1,377 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-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'; +type SignupEnvironment = typeof environment & { SIGNUP_HCAPTCHA_SITE_KEY?: string }; + +@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 = ''; + showPassword = false; + 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 as SignupEnvironment).SIGNUP_HCAPTCHA_SITE_KEY || ''; + hCaptchaError = ''; + submitError = ''; + submitInProgress = false; + showCaptchaValidationError = false; + + private hCaptchaWidgetId: string | null = null; + private hCaptchaReady = false; + private nativeSubmitting = false; + private pendingCaptchaRender = false; + 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( + 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; + } + + onUserDomainBlur(): void { + this.userDomain = this.userDomain.trim().toLowerCase(); + } + + 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.domainType === 'user' && !this.isUserDomainValid()) { + this.submitError = 'Enter a valid domain such as example.com.'; + this.focusFirstInvalidField(formElement); + return; + } + + if (this.passwordStrength < 3) { + this.submitError = 'Choose a stronger password before continuing.'; + const passwordInput = formElement.querySelector('input[name="password"]'); + passwordInput?.focus(); + 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)); + } + + showUserDomainError(control?: NgModel | null, form?: NgForm): boolean { + if (!control) { + return false; + } + + const shouldShow = control.touched || control.dirty || Boolean(form?.submitted); + return shouldShow && (!this.userDomain.trim() || !this.isUserDomainValid()); + } + + showPasswordError(control?: NgModel | null, form?: NgForm): boolean { + if (!control) { + return false; + } + + const shouldShow = control.touched || control.dirty || Boolean(form?.submitted); + return shouldShow && (control.invalid || this.passwordStrength < 3); + } + + passwordErrorMessage(control?: NgModel | null): string { + if (control?.errors?.['required']) { + return 'Enter a password for your account.'; + } + + return 'Use a stronger password with a strength of at least 3/4.'; + } + + userDomainErrorMessage(control?: NgModel | null): string { + if (!this.userDomain.trim() || control?.errors?.['required']) { + return 'Enter your domain name.'; + } + + return 'Enter a valid domain such as example.com.'; + } + + private isUserDomainValid(): boolean { + return this.customDomainPattern.test(this.userDomain.trim()); + } + + 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..925d2b950 --- /dev/null +++ b/src/app/signup/signup.module.ts @@ -0,0 +1,36 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-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/app/start/startdesk.component.scss b/src/app/start/startdesk.component.scss index fa1e78299..10716c024 100644 --- a/src/app/start/startdesk.component.scss +++ b/src/app/start/startdesk.component.scss @@ -1,5 +1,5 @@ -@import '../../../node_modules/@angular/material/theming'; -$rmm-default-highlight: mat-palette($mat-light-blue, 100, 50, 200); +@use '@angular/material' as mat; +$rmm-default-highlight: mat.define-palette(mat.$light-blue-palette, 100, 50, 200); #startdesk { position: absolute; diff --git a/src/app/websocketsearch/websocketsearch.service.spec.ts b/src/app/websocketsearch/websocketsearch.service.spec.ts index 4d718eaa3..00d1188b9 100644 --- a/src/app/websocketsearch/websocketsearch.service.spec.ts +++ b/src/app/websocketsearch/websocketsearch.service.spec.ts @@ -262,6 +262,7 @@ describe('WebSocketSearchService', () => { describe('Error handling', () => { it('should handle WebSocket errors', (done) => { + const consoleLogSpy = spyOn(console, 'log'); service.open(); mockWebSocket.simulateOpen(); @@ -271,6 +272,7 @@ describe('WebSocketSearchService', () => { mockWebSocket.simulateError(new Error('WebSocket error')); setTimeout(() => { + expect(consoleLogSpy).toHaveBeenCalledWith('websocket error', jasmine.any(Error)); expect(service.searchInProgress).toBe(false); done(); }, 10); diff --git a/src/app/welcome/welcomedesk.component.spec.ts b/src/app/welcome/welcomedesk.component.spec.ts index c99110caa..d2c2eca62 100644 --- a/src/app/welcome/welcomedesk.component.spec.ts +++ b/src/app/welcome/welcomedesk.component.spec.ts @@ -18,9 +18,13 @@ // ---------- END RUNBOX LICENSE ---------- import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatIconModule } from '@angular/material/icon'; +import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { MatLegacyCardModule as MatCardModule } from '@angular/material/legacy-card'; import { RunboxWebmailAPI } from '../rmmapi/rbwebmail'; import { WelcomeDeskComponent } from './welcomedesk.component'; -import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { RouterTestingModule } from '@angular/router/testing'; import { of } from 'rxjs'; describe('WelcomeDeskComponent', () => { @@ -31,6 +35,10 @@ describe('WelcomeDeskComponent', () => { await TestBed.configureTestingModule({ declarations: [ WelcomeDeskComponent ], imports: [ + MatCardModule, + MatIconModule, + MatIconTestingModule, + RouterTestingModule, ], providers: [ 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..3a797937c 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -2,6 +2,7 @@ @import '../iconfont/material-icons.css'; @import "../node_modules/angular-calendar/css/angular-calendar.css"; @import "@danielmoncada/angular-datetime-picker/assets/style/picker.min.css"; +@import './styles/rmm-theme-vars'; // Include non-theme styles for core. // TODO(v15): As of v15 mat.legacy-core no longer includes default typography styles. @@ -15,31 +16,6 @@ @include mat.all-legacy-component-typographies(); @include mat.legacy-core(); -// GTA 13.05.2017: From https://material.angular.io/guide/theming and https://www.materialui.co/colors: - -$rmm-default-primary: mat.define-palette(mat.$light-blue-palette, 900, A400, A700); -$rmm-default-accent: mat.define-palette(mat.$blue-palette, 200, 100, 400); -$rmm-default-warn: mat.define-palette(mat.$orange-palette, 500, A400, A700); -$rmm-default-caution: mat.define-palette(mat.$deep-orange-palette, 500, A400, A700); -$rmm-default-error: mat.define-palette(mat.$red-palette, 500, A400, A700); -$rmm-default-foreground: mat.define-palette(mat.$light-blue-palette, 400, 200, 600); -$rmm-default-highlight: mat.define-palette(mat.$blue-palette, 100, 50, 200); -$rmm-default-background: mat.define-palette(mat.$light-blue-palette, 900, A400, A700); - -$rmm-dark-background: #011b40; -$rmm-dark-background-03: rgba(1,27,64,0.3); -$rmm-darker-background: #01001c; -$rmm-gray: #dddddd; -$rmm-gray-light: #eeeeee; -$rmm-gray-lighter: #f3f3f3; - -$rmm-default-theme: mat.define-light-theme($rmm-default-primary, $rmm-default-accent, $rmm-default-warn); - -$rmm-default-lighter-gray: #eeeeee; -$rmm-default-light-gray: #f4f4f4; -$rmm-default-dark-gray: #949494; -$rmm-default-black: #444444; - @include mat.all-legacy-component-themes($rmm-default-theme); // GTA 13.06.2018: Load custom fonts @@ -404,6 +380,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 { diff --git a/src/styles/_rmm-theme-vars.scss b/src/styles/_rmm-theme-vars.scss new file mode 100644 index 000000000..37f0ac6c5 --- /dev/null +++ b/src/styles/_rmm-theme-vars.scss @@ -0,0 +1,43 @@ +/* --------- BEGIN RUNBOX LICENSE --------- +Copyright (C) 2016-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 ---------- */ + +@use '@angular/material' as mat; + +$rmm-default-primary: mat.define-palette(mat.$light-blue-palette, 900, A400, A700); +$rmm-default-accent: mat.define-palette(mat.$blue-palette, 200, 100, 400); +$rmm-default-warn: mat.define-palette(mat.$orange-palette, 500, A400, A700); +$rmm-default-caution: mat.define-palette(mat.$deep-orange-palette, 500, A400, A700); +$rmm-default-error: mat.define-palette(mat.$red-palette, 500, A400, A700); +$rmm-default-foreground: mat.define-palette(mat.$light-blue-palette, 400, 200, 600); +$rmm-default-highlight: mat.define-palette(mat.$blue-palette, 100, 50, 200); +$rmm-default-background: mat.define-palette(mat.$light-blue-palette, 900, A400, A700); + +$rmm-dark-background: #011b40; +$rmm-dark-background-03: rgba(1,27,64,0.3); +$rmm-darker-background: #01001c; +$rmm-gray: #dddddd; +$rmm-gray-light: #eeeeee; +$rmm-gray-lighter: #f3f3f3; + +$rmm-default-theme: mat.define-light-theme($rmm-default-primary, $rmm-default-accent, $rmm-default-warn); + +$rmm-default-lighter-gray: #eeeeee; +$rmm-default-light-gray: #f4f4f4; +$rmm-default-dark-gray: #949494; +$rmm-default-black: #444444;