feat(organization): screen capture off for new organizations, EEA/UK acknowledgement to turn it on

Merged
EV
15d ago
+3616-29

Summary

  • New organizations start with screen capture off (allowScreenshotCapture: false) unless the create request asks for it.
  • New employees in an organization that has screen capture off now start with capture off (before: the employee column default true), and starting or resuming an employee's work turns capture on only where the organization allows it (before: always on). A value the write states still wins at creation, and a stated "off" stays off even when the same write starts work; stored values are never rewritten outside those two moments; the desktop apps are unchanged. Organizations with capture on behave exactly as before.
  • Turning screen capture on for an organization in the EEA or the UK now takes the admin's explicit acknowledgement of three statements: a documented lawful basis, the employees' notice (GDPR Art. 13), and the works council / employee representative process. Without it the API answers 400 with the stable code SCREENSHOT_CAPTURE_ACKNOWLEDGEMENT_REQUIRED and details.missing.
  • The acknowledgement is recorded: who and when on the organization row, in the same write that turns capture on (screenshotCaptureAcknowledgedAt, screenshotCaptureAcknowledgedByUserId), plus an activity-log entry with the user, time, tenant, organization and statements.
  • The organization settings page asks for it in a dialog with three checkboxes, and sends it with the save. GraphQL accepts the same two members.
  • Existing organizations are unchanged. Nothing is backfilled, the column defaults are untouched, and every organization keeps the value it has today. Re-saving an organization that already captures, or turning capture off, asks nothing.

Why

Closes #9872. Screenshot capture of employees is high-risk processing in the EEA/UK (lawful basis, Art. 13 notice, and in several countries works council co-determination). Starting new organizations with capture off, and making the admin confirm those three points before switching it on in the EEA/UK, puts the decision with the controller and leaves a record of it. This complements the DPIA support pack from #11201.

Why new employees follow the organization (verified desktop behaviour)

The desktop apps decide on the employee flag:

  • Agent (apps/agent/src/main/util.ts, updateAgentSetting): allowScreenshotCapture: employee.allowScreenshotCapture ?? false, the employee flag only.
  • Timer (packages/desktop-lib/src/lib/desktop-screenshot.ts, allowScreenshotCapture()): auth.allowScreenshotCapture || auth.user.employee.allowScreenshotCapture, where auth.allowScreenshotCapture is "employee and organization" (time-tracker.component.ts). So the employee flag alone turns capture on.

With the employee column defaulting to true, and starting work setting it to true, an organization created with capture off would still capture as soon as it had working employees. EmployeeSubscriber now gives the employee the organization's value at those two moments, in the write's own transaction, under both ORMs. Every create path reaches it: REST POST /employee and /employee/bulk, GraphQL, the Add Employee form, invite accept, registration as employee, candidate hire, Hubstaff/Upwork imports, export-import and seeds.

What changed (by package)

@gauzy/contracts

  • screenshot-capture-acknowledgement.util.ts (new): the rule, shared by the API and the UI: DEFAULT_ALLOW_SCREENSHOT_CAPTURE_ON_NEW_ORGANIZATION = false, SCREENSHOT_CAPTURE_ACKNOWLEDGEMENT_STATEMENTS, newOrganizationAllowsScreenshotCapture, isScreenshotCaptureBeingEnabled, requiresScreenshotCaptureAcknowledgement, missingScreenshotCaptureAcknowledgements, checkScreenshotCaptureChange. It reuses the EEA/UK detection of #9873 (isEEAOrUKLocation).
  • IOrganization gains the two record fields and screenshotCaptureAcknowledgedByUser; IOrganizationCreateInput accepts screenshotCaptureAcknowledgement.

@gauzy/core

  • organization/screenshot-capture-acknowledgement.ts (new): applies the rule in the handlers. resolveScreenshotCaptureAcknowledgement (throws the 400 or returns the record), omitScreenshotCaptureAcknowledgementFields (a request can never set the record or store the request-only acknowledgement), logScreenshotCaptureAcknowledgement (activity log, best effort after the save).
  • OrganizationCreateHandler: default off, gate, record and log (the log entry is written outside the organization boundary, like the other post-create steps). A create whose id names an existing organization of the tenant is judged as the update it is (stored location counts, and the flag is written only when the request changes it). A contact the request names by id counts with the country it has.
  • OrganizationUpdateHandler: gate on the false-to-true change. The location counts as stored and as requested (so a top-level country, which the organization does not store, cannot take a UK/EEA organization out of the check). A contact the write links by id (contact.id or contactId) counts with the country it has. The flag is written only when the request changes it, so a save racing one that turns capture off cannot turn it back on without an acknowledgement.
  • employee/employee-screenshot-capture-default.ts (new) and EmployeeSubscriber: the employee rule above (screenshotCaptureFromOrganization, readOrganizationScreenshotCapture; MikroORM read without auto-flush). A stated "off" (false, or 0 / "0" / "false" from an import) is never turned on by the work-status rule.
  • InviteAcceptOrganizationContactHandler (public POST /invite/contact): the organization it creates always starts with capture off, because nobody could be recorded as having acknowledged it.
  • Organization entity: screenshotCaptureAcknowledgedAt, screenshotCaptureAcknowledgedByUser / ...UserId (FK to user, ON DELETE SET NULL), both read-only in Swagger. allowScreenshotCapture now has @IsOptional() @IsBoolean(). The relation is listed in the organization sensitive relations (ORG_USERS_VIEW).
  • DTOs: ScreenshotCaptureAcknowledgementDTO (three optional booleans, so a partial acknowledgement gets the stable code and details.missing), nested and optional on create and update.
  • GraphQL: allowScreenshotCapture and screenshotCaptureAcknowledgement: ScreenshotCaptureAcknowledgementInput on CreateOrganizationInput and UpdateOrganizationInput; the AcceptOrganizationContactInviteInput description states that screen capture is the one member not passed through; schema snapshot regenerated (additions only).
  • ApiErrorCode.SCREENSHOT_CAPTURE_ACKNOWLEDGEMENT_REQUIRED.
  • Migration 1791000003660-AddOrganizationScreenshotCaptureAcknowledgement for PostgreSQL, MySQL and SQLite: two nullable columns and the foreign key, idempotent, advisory lock on Postgres and a named lock on MySQL, down() drops them on Postgres and MySQL (SQLite keeps additive columns, like the other migrations).
  • Demo seeds set allowScreenshotCapture = true explicitly on the organizations, so demo data (and its employees) still show screenshots.

@gauzy/ui-core

  • shared: standalone ScreenshotCaptureAcknowledgementDialogComponent (three checkboxes, Confirm disabled until all are ticked, labelled close button).
  • core: ScreenshotCaptureAcknowledgementPrompt, which keeps a form in step with the rule: asks when the toggle is switched on, switches it back off on cancel, asks again at save if the acknowledgement is still needed (e.g. after a region change), forgets it when the toggle goes off.
  • i18n: seven keys in all 14 language files (translated; ach is English like its other keys).

apps/gauzy

  • Organization settings ("other settings"): wires the prompt and the dialog (reading the stored and the pending location, as the API does), sends the acknowledgement with the save, and shows the error toast when a save is refused (it was only logged to the console). The settings route now loads the organization's contact, so the page reads the same country as the API (this also fixes the EEA/UK read of the agent exit/logout settings on that page).

Flags/settings and defaults

SettingBeforeAfter
allowScreenshotCapture on an organization created through the API/UI (REST, GraphQL, bulk and integration imports all go through OrganizationCreateHandler)true (column default)false unless the request sends true
allowScreenshotCapture on existing organizationsunchangedunchanged (no backfill, column default unchanged)
allowScreenshotCapture on a new employee, organization with capture offtrue (column default)false, unless the write states a value
allowScreenshotCapture on a new employee, organization with capture ontruetrue (unchanged)
Starting or resuming work (Add Employee with a start date, Start Work, Back to Work), organization with capture offemployee flag set to trueemployee flag set to false
Starting or resuming work, organization with capture ontruetrue (unchanged)
A write that states allowScreenshotCapture: false and starts workemployee flag set to truestays false
Stored employee values, any other writeunchangedunchanged
Turning capture on, organization outside the EEA/UKallowedallowed, nothing asked (as before)
Turning capture on, organization in the EEA/UKallowedrequires screenshotCaptureAcknowledgement: { lawfulBasis, employeeNotice, worksCouncil } all true
Keeping capture on / turning it offallowedallowed, nothing asked
Organization created from a client (contact) invitetruefalse

No new environment variable or feature flag. The API change is additive: a client that does not send the acknowledgement keeps working everywhere except when it turns capture on for an EEA/UK organization.

Tests

All run locally in the worktree (Node 26.10, Windows).

  • node node_modules/jest/bin/jest.js -c packages/contracts/jest.config.ts --rootDir packages/contracts screenshot-capture-acknowledgement eea-uk-region → 2 suites, 34 tests passed.
  • NODE_OPTIONS=--experimental-vm-modules node node_modules/jest/bin/jest.js -c packages/core/jest.config.ts --rootDir packages/core "employee-screenshot-capture-default|employee.subscriber|employee.service.create-agent|agent-employee|organization.create.handler|organization.update.handler|organization.bulk|organization.resolver|screenshot-capture-acknowledgement|organization-screenshot-capture-acknowledgement-migration|invite.accept-organization-contact|graphql-cost|public-fields|graphql-organization-membership|invite.resolver|user-organization.resolver" → 19 suites, 392 tests passed.
    • employee rule (unit, both ORMs): new employee in an organization with capture off → off, on → on, a stated value kept, start date at creation follows the organization, organization from the relation, unknown organization or failed read changes nothing; start work / Back to Work (reading the stored employee's organization) follow the organization, a stated "off" stays off when the same write starts work (create and update), a write that turns the flag on itself keeps today's behaviour, end work still turns it off, other updates never read anything.
    • employee rule (real better-sqlite3 with the real subscriber): save, insert, the insert query builder and manager.save([...]); stated values kept, including false with a start date; tenant-scoped read; existing employees unchanged when the organization turns capture off and when edited; invited employee whose work starts later and Back to Work stay off in an organization with capture off, on where it is on. The suite fails 4 of its creation cases with the subscriber change removed.
    • migration spec: real run on better-sqlite3 (columns nullable, existing rows and their allowScreenshotCapture unchanged, FK sets NULL when the user is deleted, re-run no-op, down keeps), plus the exact Postgres and MySQL statements, the lock release on failure and idempotent down.
    • create handler: default off, explicit off kept, on outside EEA/UK as before, EEA without acknowledgement refused with the code, UK contact address refused, acknowledged create records who/when/tenant and logs outside the organization boundary, forged record fields ignored, an existing id is judged as an update (stored UK contact asks; an absent or unchanged flag is not written back; a change is), a contact named by id counts with its country and is not read when capture stays off. Shared helpers: the linked contact is read outside the organization boundary and only when it is not the current one; the current contact comes from the contactId column or the loaded relation.
    • update handler: EEA without acknowledgement refused and nothing saved, moving into the UK while turning on asks, a top-level country cannot skip a stored UK contact, a contact moving into the EEA asks, a UK contact linked by contact.id or contactId asks (and no contact is read when capture is not being turned on or the current one is linked), acknowledged change writes the record and the log, re-sending the stored value writes nothing (race), disabling asks nothing, outside EEA/UK unchanged, forged record fields ignored.
    • DTO: partial acknowledgement passes validation (nested too), non-boolean refused. GraphQL SDL: both inputs carry the two members, never the record fields.
    • invite: the contact's organization starts with capture off and drops forged record fields.
  • node node_modules/jest/bin/jest.js -c packages/ui-core/jest.config.ts --rootDir packages/ui-core screenshot-capture-acknowledgement → 2 suites, 17 tests passed (dialog, including the labelled close button, and prompt, including a stored EEA location with a pending one elsewhere).
  • tsc --noEmit on packages/contracts/tsconfig.lib.json, packages/core/tsconfig.lib.json, packages/core/tsconfig.spec.json and apps/gauzy/tsconfig.app.json → no errors.
  • GraphQL snapshot: composed with packages/core/src/lib/graphql/schema-snapshot.ts --stdout; the only difference from the committed snapshot is the 44 added lines.
  • ESLint on the touched files: no new errors (the reported errors are on untouched pre-existing lines).

Manual verification still needed

  • In the web app, open an EEA/UK organization (e.g. time zone Europe/Berlin, or a UK contact address) with capture off, switch "Allow screen capture" on: the dialog appears; cancel switches it back off; confirm, then save. Check the organization row and the activity log.
  • Same with a non-EEA organization: no dialog.
  • Create a new organization from the UI and check that capture is off; add an employee to it (with a start date, and by invite) and check the employee's flag is off; turn capture on for the organization and add another employee: on.
  • Run the migration against real PostgreSQL and MySQL (the SQLite path is covered by a real run in the spec; the Postgres/MySQL statements are pinned on a recording runner).

Risk and rollback

  • Low data risk: the migration only adds two nullable columns and a foreign key; no row is rewritten. Rollback is a revert plus down() on Postgres/MySQL; leaving the columns in place is also harmless.
  • Behaviour change: API clients that create organizations without allowScreenshotCapture now get capture off. Clients that turn capture on for an EEA/UK organization must send the acknowledgement (400 with a stable code otherwise).
  • Behaviour change for organizations with capture off: new employees, and employees whose work starts or resumes, get capture off instead of on. Where an organization with capture off wants a specific employee captured, the employee's own flag can still be turned on explicitly.
  • allowScreenshotCapture now has to be a boolean in the organization DTOs (a string such as "true" is refused by validation).

Owner decisions

  1. Desktop capture accepts the employee flag on its own: resolved here without touching the desktop apps, by giving employees the organization's value when the platform sets it (above).
  2. An organization that already captures and later moves into the EEA/UK is not asked: out of scope, follow-up below.
  3. The EEA/UK test uses the organization's region, time zone and contact country, as in #9873: confirmed.
  4. The database column defaults stay true (no SQLite table rebuild, no effect on existing rows); the defaults are applied by the create handler and the employee subscriber: confirmed.

Follow-ups (not in this PR)

  • Ask for the acknowledgement when an organization that already captures moves into the EEA/UK.

Review notes

  • Review threads are answered and resolved on the PR. Two findings were raised only in the Copilot review summaries, not as threads: activity-log entries for a new organization are now written outside the organization boundary (fixed), and contacts linked by id are now read (fixed).
  • Not changed: archive imports (ImportService) write organization rows as they are in the archive, including allowScreenshotCapture and the acknowledgement record. That is the documented position above (rows written outside the API keep what they carry, owner decision 4). An import restores a tenant's own records.

Closes #9872

🤖 Generated with Claude Code