diff --git a/e2e/cypress/integration/calendar-timezone.ts b/e2e/cypress/integration/calendar-timezone.ts new file mode 100644 index 000000000..f8576f6d4 --- /dev/null +++ b/e2e/cypress/integration/calendar-timezone.ts @@ -0,0 +1,408 @@ +/// +// --------- 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 { futureDateStr, buildIcs, makeVevent, osloVtimezone, londonVtimezone, expectedDisplayTime, dateStrMonth, dateStrYear, dateStrDay } from '../support/ics-helpers'; + +describe('Calendar timezone handling', () => { + beforeEach(() => { + cy.request('/rest/e2e/resetCalendarEvents'); + cy.intercept('GET', '/rest/v1/calendar/events_raw').as('eventsLoad'); + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1).and('contain', 'Mock Calendar'); + // Wait for the initial sync's reloadEvents() to complete so that no + // in-flight GET can race with event creation via the dialog. + cy.wait('@eventsLoad'); + }); + + function selectIcs(ics: string) { + cy.get('input[type=file]').selectFile({ + contents: Cypress.Buffer.from(ics), + fileName: 'test.ics', + mimeType: 'text/calendar', + }, { force: true }); + } + + function doImport() { + cy.get('mat-select').click(); + cy.contains('mat-option', 'Mock Calendar').click(); + cy.contains('button', 'Import events').click(); + cy.get('simple-snack-bar').should('contain', 'events imported'); + } + + // Navigate the calendar to the month containing the event so that + // month-view assertions work regardless of when the test runs. + function navigateToEventMonth(dateStr: string) { + const targetMonth = dateStrMonth(dateStr); + const targetYear = dateStrYear(dateStr); + cy.then(() => { + const now = new Date(); + const currentMonth = now.getMonth() + 1; + const currentYear = now.getFullYear(); + const monthsAhead = (targetYear - currentYear) * 12 + (targetMonth - currentMonth); + const buttonId = monthsAhead >= 0 ? '#nextPeriodButton' : '#previousPeriodButton'; + const clicks = Math.abs(monthsAhead); + for (let i = 0; i < clicks; i++) { + cy.get(buttonId).click(); + } + if (clicks > 0) { + cy.wait(500); + } + }); + } + + it('should display floating time event in import preview', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`${dateStr}T140000`, `${dateStr}T150000`, 'Floating Time Meeting', 'tztest-floating-001', + ['LOCATION:Oslo Office']), + ])); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'Floating Time Meeting') + .and('contain', 'Oslo Office'); + }); + + it('should display UTC event in import preview', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`${dateStr}T130000Z`, `${dateStr}T140000Z`, 'UTC Meeting', 'tztest-utc-001'), + ])); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'UTC Meeting'); + }); + + it('should display TZID event in import preview', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`TZID=Europe/Oslo:${dateStr}T140000`, `TZID=Europe/Oslo:${dateStr}T150000`, + 'TZID Oslo Meeting', 'tztest-tzid-001'), + ])); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'TZID Oslo Meeting'); + }); + + it('should display all-day event in import preview', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`VALUE=DATE:${dateStr}`, `VALUE=DATE:${futureDateStr(16)}`, + 'All Day Event', 'tztest-allday-001'), + ])); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'All Day Event'); + }); + + it('should display recurring event in import preview', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`${dateStr}T100000`, `${dateStr}T110000`, 'Weekly Standup', 'tztest-recurring-001', + ['RRULE:FREQ=WEEKLY;COUNT=4']), + ])); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'Weekly Standup'); + }); + + it('should display citadel-path TZID event in import preview', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent( + `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T140000`, + `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T150000`, + 'Citadel Path Event', 'tztest-citadel-001'), + ], osloVtimezone)); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'Citadel Path Event'); + }); + + it('should display multiple events from multi-event ICS', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`${dateStr}T100000`, `${dateStr}T110000`, 'Floating Event', 'tztest-multi-floating'), + makeVevent(`${dateStr}T120000Z`, `${dateStr}T130000Z`, 'UTC Event', 'tztest-multi-utc'), + makeVevent(`TZID=Europe/Oslo:${dateStr}T140000`, `TZID=Europe/Oslo:${dateStr}T150000`, + 'TZID Event', 'tztest-multi-tzid'), + ])); + cy.get('app-calendar-event-card').should('have.length', 3); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'Floating Event') + .and('contain', 'UTC Event') + .and('contain', 'TZID Event'); + }); + + it('should fully import a floating time event', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`${dateStr}T140000`, `${dateStr}T150000`, 'Imported Floating Event', 'tztest-import-floating'), + ])); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'Imported Floating Event'); + doImport(); + }); + + it('should fully import a UTC event', () => { + const dateStr = futureDateStr(15); + selectIcs(buildIcs([ + makeVevent(`${dateStr}T130000Z`, `${dateStr}T140000Z`, 'Imported UTC Event', 'tztest-import-utc'), + ])); + cy.get('app-calendar-event-card .upcomingEventCard') + .should('contain', 'Imported UTC Event'); + doImport(); + }); + + // --- Timezone bug reproduction tests --- + + it('should display London TZID event at correct hour for London account', () => { + cy.request('/rest/e2e/setTimezone_Europe/London'); + const dateStr = futureDateStr(3); + + // Event at 12:00 London (BST = UTC+1 in summer) = 11:00 UTC + const ics = buildIcs([ + makeVevent( + `TZID=Europe/London:${dateStr}T120000`, + `TZID=Europe/London:${dateStr}T130000`, + 'London Noon Meeting', 'tz-london-001'), + ], londonVtimezone); + + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { id: 'mock cal/tz-london-001', ical: ics, calendar: 'mock cal' }, + }); + + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + navigateToEventMonth(dateStr); + cy.get('button.calendarMonthDayEvent').should('contain', 'London Noon Meeting'); + + const expected = expectedDisplayTime(dateStr, 11, 0); + cy.get('button.calendarMonthDayEvent') + .contains('London Noon Meeting') + .invoke('text') + .should('match', new RegExp(expected)); + + cy.request('/rest/e2e/resetTimezone'); + }); + + it('should display floating time event at correct hour matching account tz', () => { + cy.request('/rest/e2e/setTimezone_Europe/London'); + const dateStr = futureDateStr(3); + + // Floating time 14:00 (no TZID) — interpreted as London BST = 13:00 UTC + const ics = buildIcs([ + makeVevent(`${dateStr}T140000`, `${dateStr}T150000`, 'Floating 2pm Meeting', 'tz-floating-001'), + ]); + + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { id: 'mock cal/tz-floating-001', ical: ics, calendar: 'mock cal' }, + }); + + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + navigateToEventMonth(dateStr); + cy.get('button.calendarMonthDayEvent').should('contain', 'Floating 2pm Meeting'); + + const expected = expectedDisplayTime(dateStr, 13, 0); + cy.get('button.calendarMonthDayEvent') + .contains('Floating 2pm Meeting') + .invoke('text') + .should('match', new RegExp(expected)); + + cy.request('/rest/e2e/resetTimezone'); + }); + + it('should show different displayed hour after timezone change', () => { + const dateStr = futureDateStr(3); + + // Floating time 12:00. Account timezone determines interpretation: + // Oslo CEST = 12:00 local = 10:00 UTC, London BST = 12:00 local = 11:00 UTC. + // These produce different local hours in any browser timezone. + const ics = buildIcs([ + makeVevent(`${dateStr}T120000`, `${dateStr}T130000`, 'Floating Noon', 'tz-changeme'), + ]); + + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { id: 'mock cal/tz-changeme', ical: ics, calendar: 'mock cal' }, + }); + + // View with default Oslo timezone + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + navigateToEventMonth(dateStr); + + cy.get('button.calendarMonthDayEvent') + .contains('Floating Noon') + .invoke('text') + .then(osloText => { + const osloHour = osloText.match(/(\d+):\d+/); + + // Switch to London timezone + cy.request('/rest/e2e/setTimezone_Europe/London'); + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + navigateToEventMonth(dateStr); + + cy.get('button.calendarMonthDayEvent') + .contains('Floating Noon') + .invoke('text') + .then(londonText => { + const londonHour = londonText.match(/(\d+):\d+/); + expect(londonHour?.[1]).to.not.equal(osloHour?.[1], + 'Displayed hour should change after timezone change'); + }); + }); + + cy.request('/rest/e2e/resetTimezone'); + }); + + it('should display 12pm Oslo event at correct hour in month view', () => { + const dateStr = futureDateStr(3); + + // Event at 12:00 Oslo (CEST = UTC+2 in summer) = 10:00 UTC + const ics = buildIcs([ + makeVevent( + `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T120000`, + `TZID=/citadel.org/20210210_1/Europe/Oslo:${dateStr}T130000`, + 'Oslo Noon Event', 'tz-oslo-noon'), + ], osloVtimezone); + + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { id: 'mock cal/tz-oslo-noon', ical: ics, calendar: 'mock cal' }, + }); + + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + navigateToEventMonth(dateStr); + cy.get('button.calendarMonthDayEvent').should('contain', 'Oslo Noon Event'); + + const expected = expectedDisplayTime(dateStr, 10, 0); + cy.get('button.calendarMonthDayEvent') + .contains('Oslo Noon Event') + .invoke('text') + .should('match', new RegExp(expected)); + }); + + it('should display all-day event on correct day in month view', () => { + const dateStr = futureDateStr(3); + const nextDay = futureDateStr(4); + + const ics = buildIcs([ + makeVevent(`VALUE=DATE:${dateStr}`, `VALUE=DATE:${nextDay}`, 'All-Day Event', 'tz-allday'), + ]); + + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { id: 'mock cal/tz-allday', ical: ics, calendar: 'mock cal' }, + }); + + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + navigateToEventMonth(dateStr); + cy.get('button.calendarMonthDayEvent').should('contain', 'All-Day Event'); + + const targetDay = dateStrDay(dateStr); + // Avoids ambiguity when the month view shows two cells with the same day number + cy.get('button.calendarMonthDayEvent') + .contains('All-Day Event') + .parents('.cal-cell-top') + .find('.cal-day-number') + .should(dayEl => { + expect(parseInt(dayEl.text().trim(), 10)).to.equal(targetDay); + }); + }); + + it('should create all-day event via dialog on correct day', () => { + // Switch to month view and navigate to next month for a clean view + cy.contains('button.calendarToolbarButton', 'Month').click(); + cy.get('#nextPeriodButton').click(); + cy.wait(500); + + // Pick the 15th of next month — find its add-event button + cy.get('.cal-cell-top').then(cells => { + const targetDay = 15; + let targetCell: JQuery = null; + cells.each((i, el) => { + const dayNum = parseInt(Cypress.$(el).find('.cal-day-number').text().trim(), 10); + // First occurrence of targetDay in the grid belongs to the displayed month + if (dayNum === targetDay && !targetCell) { + targetCell = Cypress.$(el); + } + }); + cy.wrap(targetCell).find('.add-new-event').invoke('css', 'visibility', 'visible'); + cy.wrap(targetCell).find('.add-new-event button').should('be.visible').click(); + }); + + cy.get('mat-dialog-container').within(() => { + // Use invoke + trigger instead of type() to avoid XHR-triggered + // change detection overwriting the value mid-typing. + cy.get('input[matInput]').first() + .invoke('val', 'Created All-Day Event') + .trigger('input') + .should('have.value', 'Created All-Day Event'); + cy.get('mat-checkbox').contains('All-day event').click(); + cy.get('#eventSubmitButton').click(); + }); + + cy.get('button.calendarMonthDayEvent') + .should('contain', 'Created All-Day Event'); + + cy.get('button.calendarMonthDayEvent') + .contains('Created All-Day Event') + .parents('.cal-cell-top') + .find('.cal-day-number') + .should(dayEl => { + expect(parseInt(dayEl.text().trim(), 10)).to.equal(15); + }); + }); + + it('should display multi-day all-day event starting on correct day', () => { + const day1 = futureDateStr(3); + const day4 = futureDateStr(6); + + const ics = buildIcs([ + makeVevent(`VALUE=DATE:${day1}`, `VALUE=DATE:${day4}`, 'Multi-Day Event', 'tz-multiday'), + ]); + + cy.request({ + method: 'POST', + url: '/rest/e2e/addEvent', + body: { id: 'mock cal/tz-multiday', ical: ics, calendar: 'mock cal' }, + }); + + cy.visit('/calendar'); + cy.get('.calendarListItem').should('have.length', 1); + navigateToEventMonth(day1); + cy.get('button.calendarMonthDayEvent').should('contain', 'Multi-Day Event'); + + const startDay = dateStrDay(day1); + // Find the event first, then verify its parent cell's day number. + cy.get('button.calendarMonthDayEvent') + .contains('Multi-Day Event') + .parents('.cal-cell-top') + .find('.cal-day-number') + .should(dayEl => { + expect(parseInt(dayEl.text().trim(), 10)).to.equal(startDay); + }); + }); +}); 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/e2e/cypress/plugins/index.js b/e2e/cypress/plugins/index.js index c1f7270f1..92e2bc043 100644 --- a/e2e/cypress/plugins/index.js +++ b/e2e/cypress/plugins/index.js @@ -19,4 +19,16 @@ module.exports = (on, config) => { } on('file:preprocessor', wp(options)) require('cypress-terminal-report/src/installLogsPrinter')(on); + + // Force the browser into a specific timezone via TZ env var. + // Defaults to Europe/Oslo (CET/CEST) to expose bugs where + // account tz != browser tz. Override with env var CYPRESS_TZ. + on('before:browser:launch', (browser, launchOptions) => { + const tz = config.env.TZ || 'Europe/Oslo'; + launchOptions.env = { + ...(launchOptions.env || {}), + TZ: tz, + }; + return launchOptions; + }); } diff --git a/e2e/cypress/support/ics-helpers.ts b/e2e/cypress/support/ics-helpers.ts new file mode 100644 index 000000000..ea0c60953 --- /dev/null +++ b/e2e/cypress/support/ics-helpers.ts @@ -0,0 +1,117 @@ +// --------- 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 ---------- + +export function futureDateStr(daysFromNow: number): string { + const d = new Date(); + d.setDate(d.getDate() + daysFromNow); + return d.toISOString().replace(/-/g, '').replace(/T.*/, ''); +} + +export function dateStrMonth(dateStr: string): number { + return parseInt(dateStr.substring(4, 6), 10); +} + +export function dateStrYear(dateStr: string): number { + return parseInt(dateStr.substring(0, 4), 10); +} + +export function dateStrDay(dateStr: string): number { + return parseInt(dateStr.substring(6, 8), 10); +} + +export function buildIcs(veventBlocks: string[], vtimezone?: string): string { + const parts = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Runbox//E2E Test//EN', + ]; + if (vtimezone) { parts.push(vtimezone); } + parts.push(...veventBlocks, 'END:VCALENDAR'); + return parts.join('\r\n'); +} + +export function dtLine(prefix: string, value: string): string { + return value.includes('=') ? `${prefix};${value}` : `${prefix}:${value}`; +} + +export function makeVevent(dtstart: string, dtend: string, summary: string, uid: string, extra: string[] = []): string { + return [ + 'BEGIN:VEVENT', + dtLine('DTSTART', dtstart), + dtLine('DTEND', dtend), + `SUMMARY:${summary}`, + `UID:${uid}`, + 'DTSTAMP:20260101T000000Z', + ...extra, + 'END:VEVENT', + ].join('\r\n'); +} + +export const osloVtimezone = [ + 'BEGIN:VTIMEZONE', + 'TZID:/citadel.org/20210210_1/Europe/Oslo', + 'LAST-MODIFIED:20210210T123706Z', + 'X-LIC-LOCATION:Europe/Oslo', + 'BEGIN:STANDARD', + 'TZNAME:CET', + 'TZOFFSETFROM:+0200', + 'TZOFFSETTO:+0100', + 'DTSTART:19961027T030000', + 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', + 'END:STANDARD', + 'BEGIN:DAYLIGHT', + 'TZNAME:CEST', + 'TZOFFSETFROM:+0100', + 'TZOFFSETTO:+0200', + 'DTSTART:19810329T020000', + 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', + 'END:DAYLIGHT', + 'END:VTIMEZONE', +].join('\r\n'); + +/** + * Compute what the Angular date pipe would display for a given UTC time + * in the browser's current timezone. Returns 'HH:mm' format. + * dateStr: 'YYYYMMDD' format date string. + */ +export function expectedDisplayTime(dateStr: string, utcHour: number, utcMinute: number = 0): string { + const date = new Date(Date.UTC(dateStrYear(dateStr), dateStrMonth(dateStr) - 1, dateStrDay(dateStr), utcHour, utcMinute, 0)); + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; +} + +export const londonVtimezone = [ + 'BEGIN:VTIMEZONE', + 'TZID:Europe/London', + 'X-LIC-LOCATION:Europe/London', + 'BEGIN:STANDARD', + 'TZNAME:GMT', + 'TZOFFSETFROM:+0100', + 'TZOFFSETTO:+0000', + 'DTSTART:19701025T020000', + 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU', + 'END:STANDARD', + 'BEGIN:DAYLIGHT', + 'TZNAME:BST', + 'TZOFFSETFROM:+0000', + 'TZOFFSETTO:+0100', + 'DTSTART:19810329T010000', + 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU', + 'END:DAYLIGHT', + 'END:VTIMEZONE', +].join('\r\n'); diff --git a/e2e/mockserver/mockserver.ts b/e2e/mockserver/mockserver.ts index 36ffae31f..422fd336b 100644 --- a/e2e/mockserver/mockserver.ts +++ b/e2e/mockserver/mockserver.ts @@ -22,23 +22,24 @@ import { createWriteStream } from 'fs'; import { mail_message_obj } from './emailresponse'; const logger = createWriteStream('mockserver.log'); -function log(line) { +function log(line: string) { logger.write(line + '\n'); } export class MockServer { - server: Server; + server!: Server; loggedIn = true; challenge2fa = false; port = 15000; - calendars = [ - { id: 'mock cal', displayname: 'Mock Calendar' }, + calendars: { id: string; displayname: string; syncToken: string }[] = [ + { id: 'mock cal', displayname: 'Mock Calendar', syncToken: 'e2e-sync-1' }, ]; - events = []; + events: { id: string; ical: any; calendar: string }[] = []; + accountTimezone = 'Europe/Oslo'; folders = [ { @@ -203,12 +204,37 @@ RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU END:STANDARD END:VTIMEZONE END:VCALENDAR +`; + + vtimezone_london = +`BEGIN:VCALENDAR +PRODID:-//runbox//NONSGML Runbox calendar//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:Europe/London +X-LIC-LOCATION:Europe/London +BEGIN:STANDARD +TZNAME:GMT +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +DTSTART:19701025T020000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:BST +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +DTSTART:19810329T010000 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +END:VTIMEZONE +END:VCALENDAR `; public start() { log('Starting mock server'); this.server = createServer((request, response) => { - const e2eFixture = request.url.match(/\/rest\/e2e\/(\w+)/); + const e2eFixture = request.url?.match(/\/rest\/e2e\/([^?]+)/); if (e2eFixture) { log(e2eFixture[1]); const command = e2eFixture[1]; @@ -223,6 +249,34 @@ END:VCALENDAR if (command === 'disable2fa') { this.challenge2fa = false; } + if (command === 'resetCalendarEvents') { + this.events = []; + this.bumpSyncToken(); + } + if (command.startsWith('setTimezone_')) { + this.accountTimezone = command.replace('setTimezone_', ''); + } + if (command === 'resetTimezone') { + this.accountTimezone = 'Europe/Oslo'; + } + if (command === 'addEvent') { + // POST with JSON body: { id, ical, calendar } + let body = ''; + request.on('readable', () => { + body += request.read() || ''; + }); + request.on('end', () => { + const parsed = JSON.parse(body); + this.events.push({ + id: parsed.id || ('e2e-event-' + this.events.length), + ical: parsed.ical, + calendar: parsed.calendar || 'mock cal', + }); + this.bumpSyncToken(); + response.end(JSON.stringify({ status: 'success' })); + }); + return; + } response.end(); return; } @@ -232,7 +286,7 @@ END:VCALENDAR return; } log(request.method + ' ' + request.url); - let requesturl = request.url; + let requesturl = request.url || ''; if (requesturl.indexOf('/rest/v1/list/deleted_messages') === 0) { requesturl = '/rest/v1/list/deleted_messages'; } @@ -251,7 +305,7 @@ END:VCALENDAR if (bulkemailendpiont) { const ids = bulkemailendpiont[1].split(',').map(id => parseInt(id, 10)); - const messages = {}; + const messages: { [key: number]: { json: any } } = {}; for (const id of ids) { messages[id] = { json: this.getMessage(id).result }; } @@ -295,6 +349,33 @@ END:VCALENDAR )); return; } + // ICS calendar import: PUT /rest/v1/calendar/ics/{calendar_id} + const icsImportMatch = requesturl.match(/^\/rest\/v1\/calendar\/ics\/(.+)$/); + if (icsImportMatch && request.method === 'PUT') { + let body = ''; + request.on('readable', () => { + body += request.read() || ''; + }); + request.on('end', () => { + const parsed = JSON.parse(body); + const eventCount = (parsed.ical.match(/BEGIN:VEVENT/g) || []).length; + const calendarId = decodeURIComponent(icsImportMatch[1]); + const uidMatch = parsed.ical.match(/UID:(.*)/); + this.events.push({ + id: calendarId + '/' + (uidMatch ? uidMatch[1].trim() : 'imported-' + this.events.length), + ical: parsed.ical, + calendar: calendarId, + }); + response.end(JSON.stringify({ + 'status': 'success', + 'result': { + 'events_imported': eventCount, + } + })); + }); + return; + } + switch (requesturl) { case '/ajax_mfa_authenticate': setTimeout(() => { @@ -449,7 +530,7 @@ END:VCALENDAR 'last_name': 'User', 'email_alternative': 'test@example.com', 'country': 'NO', - 'timezone': 'Europe/Oslo', + 'timezone': this.accountTimezone, 'phone_number': '', 'company': '', 'email_alternative_status': 0 @@ -477,8 +558,11 @@ END:VCALENDAR case '/_ics/Europe/Oslo.ics': response.end(this.vtimezone_oslo); break; + case '/_ics/Europe/London.ics': + response.end(this.vtimezone_london); + break; default: - if (request.url.indexOf('/rest') === 0) { + if (request.url && request.url.indexOf('/rest') === 0) { response.end(JSON.stringify({ status: 'success' })); } else { response.end(''); @@ -548,7 +632,7 @@ END:VCALENDAR }, 'company': null, 'is_overwrite_subaccount_ip_rules': 0, 'currency': 'EUR', - 'user_created': null, 'timezone': 'Europe/Oslo', 'uid': 221, + 'user_created': null, 'timezone': this.accountTimezone, 'uid': 221, 'sub_accounts': ['test%subaccount.com'], 'password_strength': 5, 'gender': null, 'has_sub_accounts': 1, 'need2pay': 'n', 'paid': 'n', 'country': null @@ -908,7 +992,7 @@ END:VCALENDAR }; } - createFolder(request, response) { + createFolder(request: any, response: any) { let body = ''; request.on('readable', () => { body += request.read() || ''; @@ -997,6 +1081,12 @@ END:VCALENDAR }; } + bumpSyncToken() { + const current = this.calendars[0].syncToken || ''; + const num = parseInt(current.split('-').pop() || '0', 10) || 0; + this.calendars[0].syncToken = 'e2e-sync-' + (num + 1); + } + getCalendars() { return { 'status': 'success', @@ -1014,8 +1104,9 @@ END:VCALENDAR }); request.on('end', () => { const event = JSON.parse(body); - event['id'] = 'mock-event-' + (this.events.length + 1); + event['id'] = (event['calendar'] || 'mock cal') + '/mock-event-' + (this.events.length + 1); this.events.push(event); + this.bumpSyncToken(); response.end(JSON.stringify({ 'status': 'success', 'result': { 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/calendar-app/calendar-app.component.html b/src/app/calendar-app/calendar-app.component.html index d539940ae..517b2d517 100644 --- a/src/app/calendar-app/calendar-app.component.html +++ b/src/app/calendar-app/calendar-app.component.html @@ -12,8 +12,8 @@ *ngFor="let event of (day.events.length > 4 ? day.events.slice(0, 3) : day.events)" (click)="openEvent(event); $event.stopPropagation()" > - - {{ event.start.getHours() }}:{{ ('0' + event.start.getMinutes()).slice(-2) }} + + {{ event.start | date:'HH:mm' }} {{ event.title }} diff --git a/src/app/calendar-app/calendar-app.component.spec.ts b/src/app/calendar-app/calendar-app.component.spec.ts index e2b29af55..1d145cadd 100644 --- a/src/app/calendar-app/calendar-app.component.spec.ts +++ b/src/app/calendar-app/calendar-app.component.spec.ts @@ -45,20 +45,28 @@ describe('CalendarAppComponent', () => { let component: CalendarAppComponent; let fixture: ComponentFixture; + // moment.toISOString() includes milliseconds (e.g. "2026-04-02T14:30:00.000Z"), + // which causes ICAL.js's fromDateTimeString to miss the 'Z' UTC designator + // (it checks index 19, but milliseconds push 'Z' to index 23). + // This helper produces ICAL-compatible ISO strings without milliseconds. + function toIcalISOString(m: moment.Moment): string { + return m.format('YYYY-MM-DDTHH:mm:ss') + 'Z'; + } + // jCal spec: https://tools.ietf.org/html/rfc7265 const simpleEvents = [ {'id': 'test-calendar/event0', 'ical': new ICAL.Component(['vcalendar', [], [ [ 'vevent', [ - [ 'dtstart', {}, 'date-time', moment().toISOString() ], - [ 'dtend', {}, 'date-time', moment().add(1, 'hour').toISOString() ], + [ 'dtstart', {}, 'date-time', toIcalISOString(moment()) ], + [ 'dtend', {}, 'date-time', toIcalISOString(moment().add(1, 'hour')) ], [ 'summary', {}, 'text', 'Test Event #0' ], ]]]]).toString(), 'calendar': 'test-calendar', }, {'id': 'test-calendar/event1', 'ical': new ICAL.Component(['vcalendar', [], [ [ 'vevent', [ - [ 'dtstart', {}, 'date-time', moment().date(15).add(1, 'month').add(1, 'day').add(2, 'hour').toISOString() ], - [ 'dtend', {}, 'date-time', moment().date(15).add(1, 'month').add(1, 'day').add(3, 'hour').toISOString() ], + [ 'dtstart', {}, 'date-time', toIcalISOString(moment().date(15).add(1, 'month').add(1, 'day').add(2, 'hour')) ], + [ 'dtend', {}, 'date-time', toIcalISOString(moment().date(15).add(1, 'month').add(1, 'day').add(3, 'hour')) ], [ 'summary', {}, 'text', 'Event #1, next month' ], ]]]]).toString(), 'calendar': 'test-calendar', @@ -68,8 +76,8 @@ describe('CalendarAppComponent', () => { const recurringEvents = [ { 'id': 'test-calendar/recurring', 'ical': new ICAL.Component(['vcalendar', [], [ [ 'vevent', [ - [ 'dtstart', {}, 'date-time', moment().date(15).toISOString() ], - [ 'dtend', {}, 'date-time', moment().add(1, 'hour').date(15).toISOString() ], + [ 'dtstart', {}, 'date-time', toIcalISOString(moment().date(15)) ], + [ 'dtend', {}, 'date-time', toIcalISOString(moment().add(1, 'hour').date(15)) ], [ 'summary', {}, 'text', 'Weekly Event #0' ], [ 'rrule', {}, 'recur', {'freq': 'WEEKLY'} ], ]]]]).toString(), @@ -305,8 +313,15 @@ END:VCALENDAR events = fixture.debugElement.nativeElement.querySelectorAll('button.calendarMonthDayEvent'); expect(component.shown_events.length).toBeGreaterThan(2, 'more events should be displayed now'); const first_occurence = component.shown_events[0].start; - expect(first_occurence.getDate()).toBe(1, 'day matches'); - expect(first_occurence.getHours()).toBe(12, 'hour matches'); - expect(first_occurence.getMinutes()).toBe(34, 'minute matches'); + // Event was created at 12:34 UTC using moment.utc().date(1).hour(12).minute(34).toISOString() + // but without a TZID in the iCal data. The calendar timezone is Europe/Stockholm. + // The floating time 12:34 is interpreted as Stockholm local time. + // Stockholm offset depends on DST: CET (UTC+1) in winter, CEST (UTC+2) in summer. + const eventLocalHour = 12; + const stockholmOffset = moment.tz('Europe/Stockholm').utcOffset(); // minutes + const expectedUtcHour = (eventLocalHour - stockholmOffset / 60 + 24) % 24; + expect(first_occurence.getUTCDate()).toBe(1); + expect(first_occurence.getUTCHours()).toBe(expectedUtcHour); + expect(first_occurence.getUTCMinutes()).toBe(34); }); }); diff --git a/src/app/calendar-app/calendar.service.spec.ts b/src/app/calendar-app/calendar.service.spec.ts index 6fbfe662e..bd6f64def 100644 --- a/src/app/calendar-app/calendar.service.spec.ts +++ b/src/app/calendar-app/calendar.service.spec.ts @@ -340,8 +340,10 @@ END:VCALENDAR // Produces multiple CalendarEvents which refer to the same ICal.Event expect(rbevents.length).toEqual(5, 'Recurring event contains 5 instances'); expect(rbevents[0].recurringFrequency).toEqual('DAILY', 'recurrence is DAILY'); - expect(rbevents[0].start).toEqual(new Date(2021, 3, 25, 15, 0, 0), 'event 1 start date is 3pm in Stockholm'); - expect(rbevents[1].start).toEqual(new Date(2021, 3, 26, 16, 0, 0), 'event 1 start date is 4pm in Stockholm'); + // Event 1: 9am Eastern (EDT UTC-4) = 13:00 UTC + // Event 2: 10am Eastern (EDT UTC-4) = 14:00 UTC (exception with +1hr) + expect(rbevents[0].start).toEqual(moment.utc('2021-04-25T13:00:00').toDate(), 'event 1 start date is 9am Eastern = 13:00 UTC'); + expect(rbevents[1].start).toEqual(moment.utc('2021-04-26T14:00:00').toDate(), 'event 2 start date is 10am Eastern = 14:00 UTC'); }); it('should be possible to import a static (non recurring) event', () => { diff --git a/src/app/calendar-app/calendar.service.ts b/src/app/calendar-app/calendar.service.ts index 9929a3b09..809c56081 100644 --- a/src/app/calendar-app/calendar.service.ts +++ b/src/app/calendar-app/calendar.service.ts @@ -332,7 +332,11 @@ export class CalendarService implements OnDestroy { // from its rrule at some point - check to see if we already // have the same uid if (vevents.length === 0) { - const ievent = new ICAL.Event(component.getFirstSubcomponent('vevent')); + const vevent = component.getFirstSubcomponent('vevent'); + if (!vevent) { + return { id, event: null }; + } + const ievent = new ICAL.Event(vevent); const existingEvent = this.icalevents.find( (entry) => entry['id'] === ievent.uid ); @@ -342,7 +346,7 @@ export class CalendarService implements OnDestroy { // we could save modified event, and delete this one // or keep doing this and leave that for a different fix? } - return; + return { id, event: null }; } if (keep) { this.icalevents.push({ 'id': id, 'event': vevents[0] }); @@ -444,7 +448,7 @@ export class CalendarService implements OnDestroy { // we need to copy it to a new calendar, and remove it from the old one. this.addEvent(event).then(id => { console.log('Event recreated as', id); - this.deleteEvent(event._old_id); + this.deleteEvent(event._old_id || ''); }); } else { // simple case: just modify the event in place @@ -477,7 +481,7 @@ export class CalendarService implements OnDestroy { // console.log('Found timezone data:', tzData); // VCALENDAR with VTIMEZONE in it const component = new ICAL.Component(ICAL.parse(tzData)); - let tz; + let tz: ICAL.Timezone | undefined; if (component.getFirstSubcomponent('vtimezone')) { for (const tzComponent of component.getAllSubcomponents('vtimezone')) { // TZIDs in vzic are, eg: /citadel.org/20210210_1/Europe/London @@ -492,7 +496,9 @@ export class CalendarService implements OnDestroy { } } } - this.userTimezoneLoaded.next(tz); + if (tz) { + this.userTimezoneLoaded.next(tz); + } this.userTimezoneLoaded.complete(); }); } @@ -578,7 +584,12 @@ export class CalendarService implements OnDestroy { // NB calendar-app.component.spec.ts relis on this being // multiple months if (this.icalevents.length > 0) { - this.events = this.generateEvents(start.toDate(), next_month.toDate(), this.icalevents); + // Preserve events that were pushed locally (e.g. via addEvent) but + // aren't in icalevents yet, so updateEventList doesn't drop them. + const localEvents = this.events.filter( + e => !this.icalevents.some(ie => ie.id === e.id)); + this.events = this.generateEvents(start.toDate(), next_month.toDate(), this.icalevents) + .concat(localEvents); this.eventSubject.next(this.events); } diff --git a/src/app/calendar-app/event-editor-dialog.component.ts b/src/app/calendar-app/event-editor-dialog.component.ts index dd5899481..6457ad953 100644 --- a/src/app/calendar-app/event-editor-dialog.component.ts +++ b/src/app/calendar-app/event-editor-dialog.component.ts @@ -145,7 +145,7 @@ export class EventEditorDialogComponent { const hasDay = setDays.find((entry) => entry['day'] === day.val); if (hasDay) { day['recurs_on'] = true; - if (hasDay['numth'] > 0) { + if (parseInt(hasDay['numth'], 10) > 0) { this.recur_by_monthyeardays.push(hasDay['numth']); } recurs_on_weekdays.push(day.val); diff --git a/src/app/calendar-app/runbox-calendar-event.spec.ts b/src/app/calendar-app/runbox-calendar-event.spec.ts index 7ce53cb4a..8dde2b4fa 100644 --- a/src/app/calendar-app/runbox-calendar-event.spec.ts +++ b/src/app/calendar-app/runbox-calendar-event.spec.ts @@ -1,3 +1,4 @@ +/// // --------- BEGIN RUNBOX LICENSE --------- // Copyright (C) 2016-2018 Runbox Solutions AS (runbox.com). // @@ -33,7 +34,6 @@ describe('RunboxCalendarEvent', () => { newEvent.title = 'New Event'; newEvent.location = 'Somewhere'; - // test things addEvent calls: expect(newEvent.toIcal()).toMatch(/BEGIN:VEVENT/); }); it('should be possible to create a new event, without times', () => { @@ -55,14 +55,74 @@ describe('RunboxCalendarEvent', () => { [] ); - // test things addEvent calls: expect(newEvent.toIcal()).toMatch(/BEGIN:VEVENT/); - // check date not time: const now = ICAL.Time.fromJSDate(moment().date(1).toDate()); expect(newEvent.toIcal()).not.toContain(now.toICALString()); now.isDate = true; expect(newEvent.toIcal()).toContain(now.toICALString()); }); + // All-day event display correctness with positive UTC offsets + // Bug: toJSDate() shifts midnight to previous day UTC (e.g. midnight CEST = 22:00 UTC prev day) + it('should display all-day event on correct day with positive UTC offset', () => { + ensureTimezone('Europe/Oslo', 1, 2); + + const event = RunboxCalendarEvent.newEmpty('Europe/Oslo'); + const startMoment = moment('2026-04-29T00:00:00').seconds(0).milliseconds(0); + const endMoment = moment('2026-04-30T00:00:00').seconds(0).milliseconds(0); + + event.updateEvent( + startMoment, endMoment, true, 'test-cal', + RecurSaveType.ALL_OCCURENCES, 'All-day event', '', '', + false, '', 0, [], [], [] + ); + + expect(event.allDay).toBe(true, 'event should be all-day'); + expect(event.start.getDate()).toBe(29, + 'All-day event on April 29 should have start.getDate() = 29'); + expect(event.start.getMonth()).toBe(3, + 'All-day event on April 29 should have start.getMonth() = 3 (April)'); + }); + it('should display multi-day all-day event on correct start and end days', () => { + ensureTimezone('Europe/Oslo', 1, 2); + + const event = RunboxCalendarEvent.newEmpty('Europe/Oslo'); + const startMoment = moment('2026-04-29T00:00:00').seconds(0).milliseconds(0); + const endMoment = moment('2026-05-01T00:00:00').seconds(0).milliseconds(0); + + event.updateEvent( + startMoment, endMoment, true, 'test-cal', + RecurSaveType.ALL_OCCURENCES, 'Multi-day event', '', '', + false, '', 0, [], [], [] + ); + + expect(event.start.getDate()).toBe(29, + 'Multi-day event start should be April 29'); + expect(event.end.getDate()).toBe(30, + 'Multi-day event end should be April 30'); + }); + it('should display created all-day event start and end on correct day', () => { + ensureTimezone('Europe/Oslo', 1, 2); + + const event = RunboxCalendarEvent.newEmpty('Europe/Oslo'); + // Simulate dialog: start at noon, end at 2pm (+1 day added by dialog for exclusive DTEND) + const startMoment = moment('2026-05-05T12:00:00').seconds(0).milliseconds(0); + const endMoment = moment('2026-05-06T14:00:00').seconds(0).milliseconds(0); + + event.updateEvent( + startMoment, endMoment, true, 'test-cal', + RecurSaveType.ALL_OCCURENCES, 'All-day event', '', '', + false, '', 0, [], [], [] + ); + + expect(event.start.getDate()).toBe(5, + 'Created all-day event start day should be 5'); + expect(event.end.getDate()).toBe(5, + 'Created all-day event end day should be 5 (inclusive)'); + expect(event.start.getMonth()).toBe(4, + 'Created all-day event start month should be May (4)'); + expect(event.end.getMonth()).toBe(4, + 'Created all-day event end month should be May (4)'); + }); it('should be possible to add/edit/remove a WEEKLY recurrence rule', () => { const sut = new RunboxCalendarEvent( 'testcal/testev', new ICAL.Event(new ICAL.Component(['vcalendar', [], [ @@ -71,8 +131,8 @@ describe('RunboxCalendarEvent', () => { [ 'dtend', {}, 'date', moment().toISOString().split('T')[0] ], [ 'summary', {}, 'text', 'One-time event' ], ] ] - ]])), ICAL.Time.fromJSDate(new Date()), ICAL.Time.fromJSDate(new Date()) - , 'Europe/London' // user's timezone for display + ]])), ICAL.Time.fromJSDate(new Date()), ICAL.Time.fromJSDate(new Date()), + 'Europe/London' ); sut.recurringFrequency = 'WEEKLY'; expect(sut.recurringFrequency).toBe('WEEKLY', 'recurrence seems to be set'); @@ -116,8 +176,8 @@ describe('RunboxCalendarEvent', () => { [ 'dtend', {}, 'date', moment().toISOString().split('T')[0] ], [ 'summary', {}, 'text', 'One-time event' ], ] ] - ]])), ICAL.Time.fromJSDate(new Date()), ICAL.Time.fromJSDate(new Date()) - , 'Europe/London' // user's timezone for display + ]])), ICAL.Time.fromJSDate(new Date()), ICAL.Time.fromJSDate(new Date()), + 'Europe/London' ); sut.recurringFrequency = 'MONTHLY'; sut.recurInterval = 1; // the default @@ -199,11 +259,11 @@ describe('RunboxCalendarEvent', () => { false, sut.calendar, RecurSaveType.THIS_ONLY, - 'Moved weekly event', undefined, undefined, + 'Moved weekly event', '', '', true, sut.recurringFrequency, sut.recurInterval, - undefined, undefined, undefined, // and optional params.. + [], [], [] // and optional params.. ); expect(sut.toIcal()).toContain('SUMMARY:Moved weekly event'); @@ -232,11 +292,11 @@ describe('RunboxCalendarEvent', () => { false, sut.calendar, RecurSaveType.THIS_ONLY, - 'Moved weekly event one hour', undefined, undefined, + 'Moved weekly event one hour', '', '', true, sut.recurringFrequency, sut.recurInterval, - undefined, undefined, undefined, // and optional params.. + [], [], [], ); expect(sut.toIcal()).toContain('SUMMARY:Moved weekly event one hour'); @@ -349,25 +409,30 @@ END:VCALENDAR` ); console.log('sut tz :' + sut.timezone); // verify timezone event has sane start/end dates - // This should be in the user's tz (Europe/London in this test) + // Event is at 09:00 Berlin (CEST=UTC+2) = 07:00 UTC + // When converted to New York (EDT=UTC-4) = 03:00 local NY time + // toJSDate() returns the correct absolute UTC time console.log('event start :' + sut.start.toISOString()); - // 3am New York - expect(sut.start.toISOString()).toBe('2021-05-15T03:00:00.000Z'); - // Move this one an hour later - // TZ? - const future = moment('2021-05-15T04:00:00'); - const future_end = moment('2021-05-15T05:00:00'); + // 07:00 UTC (which is 03:00 New York EDT) + expect(sut.start.toISOString()).toBe('2021-05-15T07:00:00.000Z'); + // Move this one an hour later (09:00 -> 10:00 Berlin time) + // Use moment.tz to create a deterministic time in the event's timezone, + // simulating what the dialog would pass after user edits the time. + // momentToIcalTime now converts via UTC, so this produces the correct + // 10:00 Berlin regardless of the test machine's local timezone. + const future = moment.tz('2021-05-15T10:00:00', 'Europe/Berlin'); + const future_end = moment.tz('2021-05-15T11:00:00', 'Europe/Berlin'); sut.updateEvent( future, future_end, false, sut.calendar, RecurSaveType.THIS_ONLY, - 'Moved daily event one hour', undefined, undefined, + 'Moved daily event one hour', '', '', true, sut.recurringFrequency, sut.recurInterval, - undefined, undefined, undefined, // and optional params.. + [], [], [], ); expect(sut.toIcal()).toContain('SUMMARY:Moved daily event one hour'); @@ -375,4 +440,532 @@ END:VCALENDAR` expect(sut.toIcal()).toContain('RECURRENCE-ID;TZID=/freeassociation.sourceforge.net/Europe/Berlin:20210515T\r\n 090000'); expect(sut.toIcal()).toContain('DTSTART;TZID=/freeassociation.sourceforge.net/Europe/Berlin:20210515T100000'); }); + + it('should handle floating time events (no TZID) by interpreting in user timezone', () => { + // Setup: Register Europe/London and Europe/Berlin timezones + const londonTzData = `BEGIN:VTIMEZONE +TZID:Europe/London +X-LIC-LOCATION:Europe/London +BEGIN:STANDARD +TZNAME:GMT +DTSTART:19701025T020000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0000 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:BST +DTSTART:19810329T010000 +TZOFFSETFROM:+0000 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +END:VTIMEZONE`; + + const berlinTzData = `BEGIN:VTIMEZONE +TZID:Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +BEGIN:STANDARD +TZNAME:CET +DTSTART:19701025T020000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +BEGIN:DAYLIGHT +TZNAME:CEST +DTSTART:19810329T010000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +END:VTIMEZONE`; + + // Register timezones + const londonComponent = new ICAL.Component(ICAL.parse(londonTzData)); + const londonTz = new ICAL.Timezone({ + tzid: londonComponent.getFirstPropertyValue('tzid'), + component: londonComponent + }); + ICAL.TimezoneService.register(londonTz.tzid, londonTz); + + const berlinComponent = new ICAL.Component(ICAL.parse(berlinTzData)); + const berlinTz = new ICAL.Timezone({ + tzid: berlinComponent.getFirstPropertyValue('tzid'), + component: berlinComponent + }); + ICAL.TimezoneService.register(berlinTz.tzid, berlinTz); + + // Floating time (no TZID) - interpreted in calendar's timezone + const floatingIcalEvent = new ICAL.Event(new ICAL.Component(['vevent', [ + ['dtstart', {}, 'date-time', '2026-03-24T16:00:00'], + ['dtend', {}, 'date-time', '2026-03-24T17:00:00'], + ['summary', {}, 'text', 'Floating event at 4pm'], + ]])); + const floatingEvent = new RunboxCalendarEvent( + 'testcal/floating', + floatingIcalEvent, + floatingIcalEvent.startDate, + floatingIcalEvent.endDate, + 'Europe/London' + ); + + // 16:00 floating with calendar tz London (UTC+0) = 16:00 UTC + expect(floatingEvent.start.getUTCHours()).toBe(16, 'Floating time 16:00 London should be 16:00 UTC'); + + // Same floating time for Berlin user - interpreted as Berlin time (UTC+1) = 15:00 UTC + const floatingBerlinIcalEvent = new ICAL.Event(new ICAL.Component(['vevent', [ + ['dtstart', {}, 'date-time', '2026-03-24T16:00:00'], + ['dtend', {}, 'date-time', '2026-03-24T17:00:00'], + ['summary', {}, 'text', 'Floating event at 4pm (Berlin user)'], + ]])); + const floatingEventBerlin = new RunboxCalendarEvent( + 'testcal/floating-berlin', + floatingBerlinIcalEvent, + floatingBerlinIcalEvent.startDate, + floatingBerlinIcalEvent.endDate, + 'Europe/Berlin' + ); + + expect(floatingEventBerlin.start.getUTCHours()).toBe(15, 'Floating time 16:00 Berlin should be 15:00 UTC'); + }); + + // Helper to create and register a timezone from standard offsets + function ensureTimezone(tzid: string, stdOffset: number, dstOffset: number) { + if (ICAL.TimezoneService.has(tzid)) { return; } + const fmt = (n: number) => (n >= 0 ? '+' : '-') + String(Math.abs(n)).padStart(2, '0'); + const std = fmt(stdOffset); + const dst = fmt(dstOffset); + const tzData = `BEGIN:VTIMEZONE +TZID:${tzid} +BEGIN:STANDARD +DTSTART:19701025T020000 +TZOFFSETFROM:${dst}00 +TZOFFSETTO:${std}00 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19810329T010000 +TZOFFSETFROM:${std}00 +TZOFFSETTO:${dst}00 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +END:VTIMEZONE`; + const comp = new ICAL.Component(ICAL.parse(tzData)); + const tz = new ICAL.Timezone({ tzid, component: comp }); + ICAL.TimezoneService.register(tz.tzid, tz); + } + + it('should correctly convert London TZID event to Berlin user timezone', () => { + // Simulates an issue: 4pm London event shown as 3pm (wrong) instead of 5pm (correct) + ensureTimezone('Europe/London', 0, 1); // GMT/BST + ensureTimezone('Europe/Berlin', 1, 2); // CET/CEST + + // Create event with TZID=Europe/London at 4pm (March = GMT = UTC+0) + const vevent = new ICAL.Component(['vevent', [ + ['dtstart', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T16:00:00'], + ['dtend', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T17:00:00'], + ['summary', {}, 'text', 'Meeting at 4pm London'], + ]]); + const dtstartProp = vevent.getFirstProperty('dtstart'); + + const londonEvent = new RunboxCalendarEvent( + 'testcal/lon', + new ICAL.Event(vevent), + ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp), + ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp), + 'Europe/London' + ); + expect(londonEvent.start.getUTCHours()).toBe(16, '4pm London = 16:00 UTC'); + + const berlinUserEvent = new RunboxCalendarEvent( + 'testcal/ber', + new ICAL.Event(vevent), + ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp), + ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp), + 'Europe/Berlin' + ); + // 4pm London (GMT+0) = 4pm UTC = 5pm Berlin (CET+1) when displayed + // The UTC time must remain 16:00Z - angular-calendar displays in browser's local tz + expect(berlinUserEvent.start.getUTCHours()).toBe(16, 'Same event = same UTC time (16:00Z)'); + }); + + it('should handle event with TZID but timezone NOT registered', () => { + // Bug: Event has TZID=Europe/London but London isn't in TimezoneService + // Fix: Preserve local time values instead of misinterpreting as floating + ensureTimezone('Europe/Berlin', 1, 2); + + const vevent = new ICAL.Component(['vevent', [ + ['dtstart', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T16:00:00'], + ['dtend', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T17:00:00'], + ['summary', {}, 'text', 'Meeting at 4pm London'], + ]]); + const dtstartProp = vevent.getFirstProperty('dtstart'); + const dtstart = ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp); + const dtend = ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp); + + const event = new RunboxCalendarEvent( + 'testcal/bug', + new ICAL.Event(vevent), + dtstart, + dtend, + 'Europe/Berlin' + ); + + expect(event.start.getUTCHours()).toBe(16, '4pm London should be 16:00 UTC even if London not registered'); + }); + + it('should display 4pm London event as 5pm for Berlin user', () => { + // Bug: 4pm London showed as 3pm Berlin instead of 5pm + ensureTimezone('Europe/London', 0, 1); + ensureTimezone('Europe/Berlin', 1, 2); + + const vevent = new ICAL.Component(['vevent', [ + ['dtstart', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T16:00:00'], + ['dtend', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T17:00:00'], + ['summary', {}, 'text', 'Meeting at 4pm London'], + ]]); + const dtstartProp = vevent.getFirstProperty('dtstart'); + const dtstart = ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp); + const dtend = ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp); + + const event = new RunboxCalendarEvent( + 'testcal/berlin-user', + new ICAL.Event(vevent), + dtstart, + dtend, + 'Europe/Berlin' + ); + + // Month view: event.start.getUTCHours() + expect(event.start.getUTCHours()).toBe(16, '4pm London = 16:00 UTC'); + // Event card: event.dtstart (moment with timezone) + expect(event.dtstart.hour()).toBe(17, '4pm London displays as 5pm (17:00) in Berlin'); + }); + + it('should handle TZID path mismatch between event and user timezone', () => { + // User's tz may be registered with full path (e.g., /citadel.org/.../Europe/Berlin) + // while event uses simple TZID (e.g., Europe/London) + ensureTimezone('/citadel.org/20210210_1/Europe/Berlin', 1, 2); + ensureTimezone('Europe/Berlin', 1, 2); + ensureTimezone('Europe/London', 0, 1); + + const vevent = new ICAL.Component(['vevent', [ + ['dtstart', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T16:00:00'], + ['dtend', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T17:00:00'], + ['summary', {}, 'text', 'Meeting at 4pm London'], + ]]); + const dtstartProp = vevent.getFirstProperty('dtstart'); + const dtstart = ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp); + + const event = new RunboxCalendarEvent( + 'testcal/tzpath', + new ICAL.Event(vevent), + dtstart, + ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp), + '/citadel.org/20210210_1/Europe/Berlin' + ); + + expect(event.start.getUTCHours()).toBe(16, '4pm London should be 16:00 UTC'); + + const eventSimple = new RunboxCalendarEvent( + 'testcal/tzsimple', + new ICAL.Event(vevent), + ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp), + ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp), + 'Europe/Berlin' + ); + expect(eventSimple.start.getUTCHours()).toBe(16, 'Simple tz name should also work'); + }); + + it('should display all-day event on correct day with citadel-path timezone', () => { + // Bug: All-day events with citadel-path TZID display on previous day + // because toJSDate() shifts midnight CEST to 22:00 UTC previous day + ensureTimezone('/citadel.org/20210210_1/Europe/Oslo', 1, 2); + + const icalData = `BEGIN:VCALENDAR +BEGIN:VTIMEZONE +TZID:/citadel.org/20210210_1/Europe/Oslo +BEGIN:STANDARD +DTSTART:19701025T020000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU +END:STANDARD +BEGIN:DAYLIGHT +DTSTART:19810329T010000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU +END:DAYLIGHT +END:VTIMEZONE +BEGIN:VEVENT +UID:allday-citadel-test +DTSTART;VALUE=DATE:20260429 +DTEND;VALUE=DATE:20260430 +SUMMARY:Citadel All-Day Event +END:VEVENT +END:VCALENDAR`; + + const jcal = ICAL.parse(icalData); + const ical = new ICAL.Component(jcal); + + for (const tzComponent of ical.getAllSubcomponents('vtimezone')) { + const tz = new ICAL.Timezone({ + tzid: tzComponent.getFirstPropertyValue('tzid'), + component: tzComponent, + }); + if (!ICAL.TimezoneService.has(tz.tzid)) { + ICAL.TimezoneService.register(tz.tzid, tz); + } + } + + const vevent = ical.getFirstSubcomponent('vevent'); + const event = new RunboxCalendarEvent( + 'testcal/allday-citadel', + new ICAL.Event(vevent), + ICAL.Time.fromDateString('2026-04-29'), + ICAL.Time.fromDateString('2026-04-30'), + '/citadel.org/20210210_1/Europe/Oslo' + ); + + expect(event.allDay).toBe(true, 'event should be all-day'); + expect(event.start.getDate()).toBe(29, + 'All-day event on April 29 should display on day 29 even with citadel-path TZ'); + }); + + it('should preserve time when event timezone is not registered', () => { + // Bug: When event TZID is not in TimezoneService, time shifts incorrectly + ensureTimezone('Europe/Berlin', 1, 2); + // Europe/Oslo is NOT registered + + const vevent = new ICAL.Component(['vevent', [ + ['dtstart', { tzid: 'Europe/Oslo' }, 'date-time', '2026-04-29T12:00:00'], + ['dtend', { tzid: 'Europe/Oslo' }, 'date-time', '2026-04-29T13:00:00'], + ['summary', {}, 'text', 'Oslo Noon (unresolved TZ)'], + ]]); + const dtstartProp = vevent.getFirstProperty('dtstart'); + const dtstart = ICAL.Time.fromDateTimeString('2026-04-29T12:00:00', dtstartProp); + const dtend = ICAL.Time.fromDateTimeString('2026-04-29T13:00:00', dtstartProp); + + const event = new RunboxCalendarEvent( + 'testcal/unresolved-tz', + new ICAL.Event(vevent), + dtstart, + dtend, + 'Europe/Berlin' + ); + + // When TZID is unresolvable, fallback should preserve the local time as UTC + // 12:00 Oslo (unresolved) → 12:00 UTC (fallback, not 10:00 UTC which would be correct) + expect(event.start.getUTCHours()).toBe(12, + 'Unresolved TZ should preserve local time as UTC'); + }); + + // Reproduction tests for staging feedback (PR #1779) + + it('should round-trip entered time correctly when creating event via updateEvent', () => { + // Bug 1 (staging feedback): User in CET browser, account tz = UK (London) + // User enters 12:00, event shows as 13:00 when reopening edit dialog. + // + // The model round-trip is actually correct: the ICAL data stores 12:00 London + // and dtstart.hour() returns 12. The bug is in EventEditorDialogComponent which + // uses bare Date objects (displayed in browser local time). + ensureTimezone('Europe/London', 0, 1); + + const event = RunboxCalendarEvent.newEmpty('Europe/London'); + const startMoment = moment('2026-04-14T12:00:00').seconds(0).milliseconds(0); + const endMoment = startMoment.clone().add(1, 'hour'); + + event.updateEvent( + startMoment, endMoment, false, 'test-cal', + RecurSaveType.ALL_OCCURENCES, 'Test Event', '', '', + false, '', 0, [], [], [] + ); + + // dtstart (timezone-aware moment) should show 12:00 in London + expect(event.dtstart.hour()).toBe(12, + 'dtstart should show 12:00 in account timezone (London)'); + // April = BST (UTC+1), so 12:00 London = 11:00 UTC + expect(event.start.toISOString()).toBe('2026-04-14T11:00:00.000Z', + 'start Date should be 11:00 UTC (12:00 BST)'); + }); + + it('should round-trip 12pm timed event with Oslo/citadel-path timezone', () => { + // Production TZID is a citadel path — moment-timezone can't resolve it + ensureTimezone('/citadel.org/20210210_1/Europe/Oslo', 1, 2); + + const event = RunboxCalendarEvent.newEmpty('/citadel.org/20210210_1/Europe/Oslo'); + const startMoment = moment('2026-04-29T12:00:00').seconds(0).milliseconds(0); + const endMoment = startMoment.clone().add(1, 'hour'); + + event.updateEvent( + startMoment, endMoment, false, 'test-cal', + RecurSaveType.ALL_OCCURENCES, 'Oslo Noon Event', '', '', + false, '', 0, [], [], [] + ); + + // April 29 = CEST (UTC+2), so 12:00 CEST = 10:00 UTC + expect(event.dtstart.hour()).toBe(12, + 'dtstart should show 12:00 in account timezone (Oslo)'); + expect(event.start.toISOString()).toBe('2026-04-29T10:00:00.000Z', + 'start Date should be 10:00 UTC (12:00 CEST)'); + }); + + it('should store exception at user-entered time when event tz differs from account tz', () => { + // Bug 2 (staging feedback): Recurring event at 09:00 Berlin, account tz = London. + // User edits one occurrence from 09:00 to 10:00 (shown in browser local time). + // momentToIcalTime tags the time with account tz (London), then converts to + // event tz (Berlin) — this double-conversion shifts the time by the London↔Berlin offset. + ensureTimezone('Europe/London', 0, 1); + ensureTimezone('/freeassociation.sourceforge.net/Europe/Berlin', 1, 2); + + const jcal = ICAL.parse( +`BEGIN:VCALENDAR +CALSCALE:GREGORIAN +PRODID:-//Test//Test//EN +VERSION:2.0 +BEGIN:VTIMEZONE +TZID:/freeassociation.sourceforge.net/Europe/Berlin +X-LIC-LOCATION:Europe/Berlin +BEGIN:DAYLIGHT +TZNAME:CEST +DTSTART:19810328T020000 +TZOFFSETFROM:+0100 +TZOFFSETTO:+0200 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3 +END:DAYLIGHT +BEGIN:STANDARD +TZNAME:CET +DTSTART:19961031T030000 +TZOFFSETFROM:+0200 +TZOFFSETTO:+0100 +RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10 +END:STANDARD +END:VTIMEZONE +BEGIN:VEVENT +UID:bug2-recurring-test +DTSTAMP:20210511T111559Z +DTSTART;TZID=/freeassociation.sourceforge.net/Europe/Berlin: + 20210514T090000 +DTEND;TZID=/freeassociation.sourceforge.net/Europe/Berlin: + 20210514T100000 +SUMMARY:Daily Berlin 9am +RRULE:FREQ=DAILY;INTERVAL=1;COUNT=5 +END:VEVENT +END:VCALENDAR` + ); + const ical = new ICAL.Component(jcal); + for (const tzComponent of ical.getAllSubcomponents('vtimezone')) { + const tz = new ICAL.Timezone({ + tzid: tzComponent.getFirstPropertyValue('tzid'), + component: tzComponent, + }); + if (!ICAL.TimezoneService.has(tz.tzid)) { + ICAL.TimezoneService.register(tz.tzid, tz); + } + } + const vevent = ical.getFirstSubcomponent('vevent'); + const dtstartProp = vevent.getFirstProperty('dtstart'); + + // Day 2 instance (May 15 at 09:00 Berlin) + const sut = new RunboxCalendarEvent( + 'testcal/bug2', + new ICAL.Event(vevent), + ICAL.Time.fromDateTimeString('2021-05-15T09:00:00', dtstartProp), + ICAL.Time.fromDateTimeString('2021-05-15T10:00:00', dtstartProp), + 'Europe/London' // account tz = London (differs from event tz = Berlin) + ); + + // Verify original: 09:00 Berlin (CEST=UTC+2) = 07:00 UTC + expect(sut.start.toISOString()).toBe('2021-05-15T07:00:00.000Z', + 'Original: 09:00 Berlin CEST = 07:00 UTC'); + + // User edits this occurrence from 09:00 to 10:00 in dialog + // Use moment.tz to create a deterministic time in the event's timezone, + // simulating what the dialog would pass after user edits the time. + // momentToIcalTime now converts via UTC, so this produces the correct + // 10:00 Berlin regardless of the test machine's local timezone. + const newStart = moment.tz('2021-05-15T10:00:00', 'Europe/Berlin').seconds(0).milliseconds(0); + const newEnd = moment.tz('2021-05-15T11:00:00', 'Europe/Berlin').seconds(0).milliseconds(0); + sut.updateEvent( + newStart, newEnd, false, sut.calendar, + RecurSaveType.THIS_ONLY, + 'Moved to 10am', '', '', + true, sut.recurringFrequency, sut.recurInterval, + [], [], [] + ); + + // The exception should exist in the ICAL data + expect(sut.toIcal()).toContain('RECURRENCE-ID'); + expect(sut.toIcal()).toContain('Moved to 10am'); + + // The exception DTSTART should be 10:00 Berlin (user entered 10:00) + // NOT 11:00 Berlin (which would result from London→Berlin double-conversion) + // 10:00 Berlin CEST (UTC+2) = 08:00 UTC + expect(sut.toIcal()).toContain('DTSTART;TZID=/freeassociation.sourceforge.net/Europe/Berlin:20210515T100000', + 'Exception should be at 10:00 Berlin (user-entered time)'); + }); + + it('should reflect updated display timezone when timezone property changes', () => { + // Bug 3 (staging feedback): After changing account timezone, event times + // don't update. The timezone property on existing RunboxCalendarEvent instances + // is set once at construction and never refreshed. + ensureTimezone('Europe/London', 0, 1); + ensureTimezone('Europe/Berlin', 1, 2); + + // Event stored as 16:00 London (March = GMT = UTC+0) + const vevent = new ICAL.Component(['vevent', [ + ['dtstart', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T16:00:00'], + ['dtend', { tzid: 'Europe/London' }, 'date-time', '2026-03-24T17:00:00'], + ['summary', {}, 'text', '4pm London'], + ]]); + const dtstartProp = vevent.getFirstProperty('dtstart'); + const event = new RunboxCalendarEvent( + 'test/tzchange', + new ICAL.Event(vevent), + ICAL.Time.fromDateTimeString('2026-03-24T16:00:00', dtstartProp), + ICAL.Time.fromDateTimeString('2026-03-24T17:00:00', dtstartProp), + 'Europe/London' + ); + + // Initially: 4pm London (GMT = UTC+0 in March) = 16:00 UTC + expect(event.start.getUTCHours()).toBe(16); + expect(event.dtstart.hour()).toBe(16, '4pm London shows as 16:00 in London'); + + // User changes account timezone to Berlin + event.timezone = 'Europe/Berlin'; + + // dtstart moment should now show Berlin time + // 16:00 UTC = 17:00 CET (UTC+1 in March) + expect(event.dtstart.hour()).toBe(17, + 'After tz change to Berlin, 4pm London should display as 5pm (17:00)'); + + // start Date remains the same UTC time (Date is always UTC) + expect(event.start.getUTCHours()).toBe(16, + 'start Date UTC time should not change (16:00 UTC)'); + }); + + it('should display UTC event at correct hour for negative-offset account', () => { + // Exercises the via-UTC path in momentToIcalTime with a negative offset (America/New_York) + ensureTimezone('America/New_York', -5, -4); // EST / EDT + + const event = RunboxCalendarEvent.newEmpty('America/New_York'); + // July 15 = EDT (UTC-4), so 13:00 EDT = 17:00 UTC + const startMoment = moment('2026-07-15T13:00:00').seconds(0).milliseconds(0); + const endMoment = moment('2026-07-15T14:00:00').seconds(0).milliseconds(0); + + event.updateEvent( + startMoment, endMoment, false, 'test-cal', + RecurSaveType.ALL_OCCURENCES, 'NY Afternoon Event', '', '', + false, '', 0, [], [], [] + ); + + // 13:00 EDT (UTC-4) = 17:00 UTC + expect(event.start.toISOString()).toBe('2026-07-15T17:00:00.000Z', + '1pm New York EDT should be 17:00 UTC'); + // dtstart moment should show 13:00 in New York + expect(event.dtstart.hour()).toBe(13, + 'dtstart should show 13:00 in account timezone (New York)'); + }); }); diff --git a/src/app/calendar-app/runbox-calendar-event.ts b/src/app/calendar-app/runbox-calendar-event.ts index 81da09446..43009785d 100644 --- a/src/app/calendar-app/runbox-calendar-event.ts +++ b/src/app/calendar-app/runbox-calendar-event.ts @@ -49,20 +49,20 @@ export class RunboxCalendarEvent implements CalendarEvent { // start and end are for display pursposes only, // and will be different from dtstart/dtend in // recurring events - _calendar: string; + _calendar!: string; _old_id?: string; ical: ICAL.Component; event: ICAL.Event; // *display* start/end - for reccurrences will not match the ICAL.Event - private _dtstart: ICAL.Time; - private _dtend: ICAL.Time; + private _dtstart!: ICAL.Time; + private _dtend!: ICAL.Time; // store user's selection of allDay setting while updating the event // required cos neither Moment nor Date have a "date only" functionality // ICAL.Time does tho! - private _allDay: boolean; + private _allDay!: boolean; get calendar(): string { return this._calendar; @@ -114,7 +114,7 @@ export class RunboxCalendarEvent implements CalendarEvent { set dtstart(value: moment.Moment) { // check this before we update: if (this._dtstart.toJSDate().toString() === this.recurStart.toString()) { - this._dtstart = this.momentToIcalTime(value, this.event.startDate ? this.event.startDate.zone : null); + this._dtstart = this.momentToIcalTime(value, this.event.startDate ? this.event.startDate.zone : ICAL.Timezone.utcTimezone); this.event.startDate = this._dtstart; } } @@ -132,7 +132,7 @@ export class RunboxCalendarEvent implements CalendarEvent { // one with a time? (as a side effect that is) set dtend(value: moment.Moment) { if (value && this._dtstart.toJSDate().toString() === this.recurStart.toString()) { - this._dtend = this.momentToIcalTime(value, this.event.endDate ? this.event.endDate.zone : undefined); + this._dtend = this.momentToIcalTime(value, this.event.endDate ? this.event.endDate.zone : ICAL.Timezone.utcTimezone); this.event.endDate = this._dtend; } } @@ -140,39 +140,112 @@ export class RunboxCalendarEvent implements CalendarEvent { // angular-calendar compatibility get start(): Date { - // This needs to be converted *from* tz the ical data is in - // *to* the tz the user's calendar display is in (this.timezone?) - let user_dtstart = this._dtstart; - // can't convert items with no tz set, so assume default (utc) - if (this._dtstart.zone) { - // console.log('start: convert from: ' + user_dtstart.zone.tzid); - // console.log('offset: ' + user_dtstart.zone.utcOffset(user_dtstart)); - // console.log('start: convert to : ' + this.timezone); - // console.log('have timezone? : ' + ICAL.TimezoneService.has(this.timezone)); - // console.log('offset: ' + ICAL.TimezoneService.get(this.timezone).utcOffset(user_dtstart)); - user_dtstart = this._dtstart.convertToZone(ICAL.TimezoneService.get(this.timezone)); - } - - return new Date(user_dtstart.toString()); + return this.convertIcalTimeToDate(this._dtstart, 'dtstart'); } get end(): Date { if (!this._dtend) { return undefined; } - - let shownEnd = this._dtend.clone(); // ICAL event DTEND is exclusive, angular-calendar is inclusive if (this.allDay) { - shownEnd.addDuration(new ICAL.Duration({'isNegative': true, 'days': 1})); - } else { - shownEnd.addDuration(new ICAL.Duration({'isNegative': true, 'seconds': 1})); + // Subtract 1 day at the Date level to avoid ICAL.js clone/normalize subtleties. + // Uses same noon-UTC pattern as convertIcalTimeToDate — keep in sync. + return new Date(Date.UTC( + this._dtend.year, + this._dtend.month - 1, + this._dtend.day - 1, + 12, 0, 0 + )); } - if (shownEnd.zone) { - shownEnd = shownEnd.convertToZone(ICAL.TimezoneService.get(this.timezone)); + const shownEnd = this._dtend.clone(); + shownEnd.addDuration(new ICAL.Duration({'isNegative': true, 'seconds': 1})); + return this.convertIcalTimeToDate(shownEnd, 'dtend'); + } + + /** + * Convert an ICAL.Time to a JavaScript Date with proper timezone handling. + * + * Handles three cases: + * 1. Proper timezone (has VTIMEZONE data or UTC) → convert to user's display timezone + * 2. True floating time (no TZID in property) → interpret in calendar's timezone + * 3. Unresolved TZID (has TZID but not found) → preserve local time values as UTC + */ + private convertIcalTimeToDate(time: ICAL.Time, propName: 'dtstart' | 'dtend'): Date { + if (time.isDate) { + // All-day dates must display on the same calendar date regardless of timezone. + // Use noon UTC so getDate() returns the correct day in every timezone. + return new Date(Date.UTC(time.year, time.month - 1, time.day, 12, 0, 0)); + } + + const zone = time.zone; + // Check for proper timezone with VTIMEZONE data or UTC + const hasProperTimezone = zone && + typeof zone === 'object' && + zone.tzid && + zone.tzid !== 'floating' && + (zone.component || zone.tzid === 'UTC'); + + if (hasProperTimezone) { + const targetTz = this.getAccountTimezone(); + const converted = targetTz ? time.convertToZone(targetTz) : time; + return converted.toJSDate(); + } + + // Check if the property had a TZID parameter to differentiate floating vs unresolved + const prop = this.event.component.getFirstProperty(propName); + const hasTzidParam = prop && prop.getParameter('tzid'); + + if (!hasTzidParam) { + // True floating time - interpret in calendar's timezone + // First try ICAL.TimezoneService (for non-standard paths with VTIMEZONE data) + const calendarTz = this.getAccountTimezone(); + + if (calendarTz) { + // Create time directly in calendar's timezone, then convert to UTC via toJSDate() + const localTime = new ICAL.Time({ + year: time.year, + month: time.month, + day: time.day, + hour: time.hour, + minute: time.minute, + second: time.second + }, calendarTz); + return localTime.toJSDate(); + } + + // Try moment-timezone for standard IANA timezones + const momentZone = this.timezone ? moment.tz.zone(this.timezone) : null; + if (momentZone) { + const m = moment.tz([ + time.year, + time.month - 1, + time.day, + time.hour, + time.minute, + time.second, + 0 + ], this.timezone || 'UTC'); + return m.toDate(); + } + + // Fallback: no calendar timezone available, preserve local time as UTC + return this.icalTimeToUTCDate(time); } - return new Date(shownEnd.toString()); + // Unresolved TZID or no calendar timezone - preserve local time values + return this.icalTimeToUTCDate(time); + } + + private icalTimeToUTCDate(time: ICAL.Time): Date { + return new Date(Date.UTC( + time.year, + time.month - 1, + time.day, + time.hour, + time.minute, + time.second + )); } set allDay(value) { @@ -211,15 +284,14 @@ export class RunboxCalendarEvent implements CalendarEvent { // DAILY, WEEKLY, MONTHLY etc get recurringFrequency(): string { - const recur = this.event.component.getFirstPropertyValue('rrule'); - return recur ? recur.freq : ''; + return this.getRecur()?.freq || ''; } // Only set if the new value is different from the old one // Prevents us accidentally overwriting imported RRULE details // -> dont display the "recurring" select box on exception events? set recurringFrequency(frequency: string) { - const recur = this.event.component.getFirstPropertyValue('rrule'); + const recur = this.getRecur(); if (recur) { recur.freq = frequency; this.event.component.updatePropertyWithValue('rrule', recur); @@ -230,44 +302,49 @@ export class RunboxCalendarEvent implements CalendarEvent { // How often does this repeat (every X freqs) get recurInterval(): number { - const recur = this.event.component.getFirstPropertyValue('rrule'); - return recur ? recur.interval : 1; + return this.getRecur()?.interval || 1; } set recurInterval(interval: number) { - const recur = this.event.component.getFirstPropertyValue('rrule'); - recur.interval = interval; - this.event.component.updatePropertyWithValue('rrule', recur); + const recur = this.getRecur(); + if (recur) { + recur.interval = interval; + this.event.component.updatePropertyWithValue('rrule', recur); + } } // An UNTIL date, a COUNT or null = unset/repeats forever - get recurEnds(): Date | number { - const recur = this.event.component.getFirstPropertyValue('rrule'); - if (recur && recur.until) { - return recur.until.toJSDate(); - } - if (recur && recur.count) { - return recur.count; + get recurEnds(): Date | number | null { + const recur = this.getRecur(); + if (recur) { + if (recur.until) { + return recur.until.toJSDate(); + } + if (recur.count) { + return recur.count; + } } return null; } set recurEnds(end: Date | number) { - const recur = this.event.component.getFirstPropertyValue('rrule'); - recur.until = null; - recur.count = null; - if (typeof end === 'number' && end != null) { - recur.count = end; - } - if (end instanceof Date && end != null) { - // Must be a date (cant do typeof === 'Date' !? - const zone = this.event.startDate.zone; - const icaltime = ICAL.Time.fromJSDate(end); - icaltime.zone = zone; - recur.until = icaltime; + const recur = this.getRecur(); + if (recur) { + recur.until = null; + recur.count = null; + if (typeof end === 'number') { + recur.count = end; + } + if (end instanceof Date) { + // Must be a date (cant do typeof === 'Date' !? + const zone = this.event.startDate.zone; + const icaltime = ICAL.Time.fromJSDate(end); + icaltime.zone = zone; + recur.until = icaltime; + } + // else, everything null, repeats forever. + this.event.component.updatePropertyWithValue('rrule', recur); } - // else, everything null, repeats forever. - this.event.component.updatePropertyWithValue('rrule', recur); } // get "part"s, eg byday, bymonthday, bymonth, byyearday, byweekno @@ -275,72 +352,80 @@ export class RunboxCalendarEvent implements CalendarEvent { // One or more days of the week this rule could run on (or none) // SU, MO etc (or can convert to days of week using .icalDayToNumericDay // if a monthly BYDAY, could be "\+?1SU" or "-2TU" etc - get recursByDay(): string[] { - const recur = this.event.component.getFirstPropertyValue('rrule'); + get recursByDay(): { day: string; numth: string }[] { + const recur = this.getRecur(); if (!recur) { return []; } const bydays = recur.getComponent('byday'); const rgx = /^([+-]?\d+)?(\w{2})$/; - const bydays_mapped = bydays.map((day) => { + const bydays_mapped = bydays.map((day: string) => { const match = day.match(rgx); - const numth = match[1] ? match[1] : '0'; - return { 'day': match[2], 'numth': numth }; + const numth = match && match[1] ? match[1] : '0'; + return { 'day': match && match[2] ? match[2] : '', 'numth': numth }; }); return bydays_mapped; } // replace entire list of day(s) set recursByDay(value: string[]) { - const recur = this.event.component.getFirstPropertyValue('rrule'); - recur.setComponent('byday', value); - this.event.component.updatePropertyWithValue('rrule', recur); + const recur = this.getRecur(); + if (recur) { + recur.setComponent('byday', value); + this.event.component.updatePropertyWithValue('rrule', recur); + } } get recursByMonthDay(): string[] { - const recur = this.event.component.getFirstPropertyValue('rrule'); + const recur = this.getRecur(); if (!recur) { return []; } const bymonthdays = recur.getComponent('bymonthday'); - return bymonthdays.map((day) => day.toString()); + return bymonthdays.map((day: string) => day.toString()); } set recursByMonthDay(value: string[]) { - const recur = this.event.component.getFirstPropertyValue('rrule'); - recur.setComponent('bymonthday', value); - this.event.component.updatePropertyWithValue('rrule', recur); + const recur = this.getRecur(); + if (recur) { + recur.setComponent('bymonthday', value); + this.event.component.updatePropertyWithValue('rrule', recur); + } } get recursByYearDay(): string[] { - const recur = this.event.component.getFirstPropertyValue('rrule'); + const recur = this.getRecur(); if (!recur) { return []; } const byyeardays = recur.getComponent('byyearday'); - return byyeardays.map((day) => day.toString()); + return byyeardays.map((day: string) => day.toString()); } set recursByYearDay(value: string[]) { - const recur = this.event.component.getFirstPropertyValue('rrule'); - recur.setComponent('byyearday', value); - this.event.component.updatePropertyWithValue('rrule', recur); + const recur = this.getRecur(); + if (recur) { + recur.setComponent('byyearday', value); + this.event.component.updatePropertyWithValue('rrule', recur); + } } // Jan-Dec numbered 1-12 get recursByMonth(): string[] { - const recur = this.event.component.getFirstPropertyValue('rrule'); + const recur = this.getRecur(); if (!recur) { return []; } const bymonth = recur.getComponent('bymonth'); - return bymonth.map((month) => month.toString()); + return bymonth.map((month: string) => month.toString()); } set recursByMonth(value: string[]) { - const recur = this.event.component.getFirstPropertyValue('rrule'); - recur.setComponent('bymonth', value); - this.event.component.updatePropertyWithValue('rrule', recur); + const recur = this.getRecur(); + if (recur) { + recur.setComponent('bymonth', value); + this.event.component.updatePropertyWithValue('rrule', recur); + } } get isException(): boolean { @@ -398,30 +483,34 @@ export class RunboxCalendarEvent implements CalendarEvent { thisandfuture: boolean, title: string, description: string, - location: string): boolean { + location: string): boolean | undefined { if (this.isException) { // This shouldnt be possible console.log('Refusing to create an exception of an exception'); - return; + return false; } // clone existing one const new_exception = new ICAL.Event(ICAL.Component.fromString(this.event.toString())); new_exception.component.removeProperty('rrule'); const recurrence_id = origdate; - const new_start = this.momentToIcalTime(startdate, this.event.startDate.zone); - let new_end; + const new_start = this.momentToIcalTime(startdate, this.event.startDate.zone || ICAL.Timezone.utcTimezone); + let new_end: ICAL.Time | undefined; if (enddate) { - new_end = this.momentToIcalTime(enddate, this.event.startDate.zone); + new_end = this.momentToIcalTime(enddate, this.event.startDate.zone || ICAL.Timezone.utcTimezone); } if (this._dtstart.isDate) { recurrence_id.isDate = true; new_start.isDate = true; - new_end.isDate = true; + if (new_end) { + new_end.isDate = true; + } } new_exception.recurrenceId = recurrence_id; if (thisandfuture) { const rId = new_exception.component.getFirstProperty('recurrence-id'); - rId.setParameter('range', 'THISANDFUTURE'); + if (rId) { + rId.setParameter('range', 'THISANDFUTURE'); + } } new_exception.startDate = new_start; if (new_end) { @@ -543,28 +632,28 @@ export class RunboxCalendarEvent implements CalendarEvent { get_overview(): EventOverview[] { const events = []; - const seen = {}; + const seen: { [key: string]: boolean } = {}; - for (let e of this.ical.getAllSubcomponents('vevent')) { - e = new ICAL.Event(e); + for (const component of this.ical.getAllSubcomponents('vevent')) { + const event = new ICAL.Event(component); // Skip duplicate uids (if defined), // to eliminate possible special cases in recurring events. - if (e.uid && seen[e.uid]) { + if (event.uid && seen[event.uid]) { continue; } else { - seen[e.uid] = true; + seen[event.uid] = true; } - const rrule = e.component.getFirstPropertyValue('rrule'); + const rrule = event.component.getFirstPropertyValue('rrule'); events.push(new EventOverview( - e.summary, - this.icalTimeToMoment(e.startDate), - e.endDate ? this.icalTimeToMoment(e.endDate) : undefined, - rrule ? rrule.freq : undefined, - e.location, - e.description, + event.summary, + this.icalTimeToMoment(event.startDate), + event.endDate ? this.icalTimeToMoment(event.endDate) : undefined, + (rrule instanceof ICAL.Recur) ? rrule.freq : undefined, + event.location, + event.description, )); } @@ -584,14 +673,58 @@ export class RunboxCalendarEvent implements CalendarEvent { return moment(time.toString()); } else { // Assemble a moment with the UTC Date() and the user's timezone - let my_timezone = time.zone && time.zone.component ? time.zone.component.getFirstPropertyValue('x-lic-location') : null; - my_timezone = my_timezone || this.timezone || moment.tz.guess(); + let my_timezone: string | null = time.zone && time.zone.component ? time.zone.component.getFirstPropertyValue('x-lic-location') as string : null; + my_timezone = my_timezone && moment.tz.zone(my_timezone) + ? my_timezone + : this.resolveTimezoneName(); const m = moment(time.toJSDate()).tz(my_timezone); return m; } } - private momentToIcalTime(input: moment.Moment, zone: ICAL.Timezone): ICAL.Time { + /** Resolve a usable IANA timezone name for moment-timezone. */ + private resolveTimezoneName(): string { + if (this.timezone && moment.tz.zone(this.timezone)) { + return this.timezone; + } + // Extract IANA from path-style TZIDs: /citadel.org/.../Europe/Oslo → Europe/Oslo + if (this.timezone && this.timezone.includes('/')) { + const iana = this.timezone.split('/').slice(-2).join('/'); + if (moment.tz.zone(iana)) { + return iana; + } + } + return moment.tz.guess(); + } + + /** Safely get the RRULE as an ICAL.Recur, or null if missing/not a Recur instance. */ + private getRecur(): ICAL.Recur | null { + const recur = this.event.component.getFirstPropertyValue('rrule'); + return (recur && recur instanceof ICAL.Recur) ? recur : null; + } + + /** Get the account timezone from ICAL.TimezoneService, or null if unset/unregistered. */ + private getAccountTimezone(): ICAL.Timezone | null { + return this.timezone ? ICAL.TimezoneService.get(this.timezone) : null; + } + + private momentToIcalTime(input: moment.Moment, zone: ICAL.Timezone | null | undefined): ICAL.Time { + if (this._allDay) { + // All-day events: use local date parts to preserve the calendar date + // regardless of the UTC offset. Midnight local May 1 must store day=1, + // not day=30 (which would result from extracting UTC date parts). + const d = input.toDate(); + const ical_time = ICAL.Time.fromJSDate(d); + ical_time.isDate = true; + const accountTz = this.getAccountTimezone(); + if (accountTz) { + ical_time.zone = accountTz; + if (!this.ical.getFirstSubcomponent('vtimezone')) { + this.ical.addSubcomponent(accountTz.component); + } + } + return ical_time; + } // No supplied tz = new, or original didnt have one: // (Is it legit to have dates with tzs and without in same ical?) if (!zone || zone.tzid === 'floating') { @@ -607,18 +740,20 @@ export class RunboxCalendarEvent implements CalendarEvent { ical_time.isDate = this._allDay; return ical_time; } - // input is date in user timezone, convert to target timezone - const ical_tztime = ICAL.Time.fromJSDate(input.toDate()); - if (ICAL.TimezoneService.has(this.timezone)) { - ical_tztime.zone = ICAL.TimezoneService.get(this.timezone); - ical_tztime.isDate = this._allDay; - return ical_tztime.convertToZone(zone); - } else { - // Hmm this (should?) only get hit if zone wasnt loaded - // eg in tests!? - ical_tztime.zone = zone; - ical_tztime.isDate = this._allDay; - return ical_tztime; - } + // input is date in browser-local time, convert to target timezone via UTC. + // Using UTC as intermediate avoids double-conversion when browser tz + // differs from account tz (the moment's UTC representation is always correct). + const d = input.toDate(); + const ical_tztime = new ICAL.Time({ + year: d.getUTCFullYear(), + month: d.getUTCMonth() + 1, + day: d.getUTCDate(), + hour: d.getUTCHours(), + minute: d.getUTCMinutes(), + second: d.getUTCSeconds() + }); + ical_tztime.zone = ICAL.Timezone.utcTimezone; + ical_tztime.isDate = this._allDay; + return ical_tztime.convertToZone(zone); } } 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..ca0bcd9f9 --- /dev/null +++ b/src/app/signup/signup.component.html @@ -0,0 +1,339 @@ + + + diff --git a/src/app/signup/signup.component.scss b/src/app/signup/signup.component.scss new file mode 100644 index 000000000..fdd78b9b6 --- /dev/null +++ b/src/app/signup/signup.component.scss @@ -0,0 +1,825 @@ +/* --------- 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: #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; + width: fit-content; + max-width: 100%; +} + +.field-label-text { + display: inline; +} + +.field-help-row { + display: inline-flex; + align-items: center; + gap: 0.35rem; + width: fit-content; + max-width: 100%; +} + +.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: #d9ebf8; + color: #005897; + 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: #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(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: #0f2740; + 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 #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 { + 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..b96936326 --- /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.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..91cf00c6f --- /dev/null +++ b/src/app/signup/signup.component.ts @@ -0,0 +1,320 @@ +// --------- 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 = ''; + 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; + + 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..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..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 {