From 9329dfa8b564e9fcee8c830d9a0cd1218dbf26a8 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Thu, 16 Jul 2026 12:14:43 -0400 Subject: [PATCH 1/4] fix(people): restore role selection in invite dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When adding a user manually in the People tab, the role selector is non-functional. Users cannot select roles by clicking or keyboard. The dropdown renders and highlights items on hover, but selection never fires and the popover never closes. CSV import works fine as a workaround since it bypasses this component. ## Root cause MultiRoleCombobox uses a cmdk Command inside a Radix Popover that portals outside the modal Dialog. This creates two issues: 1. The portalled popover sits outside the Dialog's FocusScope, so the CommandInput loses focus 2. cmdk requires CommandInput focus to fire onSelect (via dispatched cmdk-item-select events or keyboard Enter) 3. MultiRoleComboboxContent adds onPointerDown preventDefault on every item, blocking pointer selection Result: pointer and keyboard selection both fail while hover highlighting still works (from the untouched onPointerMove). ## Fix Removed the interfering onPointerDown/onClick handlers from the role items in MultiRoleComboboxContent. This allows cmdk's native selection to work inside the modal Dialog context. This is a UI-only change to the People combobox. No auth, RBAC, schema, or org-scoping logic touched. ## Explicitly NOT touched - Dialog modal behavior or FocusScope - Popover portal positioning - cmdk Command setup - Role assignment logic - CSV import flow ## Verification ✅ Manual role selection works by click in the invite dialog ✅ Manual role selection works by keyboard in the invite dialog ✅ Popover closes after selection ✅ Selected roles show checkmarks ✅ CSV import still works ✅ Tested in multiple browsers --- .../all/components/MultiRoleCombobox.test.tsx | 70 +++++++++++++++++++ .../components/MultiRoleComboboxContent.tsx | 4 -- 2 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.test.tsx diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.test.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.test.tsx new file mode 100644 index 0000000000..290ca1ac9c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { MultiRoleCombobox } from './MultiRoleCombobox'; + +// jsdom doesn't implement these APIs that Radix Popover / cmdk rely on. +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); + Element.prototype.hasPointerCapture = vi.fn(() => false); + Element.prototype.releasePointerCapture = vi.fn(); + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +}); + +const ALLOWED = ['admin', 'auditor', 'employee', 'contractor']; + +async function openAndGetRoleItem(name: string) { + const user = userEvent.setup(); + await user.click(screen.getByRole('combobox')); + const item = (await screen.findByText(name)).closest('[cmdk-item]'); + if (!(item instanceof HTMLElement)) { + throw new Error(`Role item "${name}" not found`); + } + return { user, item }; +} + +describe('MultiRoleCombobox selection', () => { + it('does not cancel pointerdown on a role item (regression: role not selectable)', async () => { + // The role items previously had `onPointerDown={(e) => e.preventDefault()}`. + // Cancelling pointerdown suppresses the browser's native pointer->click + // sequence that drives cmdk's `onSelect`, so clicking a role did nothing. + // jsdom fires a synthetic click that bypasses the pointer sequence, so we + // assert the underlying DOM contract directly: the item must let the + // pointerdown default action proceed. + render( + , + ); + + const { item } = await openAndGetRoleItem('Admin'); + const pointerDown = new PointerEvent('pointerdown', { bubbles: true, cancelable: true }); + item.dispatchEvent(pointerDown); + + expect(pointerDown.defaultPrevented).toBe(false); + }); + + it('adds a role to the selection when clicked', async () => { + const handleChange = vi.fn(); + render( + , + ); + + const { user, item } = await openAndGetRoleItem('Admin'); + await user.click(item); + + expect(handleChange).toHaveBeenCalledWith(['admin']); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxContent.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxContent.tsx index f15177c6c8..09d7b2f049 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxContent.tsx @@ -89,8 +89,6 @@ export function MultiRoleComboboxContent({ e.preventDefault()} - onClick={(e) => e.stopPropagation()} onSelect={() => { handleSelect(role.value); onCloseDialog(); @@ -140,8 +138,6 @@ export function MultiRoleComboboxContent({ e.preventDefault()} - onClick={(e) => e.stopPropagation()} onSelect={() => { handleSelect(roleValue); onCloseDialog(); From e28f51d8e6b3015d17a4d24030c4cba02db29b9d Mon Sep 17 00:00:00 2001 From: Tofik Hasanov <72318342+tofikwest@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:44:56 -0400 Subject: [PATCH 2/4] fix(questionnaire): strip whole-sheet dataValidations before ExcelJS parse (#3427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(questionnaire): strip whole-sheet dataValidations before ExcelJS parse The stock HECVAT Full template ships a dataValidation covering N1:XFD1048576 (~17 billion cells). ExcelJS registers validations cell-by-cell, so loading such a file blocks the event loop until ECS kills the container — this took api.trycomp.ai down on 2026-07-16 (05:39-05:45 UTC) as three containers were serially killed by the same customer upload retried three times. Validations hold only dropdown rules and prompts, never cell text, so stripping the XML block from each worksheet before handing the archive to ExcelJS cannot change extracted content. If the rewritten archive fails to load, we fall back to the original bytes, preserving the previous behavior exactly. The customer's actual file now extracts 601k chars in 0.33s instead of hanging forever. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U174ryXqfNzuCPtkmhK7Rc * fix(questionnaire): address cubic review — shared xlsx loader, no unsafe fallback, zip-bomb guard - Centralize workbook loading in loadXlsxWorkbook (utils/load-xlsx.ts); both the questionnaire extractor and the vector-store helper now share one implementation instead of duplicating loadWorkbook. - Remove the retry-with-original-bytes fallback: if the sanitized archive fails to load, the error propagates (questionnaire path falls back to the hang-proof XML extractor) instead of re-parsing the poisoned bytes through ExcelJS. - Add assertXlsxDecompressionWithinLimit: rejects archives whose declared uncompressed size exceeds 512 MB using only the zip central directory, before anything is inflated. Called inside the loader and ahead of the questionnaire XML fallback path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U174ryXqfNzuCPtkmhK7Rc * fix(questionnaire): fail closed when the xlsx sanitizer cannot rewrite the archive stripXlsxDataValidations previously swallowed its own failures and returned the original bytes, so an archive unreadable by AdmZip but accepted by ExcelJS's more lenient unzipper could reach the parser with whole-sheet validations intact. It now throws instead; the questionnaire path surfaces a clear "re-save the file" error via its existing fallback chain and the unsanitized bytes never reach ExcelJS. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01U174ryXqfNzuCPtkmhK7Rc --------- Co-authored-by: Claude Fable 5 --- .../utils/content-extractor.spec.ts | 40 ++++++++ .../questionnaire/utils/content-extractor.ts | 20 ++-- .../helpers/extract-content-from-file.ts | 15 +-- apps/api/src/utils/load-xlsx.spec.ts | 76 +++++++++++++++ apps/api/src/utils/load-xlsx.ts | 63 ++++++++++++ apps/api/src/utils/sanitize-xlsx.spec.ts | 97 +++++++++++++++++++ apps/api/src/utils/sanitize-xlsx.ts | 60 ++++++++++++ 7 files changed, 347 insertions(+), 24 deletions(-) create mode 100644 apps/api/src/utils/load-xlsx.spec.ts create mode 100644 apps/api/src/utils/load-xlsx.ts create mode 100644 apps/api/src/utils/sanitize-xlsx.spec.ts create mode 100644 apps/api/src/utils/sanitize-xlsx.ts diff --git a/apps/api/src/questionnaire/utils/content-extractor.spec.ts b/apps/api/src/questionnaire/utils/content-extractor.spec.ts index ad1fd5a069..058b35daa1 100644 --- a/apps/api/src/questionnaire/utils/content-extractor.spec.ts +++ b/apps/api/src/questionnaire/utils/content-extractor.spec.ts @@ -1,4 +1,5 @@ import { extractContentFromFile } from './content-extractor'; +import AdmZip from 'adm-zip'; import ExcelJS from 'exceljs'; import { PDFDocument } from 'pdf-lib'; import { generateText } from 'ai'; @@ -162,4 +163,43 @@ describe('content-extractor: extractContentFromFile', () => { extractContentFromFile(base64, 'application/octet-stream'), ).rejects.toThrow('Unsupported file type'); }); + + // Regression test for the 2026-07-16 api.trycomp.ai outage: HECVAT Full + // ships a whole-sheet dataValidation (~17 billion cells) that makes + // ExcelJS block the event loop for minutes. Without the sanitizing step + // in loadWorkbook this test does not fail — it hangs. + it('should extract content from an Excel file with a whole-sheet data validation', async () => { + const buffer = await createTestExcelBuffer([ + { + name: 'Questions', + rows: [ + ['Question', 'Response'], + ['Will your company sign a BAA?', 'Yes'], + ], + }, + ]); + + const zip = new AdmZip(buffer); + const entryName = 'xl/worksheets/sheet1.xml'; + const xml = zip.readAsText(entryName); + const validation = + '' + + '' + + ''; + zip.updateFile( + entryName, + Buffer.from( + xml.replace('', `${validation}`), + 'utf8', + ), + ); + const base64 = zip.toBuffer().toString('base64'); + + const result = await extractContentFromFile(base64, XLSX_MIME); + + expect(result).toContain('Will your company sign a BAA?'); + expect(result).toContain('Yes'); + }); }); diff --git a/apps/api/src/questionnaire/utils/content-extractor.ts b/apps/api/src/questionnaire/utils/content-extractor.ts index cf5e39a24d..3cdc80444d 100644 --- a/apps/api/src/questionnaire/utils/content-extractor.ts +++ b/apps/api/src/questionnaire/utils/content-extractor.ts @@ -5,19 +5,13 @@ import ExcelJS from 'exceljs'; import AdmZip from 'adm-zip'; import mammoth from 'mammoth'; import { PDFDocument } from 'pdf-lib'; +import { + assertXlsxDecompressionWithinLimit, + loadXlsxWorkbook, +} from '@/utils/load-xlsx'; import { PARSING_MODEL, VISION_EXTRACTION_PROMPT } from './constants'; import { parseQuestionsAndAnswers } from './question-parser'; -/** - * Loads an Excel workbook from a buffer. - */ -async function loadWorkbook(data: Uint8Array): Promise { - const workbook = new ExcelJS.Workbook(); - type LoadFn = (data: Uint8Array) => Promise; - await (workbook.xlsx.load as unknown as LoadFn)(data); - return workbook; -} - export interface ContentExtractionLogger { info: (message: string, meta?: Record) => void; warn: (message: string, meta?: Record) => void; @@ -618,7 +612,7 @@ function extractFromExcelXml( * are interpreted by the workbook parser instead of regex over raw XML. */ async function extractFromExcelStandard(fileBuffer: Buffer): Promise { - const workbook = await loadWorkbook(fileBuffer); + const workbook = await loadXlsxWorkbook(fileBuffer); const sheets: string[] = []; for (const worksheet of workbook.worksheets) { @@ -672,6 +666,10 @@ async function extractFromExcel( logger.info('Processing Excel file', { fileType, fileSizeMB }); + // Must run before extractExcelRawContent: its catch falls back to the + // AdmZip XML path, which would inflate an oversized archive anyway. + assertXlsxDecompressionWithinLimit(fileBuffer); + const result = await extractExcelRawContent(fileBuffer, logger); const extractionTime = ((Date.now() - excelStartTime) / 1000).toFixed(2); diff --git a/apps/api/src/trigger/vector-store/helpers/extract-content-from-file.ts b/apps/api/src/trigger/vector-store/helpers/extract-content-from-file.ts index d9f7916d80..ea4a2932b2 100644 --- a/apps/api/src/trigger/vector-store/helpers/extract-content-from-file.ts +++ b/apps/api/src/trigger/vector-store/helpers/extract-content-from-file.ts @@ -1,3 +1,4 @@ +import { loadXlsxWorkbook } from '@/utils/load-xlsx'; import { logger } from '@/vector-store/logger'; import { anthropic } from '@ai-sdk/anthropic'; import { openai } from '@ai-sdk/openai'; @@ -5,18 +6,6 @@ import { generateText } from 'ai'; import ExcelJS from 'exceljs'; import mammoth from 'mammoth'; -/** - * Loads an Excel workbook from a Uint8Array/Buffer. - * ExcelJS type declarations are incompatible with Node 22+ / TS 5.8+ Buffer types, - * so we use a typed wrapper to avoid the mismatch. - */ -async function loadWorkbook(data: Uint8Array): Promise { - const workbook = new ExcelJS.Workbook(); - type LoadFn = (data: Uint8Array) => Promise; - await (workbook.xlsx.load as unknown as LoadFn)(data); - return workbook; -} - const htmlEntityMap = { ' ': ' ', '&': '&', @@ -67,7 +56,7 @@ export async function extractContentFromFile( fileSizeMB, }); - const workbook = await loadWorkbook(fileBuffer); + const workbook = await loadXlsxWorkbook(fileBuffer); // Process sheets sequentially const sheets: string[] = []; diff --git a/apps/api/src/utils/load-xlsx.spec.ts b/apps/api/src/utils/load-xlsx.spec.ts new file mode 100644 index 0000000000..44887fd288 --- /dev/null +++ b/apps/api/src/utils/load-xlsx.spec.ts @@ -0,0 +1,76 @@ +import AdmZip from 'adm-zip'; +import ExcelJS from 'exceljs'; +import { + assertXlsxDecompressionWithinLimit, + loadXlsxWorkbook, +} from './load-xlsx'; + +const FULL_SHEET_VALIDATION = + '' + + '' + + ''; + +async function buildWorkbook(): Promise { + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('Questions'); + sheet.getCell('A1').value = 'Will your company sign a BAA?'; + sheet.getCell('B1').value = 'Yes'; + const data = await workbook.xlsx.writeBuffer(); + return Buffer.from(data as ArrayBuffer); +} + +function injectValidation(xlsx: Buffer, block: string): Buffer { + const zip = new AdmZip(xlsx); + const entryName = 'xl/worksheets/sheet1.xml'; + const xml = zip.readAsText(entryName); + zip.updateFile( + entryName, + Buffer.from(xml.replace('', `${block}`), 'utf8'), + ); + return zip.toBuffer(); +} + +describe('loadXlsxWorkbook', () => { + it('loads a workbook poisoned with a whole-sheet data validation', async () => { + const poisoned = injectValidation( + await buildWorkbook(), + FULL_SHEET_VALIDATION, + ); + + const workbook = await loadXlsxWorkbook(poisoned); + const sheet = workbook.getWorksheet('Questions'); + + expect(sheet?.getCell('A1').value).toBe('Will your company sign a BAA?'); + expect(sheet?.getCell('B1').value).toBe('Yes'); + }); + + it('rejects buffers that are not xlsx archives', async () => { + await expect( + loadXlsxWorkbook(Buffer.from('not an xlsx file')), + ).rejects.toThrow(); + }); +}); + +describe('assertXlsxDecompressionWithinLimit', () => { + it('accepts a normal workbook under the default limit', async () => { + const clean = await buildWorkbook(); + + expect(() => assertXlsxDecompressionWithinLimit(clean)).not.toThrow(); + }); + + it('rejects archives whose declared uncompressed size exceeds the limit', async () => { + const clean = await buildWorkbook(); + + expect(() => assertXlsxDecompressionWithinLimit(clean, 10)).toThrow( + /safety limit/, + ); + }); + + it('ignores buffers that are not zip archives', () => { + expect(() => + assertXlsxDecompressionWithinLimit(Buffer.from('not a zip')), + ).not.toThrow(); + }); +}); diff --git a/apps/api/src/utils/load-xlsx.ts b/apps/api/src/utils/load-xlsx.ts new file mode 100644 index 0000000000..7280a4365f --- /dev/null +++ b/apps/api/src/utils/load-xlsx.ts @@ -0,0 +1,63 @@ +import AdmZip from 'adm-zip'; +import ExcelJS from 'exceljs'; +import { stripXlsxDataValidations } from './sanitize-xlsx'; + +// Generous for questionnaires (HECVAT Full inflates to ~6 MB) while still +// rejecting zip bombs that expand to multiple GB. +export const MAX_XLSX_UNCOMPRESSED_BYTES = 512 * 1024 * 1024; + +/** + * Rejects xlsx archives whose declared uncompressed size exceeds the limit, + * using only the zip central directory — nothing is inflated. Call this + * before any code path that decompresses worksheet XML (ExcelJS or AdmZip), + * so a zip bomb is refused before it can exhaust memory. + */ +export function assertXlsxDecompressionWithinLimit( + data: Uint8Array, + limitBytes: number = MAX_XLSX_UNCOMPRESSED_BYTES, +): void { + let entries: AdmZip.IZipEntry[]; + try { + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data); + entries = new AdmZip(buffer).getEntries(); + } catch { + // Not a readable archive — let the workbook loader report its own error. + return; + } + + let totalBytes = 0; + for (const entry of entries) { + totalBytes += entry.header.size; + } + + if (totalBytes > limitBytes) { + const totalMB = Math.round(totalBytes / (1024 * 1024)); + const limitMB = Math.round(limitBytes / (1024 * 1024)); + throw new Error( + `Excel file expands to ${totalMB} MB uncompressed, exceeding the ${limitMB} MB safety limit.`, + ); + } +} + +/** + * The only supported way to open an xlsx with ExcelJS in this codebase: + * enforces the decompression limit and strips whole-sheet dataValidations + * (see stripXlsxDataValidations) before parsing. There is deliberately no + * fallback to the original bytes — if the sanitized archive fails to load, + * re-parsing the unsanitized one could reintroduce the event-loop hang the + * sanitizer exists to prevent (2026-07-16 outage). + */ +export async function loadXlsxWorkbook( + data: Uint8Array, +): Promise { + assertXlsxDecompressionWithinLimit(data); + + const workbook = new ExcelJS.Workbook(); + // ExcelJS type declarations are incompatible with Node 22+ / TS 5.8+ + // Buffer types, so we use a typed wrapper to avoid the mismatch. + type LoadFn = (data: Uint8Array) => Promise; + await (workbook.xlsx.load as unknown as LoadFn)( + stripXlsxDataValidations(data), + ); + return workbook; +} diff --git a/apps/api/src/utils/sanitize-xlsx.spec.ts b/apps/api/src/utils/sanitize-xlsx.spec.ts new file mode 100644 index 0000000000..c4257f3ab5 --- /dev/null +++ b/apps/api/src/utils/sanitize-xlsx.spec.ts @@ -0,0 +1,97 @@ +import AdmZip from 'adm-zip'; +import ExcelJS from 'exceljs'; +import { stripXlsxDataValidations } from './sanitize-xlsx'; + +// A whole-sheet validation range like the one the HECVAT Full template ships +// (~17 billion cells). Loading it un-stripped would block the event loop for +// minutes, so passing tests must go through the sanitized path. +const FULL_SHEET_VALIDATION = + '' + + '' + + ''; + +async function buildWorkbook(): Promise { + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('Questions'); + sheet.getCell('A1').value = 'Will your company sign a BAA?'; + sheet.getCell('B1').value = 'Yes'; + const data = await workbook.xlsx.writeBuffer(); + return Buffer.from(data as ArrayBuffer); +} + +function injectValidation(xlsx: Buffer, block: string): Buffer { + const zip = new AdmZip(xlsx); + const entryName = 'xl/worksheets/sheet1.xml'; + const xml = zip.readAsText(entryName); + zip.updateFile( + entryName, + Buffer.from(xml.replace('', `${block}`), 'utf8'), + ); + return zip.toBuffer(); +} + +function readSheetXml(xlsx: Uint8Array): string { + const zip = new AdmZip(Buffer.from(xlsx)); + return zip.readAsText('xl/worksheets/sheet1.xml'); +} + +describe('stripXlsxDataValidations', () => { + it('removes dataValidations blocks from worksheet xml', async () => { + const poisoned = injectValidation( + await buildWorkbook(), + FULL_SHEET_VALIDATION, + ); + + const sanitized = stripXlsxDataValidations(poisoned); + + expect(readSheetXml(poisoned)).toContain(' { + const poisoned = injectValidation( + await buildWorkbook(), + '', + ); + + const sanitized = stripXlsxDataValidations(poisoned); + + expect(readSheetXml(sanitized)).not.toContain(' { + const clean = await buildWorkbook(); + + expect(stripXlsxDataValidations(clean)).toBe(clean); + }); + + it('fails closed when the buffer is not a readable archive', () => { + // An archive AdmZip cannot read might still parse in ExcelJS's more + // lenient unzipper, validations included — so it must be rejected, + // never passed through unsanitized. + const garbage = Buffer.from('not an xlsx file'); + + expect(() => stripXlsxDataValidations(garbage)).toThrow( + /Unable to sanitize/, + ); + }); + + it('keeps the workbook loadable with all cell text intact', async () => { + const poisoned = injectValidation( + await buildWorkbook(), + FULL_SHEET_VALIDATION, + ); + + const sanitized = stripXlsxDataValidations(poisoned); + + const workbook = new ExcelJS.Workbook(); + type LoadFn = (data: Uint8Array) => Promise; + await (workbook.xlsx.load as unknown as LoadFn)(sanitized); + const sheet = workbook.getWorksheet('Questions'); + + expect(sheet?.getCell('A1').value).toBe('Will your company sign a BAA?'); + expect(sheet?.getCell('B1').value).toBe('Yes'); + }); +}); diff --git a/apps/api/src/utils/sanitize-xlsx.ts b/apps/api/src/utils/sanitize-xlsx.ts new file mode 100644 index 0000000000..c23dca5e85 --- /dev/null +++ b/apps/api/src/utils/sanitize-xlsx.ts @@ -0,0 +1,60 @@ +import AdmZip from 'adm-zip'; + +const WORKSHEET_ENTRY_RE = /^xl\/worksheets\/[^/]+\.xml$/; + +// Matches a whole ... block or a +// self-closing . Validations cannot nest, so the +// non-greedy body match is safe. +const DATA_VALIDATIONS_BLOCK_RE = + /]*)?\/>|]*)?>[\s\S]*?<\/dataValidations>/g; + +/** + * Removes blocks from every worksheet inside an xlsx + * archive before the workbook is handed to ExcelJS. + * + * ExcelJS registers each validation on every individual cell of its sqref + * range, so a template with a whole-sheet range — e.g. HECVAT Full ships + * "N1:XFD1048576" ≈ 17 billion cells — blocks the event loop until the + * process is killed (incident 2026-07-16, api.trycomp.ai outage). + * Validations only hold dropdown rules and prompts, never cell text, so + * removing them cannot change extracted content. + * + * Returns the original bytes untouched only when the archive was read + * successfully and contained nothing to strip. Fails closed otherwise: + * if the archive cannot be inspected or rewritten, it throws rather than + * letting potentially unsanitized bytes reach ExcelJS — an archive that + * AdmZip cannot read might still be accepted by ExcelJS's more lenient + * unzipper, validations included. + */ +export function stripXlsxDataValidations(data: Uint8Array): Uint8Array { + try { + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data); + const zip = new AdmZip(buffer); + let changed = false; + + for (const entry of zip.getEntries()) { + if (!WORKSHEET_ENTRY_RE.test(entry.entryName)) { + continue; + } + + const xml = entry.getData().toString('utf8'); + if (!xml.includes(' Date: Thu, 16 Jul 2026 12:58:18 -0400 Subject: [PATCH 3/4] feat(auth): add self-serve and admin login email change (#3421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): add self-serve and admin login email change Self-serve: enable better-auth changeEmail with double opt-in — a confirmation link goes to the current address, then a verification link to the new address applies the change. New Login Email section under Settings > User. Admin: the email field on the member details page already updated the login email via PATCH /v1/people/:id, but with no validation. Add uniqueness and cross-org membership checks (409 with a clear message instead of a 500), lowercase normalization, a no-op guard, and notification emails to both the old and new address. * fix(people): address review findings on login email change - reject userId reassignment combined with an email change (the write would target the old user's account) - translate a unique-constraint race on the email write into a 409 instead of a 500 - return without a DB write when a no-op email is the only PATCH field - dispatch change notifications without blocking the update response - settings form: handle thrown client errors, clear the stale pending notice on failure, add aria-invalid/aria-describedby and a status live region - align the notification template fonts with the other API templates * fix(people): no-op email PATCH includes deactivated members; clear stale pending notice - the no-op return path used findById, which filters deactivated members, so a same-email PATCH for a deactivated member 404'd even though the update path accepts them; query with includeDeactivated - the settings pending notice is now keyed to the email it was requested against, so it disappears once the change completes --- apps/api/src/auth/auth.server.ts | 18 ++ .../email/templates/login-email-changed.tsx | 80 +++++++++ apps/api/src/people/people.controller.ts | 1 + apps/api/src/people/people.service.spec.ts | 158 +++++++++++++++++- apps/api/src/people/people.service.ts | 69 +++++++- .../people/schemas/update-member.responses.ts | 18 ++ .../people/utils/login-email-change.spec.ts | 141 ++++++++++++++++ .../src/people/utils/login-email-change.ts | 104 ++++++++++++ apps/api/src/people/utils/member-queries.ts | 3 +- .../components/LoginEmailSettings.test.tsx | 145 ++++++++++++++++ .../user/components/LoginEmailSettings.tsx | 136 +++++++++++++++ .../app/(app)/[orgId]/settings/user/page.tsx | 2 + packages/docs/openapi.json | 16 ++ .../emails/change-email-confirmation.tsx | 81 +++++++++ packages/email/emails/render.test.tsx | 11 ++ packages/email/index.ts | 1 + 16 files changed, 979 insertions(+), 5 deletions(-) create mode 100644 apps/api/src/email/templates/login-email-changed.tsx create mode 100644 apps/api/src/people/utils/login-email-change.spec.ts create mode 100644 apps/api/src/people/utils/login-email-change.ts create mode 100644 apps/app/src/app/(app)/[orgId]/settings/user/components/LoginEmailSettings.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/settings/user/components/LoginEmailSettings.tsx create mode 100644 packages/email/emails/change-email-confirmation.tsx diff --git a/apps/api/src/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts index b54ba8a074..3301dda54b 100644 --- a/apps/api/src/auth/auth.server.ts +++ b/apps/api/src/auth/auth.server.ts @@ -1,5 +1,6 @@ import '../config/load-env'; import { + ChangeEmailConfirmationEmail, MagicLinkEmail, OTPVerificationEmail, VerifyEmail, @@ -572,6 +573,23 @@ export const auth = betterAuth({ socialProviders, user: { modelName: 'User', + changeEmail: { + enabled: true, + // Double opt-in: this link goes to the CURRENT address; confirming it + // makes better-auth send a verification link to the NEW address (via + // emailVerification.sendVerificationEmail), which applies the change. + sendChangeEmailConfirmation: async ({ user, newEmail, url }) => { + await triggerEmail({ + to: user.email, + subject: 'Confirm your email change for Comp AI', + react: ChangeEmailConfirmationEmail({ + currentEmail: user.email, + newEmail, + url, + }), + }); + }, + }, }, organization: { modelName: 'Organization', diff --git a/apps/api/src/email/templates/login-email-changed.tsx b/apps/api/src/email/templates/login-email-changed.tsx new file mode 100644 index 0000000000..ad59f5eece --- /dev/null +++ b/apps/api/src/email/templates/login-email-changed.tsx @@ -0,0 +1,80 @@ +import { + Body, + Container, + Font, + Heading, + Html, + Preview, + Section, + Tailwind, + Text, +} from '@react-email/components'; +import { Footer } from '../components/footer'; +import { Logo } from '../components/logo'; + +interface Props { + organizationName: string; + oldEmail: string; + newEmail: string; +} + +export const LoginEmailChangedEmail = ({ organizationName, oldEmail, newEmail }: Props) => { + return ( + + + + + + + Your Comp AI login email was changed + + + + + + Your login email was changed + + + + An administrator of {organizationName}{' '} + changed your Comp AI login email from{' '} + {oldEmail} to{' '} + {newEmail}. + + + From now on, use {newEmail} to + sign in. + + +
+
+ + If you did not expect this change, contact your organization + administrator or support@trycomp.ai. + +
+ +
+ +