diff --git a/.env.example b/.env.example index 962e1bcf..70e09915 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,7 @@ # MAX_PHOTO_SIZE_MB=5 # 1–50 MB per photo upload # MAX_PHOTOS_PER_BIN=1 # Max photos per bin (1–100) # ATTACHMENTS_ENABLED=true # Non-image file attachments on bins (PDFs, docs, archives — 5 MB cap). Set to false to disable. +# MARKETING_OPT_IN_VISIBLE=false # Cloud only. Show the optional "send me product updates" checkbox at signup. Set to true once OpenBin actually sends marketing email. # ── AI Provider (server-wide fallback) ──────── # When set, all users get AI features without configuring their own keys. diff --git a/server/schema.pg.sql b/server/schema.pg.sql index 84cc1f0c..57bf530e 100644 --- a/server/schema.pg.sql +++ b/server/schema.pg.sql @@ -22,6 +22,11 @@ CREATE TABLE IF NOT EXISTS users ( deletion_requested_at TEXT, deletion_scheduled_at TEXT, deletion_reason TEXT, + current_tos_version TEXT, + current_privacy_version TEXT, + marketing_opt_in BOOLEAN NOT NULL DEFAULT FALSE, + marketing_opt_in_at TEXT, + marketing_opt_out_at TEXT, created_at TEXT NOT NULL DEFAULT (NOW()), updated_at TEXT NOT NULL DEFAULT (NOW()) ); @@ -534,3 +539,17 @@ CREATE TABLE IF NOT EXISTS bin_usage_days ( ); CREATE INDEX IF NOT EXISTS idx_bin_usage_days_date ON bin_usage_days(date); CREATE INDEX IF NOT EXISTS idx_bin_usage_days_bin ON bin_usage_days(bin_id, date DESC); + +-- User consent audit trail (cloud only). See SQLite schema for full notes. +CREATE TABLE IF NOT EXISTS user_consents ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + document TEXT NOT NULL, + version TEXT NOT NULL, + accepted_at TEXT NOT NULL, + ip TEXT, + user_agent TEXT, + source TEXT NOT NULL, + UNIQUE(user_id, document, version) +); +CREATE INDEX IF NOT EXISTS idx_user_consents_user_id ON user_consents(user_id); diff --git a/server/schema.sqlite.sql b/server/schema.sqlite.sql index 229acf6d..caa1976c 100644 --- a/server/schema.sqlite.sql +++ b/server/schema.sqlite.sql @@ -22,6 +22,11 @@ CREATE TABLE IF NOT EXISTS users ( deletion_requested_at TEXT, deletion_scheduled_at TEXT, deletion_reason TEXT, + current_tos_version TEXT, + current_privacy_version TEXT, + marketing_opt_in INTEGER NOT NULL DEFAULT 0, + marketing_opt_in_at TEXT, + marketing_opt_out_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -517,3 +522,19 @@ CREATE TABLE IF NOT EXISTS bin_usage_days ( ); CREATE INDEX IF NOT EXISTS idx_bin_usage_days_date ON bin_usage_days(date); CREATE INDEX IF NOT EXISTS idx_bin_usage_days_bin ON bin_usage_days(bin_id, date DESC); + +-- User consent audit trail (cloud only). One row per (user, document, version) +-- written by recordConsent() in server/src/lib/consent.ts. Source values: +-- 'signup' | 'oauth_completion' | 'reaccept_modal' | 'backfill'. +CREATE TABLE IF NOT EXISTS user_consents ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + document TEXT NOT NULL, + version TEXT NOT NULL, + accepted_at TEXT NOT NULL, + ip TEXT, + user_agent TEXT, + source TEXT NOT NULL, + UNIQUE(user_id, document, version) +); +CREATE INDEX IF NOT EXISTS idx_user_consents_user_id ON user_consents(user_id); diff --git a/server/src/__tests__/auth.test.ts b/server/src/__tests__/auth.test.ts index 9ace9b5f..7a0c8761 100644 --- a/server/src/__tests__/auth.test.ts +++ b/server/src/__tests__/auth.test.ts @@ -1061,3 +1061,23 @@ describe('POST /api/auth/register (deletion lifecycle)', () => { expect(res.body.error).toBe('CONFLICT'); }); }); + +describe('GET /api/auth/status — consent version fields', () => { + it('returns tosVersion, privacyVersion, marketingOptInVisible', async () => { + const res = await request(app).get('/api/auth/status'); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('tosVersion'); + expect(res.body).toHaveProperty('privacyVersion'); + expect(res.body).toHaveProperty('marketingOptInVisible'); + expect(typeof res.body.marketingOptInVisible).toBe('boolean'); + }); + + it('returns null versions on self-hosted', async () => { + const res = await request(app).get('/api/auth/status'); + + // Default test environment has SELF_HOSTED=true. + expect(res.body.tosVersion).toBeNull(); + expect(res.body.privacyVersion).toBeNull(); + }); +}); diff --git a/server/src/__tests__/completeConsent.test.ts b/server/src/__tests__/completeConsent.test.ts new file mode 100644 index 00000000..05363b93 --- /dev/null +++ b/server/src/__tests__/completeConsent.test.ts @@ -0,0 +1,82 @@ +import type { Express } from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { query } from '../db.js'; +import { createApp } from '../index.js'; +import { CURRENT_PRIVACY_VERSION, CURRENT_TOS_VERSION } from '../lib/legalVersions.js'; +import * as planGate from '../lib/planGate.js'; + +let app: Express; + +beforeEach(() => { + app = createApp(); + vi.spyOn(planGate, 'isSelfHosted').mockReturnValue(false); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function registerCloudUser(email: string): Promise<{ accessCookie: string; csrfCookie: string; userId: string }> { + const res = await request(app) + .post('/api/auth/register') + .send({ email, password: 'StrongPass1!', displayName: 'CC User', acceptedTos: true, acceptedPrivacy: true }); + expect(res.status).toBe(201); + const cookies = (res.headers['set-cookie'] as unknown as string[]) ?? []; + const get = (name: string) => { + for (const c of cookies) { + const [pair] = c.split(';'); + const [k, v] = pair.split('='); + if (k.trim() === name) return v; + } + return ''; + }; + const { rows } = await query<{ id: string }>('SELECT id FROM users WHERE email = $1', [email]); + return { + accessCookie: get('openbin-access'), + csrfCookie: get('openbin-csrf'), + userId: rows[0].id, + }; +} + +describe('POST /api/auth/complete-consent', () => { + it('rejects unauthenticated requests', async () => { + const res = await request(app) + .post('/api/auth/complete-consent') + .send({ acceptedTos: true, acceptedPrivacy: true }); + expect(res.status).toBe(401); + }); + + it('rejects when acceptedTos is false', async () => { + const { accessCookie, csrfCookie } = await registerCloudUser('cc1@test.local'); + const res = await request(app) + .post('/api/auth/complete-consent') + .set('Cookie', [`openbin-access=${accessCookie}`, `openbin-csrf=${csrfCookie}`]) + .set('X-CSRF-Token', csrfCookie) + .send({ acceptedTos: false, acceptedPrivacy: true }); + expect(res.status).toBe(422); + }); + + it('records consent with the requested source', async () => { + const { userId, accessCookie, csrfCookie } = await registerCloudUser('cc2@test.local'); + await query('UPDATE users SET current_tos_version = NULL, current_privacy_version = NULL WHERE id = $1', [userId]); + await query('DELETE FROM user_consents WHERE user_id = $1', [userId]); + + const res = await request(app) + .post('/api/auth/complete-consent?source=oauth_completion') + .set('Cookie', [`openbin-access=${accessCookie}`, `openbin-csrf=${csrfCookie}`]) + .set('X-CSRF-Token', csrfCookie) + .send({ acceptedTos: true, acceptedPrivacy: true }); + + expect(res.status).toBe(200); + expect(res.body.currentTosVersion).toBe(CURRENT_TOS_VERSION); + expect(res.body.currentPrivacyVersion).toBe(CURRENT_PRIVACY_VERSION); + + const consents = await query( + 'SELECT source FROM user_consents WHERE user_id = $1 ORDER BY document', + [userId], + ); + expect(consents.rows).toHaveLength(2); + expect(consents.rows[0].source).toBe('oauth_completion'); + }); +}); diff --git a/server/src/__tests__/helpers.ts b/server/src/__tests__/helpers.ts index c856faf2..d5e467d5 100644 --- a/server/src/__tests__/helpers.ts +++ b/server/src/__tests__/helpers.ts @@ -38,9 +38,13 @@ export async function createTestUser(app: Express, overrides?: { email?: string; const password = overrides?.password ?? 'TestPass123!'; const displayName = overrides?.displayName ?? `Test User ${userCounter}`; + // Always send consent flags. In the default self-hosted test environment + // these are ignored; in tests that mock isSelfHosted() to false (plan + // gating, downgrade flows, etc.) they satisfy the cloud-only ToS/Privacy + // gate added in the consent flow. const res = await request(app) .post('/api/auth/register') - .send({ email, password, displayName }); + .send({ email, password, displayName, acceptedTos: true, acceptedPrivacy: true }); return { token: extractAccessToken(res), diff --git a/server/src/__tests__/registerConsent.test.ts b/server/src/__tests__/registerConsent.test.ts new file mode 100644 index 00000000..e74bf6b2 --- /dev/null +++ b/server/src/__tests__/registerConsent.test.ts @@ -0,0 +1,99 @@ +import type { Express } from 'express'; +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { query } from '../db.js'; +import { createApp } from '../index.js'; +import { CURRENT_PRIVACY_VERSION, CURRENT_TOS_VERSION } from '../lib/legalVersions.js'; +import * as planGate from '../lib/planGate.js'; + +// `config` is Object.freeze'd; spy on isSelfHosted() instead of mutating it. + +let app: Express; + +beforeEach(() => { + app = createApp(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('POST /api/auth/register — consent', () => { + describe('cloud mode (selfHosted=false)', () => { + beforeEach(() => { + vi.spyOn(planGate, 'isSelfHosted').mockReturnValue(false); + }); + + it('rejects when acceptedTos is missing', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ + email: 'cloud1@test.local', + password: 'StrongPass1!', + displayName: 'Cloud One', + acceptedPrivacy: true, + }); + expect(res.status).toBe(422); + expect(res.body.message).toMatch(/Terms of Service/i); + }); + + it('rejects when acceptedPrivacy is missing', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ + email: 'cloud2@test.local', + password: 'StrongPass1!', + displayName: 'Cloud Two', + acceptedTos: true, + }); + expect(res.status).toBe(422); + expect(res.body.message).toMatch(/Privacy Policy/i); + }); + + it('records both consent rows + sets current_*_version on success', async () => { + const res = await request(app) + .post('/api/auth/register') + .send({ + email: 'cloud3@test.local', + password: 'StrongPass1!', + displayName: 'Cloud Three', + acceptedTos: true, + acceptedPrivacy: true, + }); + expect(res.status).toBe(201); + + const consents = await query( + `SELECT document, version, source FROM user_consents + WHERE user_id = (SELECT id FROM users WHERE email = $1) + ORDER BY document`, + ['cloud3@test.local'], + ); + expect(consents.rows).toHaveLength(2); + expect(consents.rows[0].source).toBe('signup'); + expect(consents.rows[0].version).toBe(CURRENT_PRIVACY_VERSION); + expect(consents.rows[1].version).toBe(CURRENT_TOS_VERSION); + + const u = await query( + 'SELECT current_tos_version, current_privacy_version FROM users WHERE email = $1', + ['cloud3@test.local'], + ); + expect(u.rows[0].current_tos_version).toBe(CURRENT_TOS_VERSION); + expect(u.rows[0].current_privacy_version).toBe(CURRENT_PRIVACY_VERSION); + }); + }); + + describe('self-hosted mode', () => { + it('does not require acceptedTos/acceptedPrivacy', async () => { + // Test setup defaults to SELF_HOSTED=true; planGate.isSelfHosted() + // returns true here without any spy. + const res = await request(app) + .post('/api/auth/register') + .send({ + email: 'selfhost@test.local', + password: 'StrongPass1!', + displayName: 'Self Host', + }); + expect(res.status).toBe(201); + }); + }); +}); diff --git a/server/src/__tests__/setup.ts b/server/src/__tests__/setup.ts index 99058d9b..789ceddc 100644 --- a/server/src/__tests__/setup.ts +++ b/server/src/__tests__/setup.ts @@ -51,6 +51,7 @@ const TABLES = [ 'login_history', 'announcements', 'user_oauth_links', + 'user_consents', ]; afterEach(() => { diff --git a/server/src/db/__tests__/userConsentMigration.test.ts b/server/src/db/__tests__/userConsentMigration.test.ts new file mode 100644 index 00000000..cd705b89 --- /dev/null +++ b/server/src/db/__tests__/userConsentMigration.test.ts @@ -0,0 +1,70 @@ +import Database from 'better-sqlite3'; +import { describe, expect, it } from 'vitest'; +import { userConsent } from '../migrations/0013_user_consent.js'; + +function freshSqlite(): Database.Database { + const db = new Database(':memory:'); + // Minimal users table that mirrors what exists pre-migration. + // Use prepare(...).run() per-statement to follow this codebase's pattern. + const setupStmts = [ + `CREATE TABLE users ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + email TEXT UNIQUE NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + ]; + for (const sql of setupStmts) db.prepare(sql).run(); + + db.prepare('INSERT INTO users (id, display_name, email, created_at) VALUES (?, ?, ?, ?)') + .run('u1', 'Alice', 'alice@test.local', '2026-04-01T00:00:00.000Z'); + db.prepare('INSERT INTO users (id, display_name, email, created_at) VALUES (?, ?, ?, ?)') + .run('u2', 'Bob', 'bob@test.local', '2026-04-15T00:00:00.000Z'); + return db; +} + +describe('migration 0013_user_consent', () => { + it('adds 5 columns to users and creates user_consents table', () => { + const db = freshSqlite(); + userConsent.sqlite!(db); + + const cols = db.prepare("PRAGMA table_info('users')").all() as { name: string }[]; + const colNames = cols.map((c) => c.name); + expect(colNames).toContain('current_tos_version'); + expect(colNames).toContain('current_privacy_version'); + expect(colNames).toContain('marketing_opt_in'); + expect(colNames).toContain('marketing_opt_in_at'); + expect(colNames).toContain('marketing_opt_out_at'); + + const ucCols = db.prepare("PRAGMA table_info('user_consents')").all() as { name: string }[]; + expect(ucCols.length).toBeGreaterThan(0); + }); + + it('backfills existing users with current versions and writes audit rows', () => { + const db = freshSqlite(); + userConsent.sqlite!(db); + + const u1 = db.prepare('SELECT current_tos_version, current_privacy_version, marketing_opt_in FROM users WHERE id = ?').get('u1') as Record; + expect(u1.current_tos_version).toBe('2026-03-31'); + expect(u1.current_privacy_version).toBe('2026-03-31'); + expect(u1.marketing_opt_in).toBe(0); + + const consents = db.prepare("SELECT document, source, accepted_at, version FROM user_consents WHERE user_id = ? ORDER BY document").all('u1') as { document: string; source: string; accepted_at: string; version: string }[]; + expect(consents).toHaveLength(2); + expect(consents[0].document).toBe('privacy'); + expect(consents[0].source).toBe('backfill'); + expect(consents[0].version).toBe('2026-03-31'); + expect(consents[0].accepted_at).toBe('2026-04-01T00:00:00.000Z'); + expect(consents[1].document).toBe('tos'); + }); + + it('is idempotent on re-run', () => { + const db = freshSqlite(); + userConsent.sqlite!(db); + userConsent.sqlite!(db); + + const consents = db.prepare('SELECT COUNT(*) as n FROM user_consents').get() as { n: number }; + expect(consents.n).toBe(4); // 2 users × 2 documents — no duplicates from the second run + }); +}); diff --git a/server/src/db/migrations/0013_user_consent.ts b/server/src/db/migrations/0013_user_consent.ts new file mode 100644 index 00000000..8c944636 --- /dev/null +++ b/server/src/db/migrations/0013_user_consent.ts @@ -0,0 +1,121 @@ +import crypto from 'node:crypto'; +import type Database from 'better-sqlite3'; +import { LEGAL_DOCUMENTS } from '../../lib/legalVersions.js'; +import type { Migration } from './types.js'; + +const SQLITE_ALTER_STATEMENTS = [ + 'ALTER TABLE users ADD COLUMN current_tos_version TEXT', + 'ALTER TABLE users ADD COLUMN current_privacy_version TEXT', + 'ALTER TABLE users ADD COLUMN marketing_opt_in INTEGER NOT NULL DEFAULT 0', + 'ALTER TABLE users ADD COLUMN marketing_opt_in_at TEXT', + 'ALTER TABLE users ADD COLUMN marketing_opt_out_at TEXT', +]; + +const POSTGRES_ALTER_STATEMENTS = [ + 'ALTER TABLE users ADD COLUMN IF NOT EXISTS current_tos_version TEXT', + 'ALTER TABLE users ADD COLUMN IF NOT EXISTS current_privacy_version TEXT', + 'ALTER TABLE users ADD COLUMN IF NOT EXISTS marketing_opt_in BOOLEAN NOT NULL DEFAULT FALSE', + 'ALTER TABLE users ADD COLUMN IF NOT EXISTS marketing_opt_in_at TEXT', + 'ALTER TABLE users ADD COLUMN IF NOT EXISTS marketing_opt_out_at TEXT', +]; + +const CREATE_STATEMENTS = [ + `CREATE TABLE IF NOT EXISTS user_consents ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + document TEXT NOT NULL, + version TEXT NOT NULL, + accepted_at TEXT NOT NULL, + ip TEXT, + user_agent TEXT, + source TEXT NOT NULL, + UNIQUE(user_id, document, version) + )`, + `CREATE INDEX IF NOT EXISTS idx_user_consents_user_id ON user_consents(user_id)`, +]; + +function safeAlterSqlite(db: Database.Database, sql: string): void { + try { + db.prepare(sql).run(); + } catch (err) { + const msg = String((err as Error)?.message ?? ''); + if (/duplicate column name/i.test(msg)) return; + throw err; + } +} + +export const userConsent: Migration = { + name: '0013_user_consent', + sqlite(db) { + for (const sql of SQLITE_ALTER_STATEMENTS) safeAlterSqlite(db, sql); + for (const sql of CREATE_STATEMENTS) db.prepare(sql).run(); + + // Backfill: every user with a NULL current_tos_version gets one row each + // for tos + privacy at the current version, source='backfill', accepted_at + // = their original created_at (most-honest approximation of when they + // accepted under the old passive notice). + const rows = db + .prepare('SELECT id, created_at FROM users WHERE current_tos_version IS NULL') + .all() as { id: string; created_at: string }[]; + + const insert = db.prepare( + `INSERT OR IGNORE INTO user_consents + (id, user_id, document, version, accepted_at, ip, user_agent, source) + VALUES (?, ?, ?, ?, ?, NULL, NULL, 'backfill')`, + ); + const [tosVersion, privacyVersion] = LEGAL_DOCUMENTS.map(([, v]) => v); + const setUser = db.prepare( + `UPDATE users SET current_tos_version = ?, current_privacy_version = ? + WHERE id = ?`, + ); + + const tx = db.transaction(() => { + for (const r of rows) { + for (const [document, version] of LEGAL_DOCUMENTS) { + insert.run(crypto.randomUUID(), r.id, document, version, r.created_at); + } + setUser.run(tosVersion, privacyVersion, r.id); + } + }); + tx(); + }, + async postgres(pool) { + for (const sql of POSTGRES_ALTER_STATEMENTS) { + await pool.query(sql); + } + for (const sql of CREATE_STATEMENTS) { + await pool.query(sql); + } + + const rows = await pool.query<{ id: string; created_at: string }>( + 'SELECT id, created_at FROM users WHERE current_tos_version IS NULL', + ); + if (rows.rowCount === 0) return; + + const [tosVersion, privacyVersion] = LEGAL_DOCUMENTS.map(([, v]) => v); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + for (const r of rows.rows) { + for (const [document, version] of LEGAL_DOCUMENTS) { + await client.query( + `INSERT INTO user_consents (id, user_id, document, version, accepted_at, ip, user_agent, source) + VALUES ($1, $2, $3, $4, $5, NULL, NULL, 'backfill') + ON CONFLICT (user_id, document, version) DO NOTHING`, + [crypto.randomUUID(), r.id, document, version, r.created_at], + ); + } + await client.query( + `UPDATE users SET current_tos_version = $1, current_privacy_version = $2 WHERE id = $3`, + [tosVersion, privacyVersion, r.id], + ); + } + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } + }, +}; diff --git a/server/src/db/migrations/index.ts b/server/src/db/migrations/index.ts index 163f1b69..619dd502 100644 --- a/server/src/db/migrations/index.ts +++ b/server/src/db/migrations/index.ts @@ -10,6 +10,7 @@ import { drainDeleteUserOutbox } from './0009_drain_delete_user_outbox.js'; import { relaxCreatedByFks } from './0010_relax_created_by_fks.js'; import { clearLegacyMonthlyBillingPeriod } from './0011_clear_legacy_monthly_billing_period.js'; import { resetAiCreditPeriods } from './0012_reset_ai_credit_periods.js'; +import { userConsent } from './0013_user_consent.js'; import type { Migration } from './types.js'; /** @@ -29,4 +30,5 @@ export const migrations: Migration[] = [ relaxCreatedByFks, clearLegacyMonthlyBillingPeriod, resetAiCreditPeriods, + userConsent, ]; diff --git a/server/src/index.ts b/server/src/index.ts index 800f48c2..607f3ca0 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -23,6 +23,7 @@ import { isRestoreInProgress } from './lib/restore.js'; import { tryAuthenticate } from './middleware/auth.js'; import { maintenanceGate } from './middleware/maintenance.js'; import { requestLogger } from './middleware/requestLogger.js'; +import { requireCurrentConsent } from './middleware/requireCurrentConsent.js'; import { requireActiveSubscription } from './middleware/requirePlan.js'; import activityRoutes from './routes/activity.js'; import adminRoutes from './routes/admin/index.js'; @@ -193,6 +194,12 @@ export function createApp(opts?: { mountEeRoutes?: (app: express.Express) => voi app.use('/api', tryAuthenticate); app.use('/api', apiLimiter); app.use('/api', maintenanceGate, requireActiveSubscription()); + // Cloud-only: block mutating requests from users with stale consent. + // Skip the auth subtree entirely so /complete-consent itself can be reached. + app.use('/api', (req, res, next) => { + if (req.path.startsWith('/auth/')) return next(); + return requireCurrentConsent(req, res, next); + }); app.use('/api/auth/login', authLimiter); app.use('/api/auth/demo-login', authLimiter); app.use('/api/auth/register', registerLimiter); diff --git a/server/src/lib/__tests__/accountDeletion.e2e.test.ts b/server/src/lib/__tests__/accountDeletion.e2e.test.ts index 4848bd5e..54a5e01f 100644 --- a/server/src/lib/__tests__/accountDeletion.e2e.test.ts +++ b/server/src/lib/__tests__/accountDeletion.e2e.test.ts @@ -459,7 +459,7 @@ describe('account deletion E2E (race conditions)', () => { const res = await request(app) .post('/api/auth/register') - .send({ email, password: 'StrongPass1!', displayName: 'Re-register' }); + .send({ email, password: 'StrongPass1!', displayName: 'Re-register', acceptedTos: true, acceptedPrivacy: true }); expect(res.status).toBe(409); expect(res.body.error).toBe('EMAIL_PENDING_DELETION'); @@ -472,7 +472,7 @@ describe('account deletion E2E (race conditions)', () => { const password = 'StrongPass1!'; const reg = await request(app) .post('/api/auth/register') - .send({ email, password, displayName: 'Race Five' }); + .send({ email, password, displayName: 'Race Five', acceptedTos: true, acceptedPrivacy: true }); expect(reg.status).toBe(201); const userId = reg.body.user.id as string; diff --git a/server/src/lib/__tests__/consent.test.ts b/server/src/lib/__tests__/consent.test.ts new file mode 100644 index 00000000..4ac9ca12 --- /dev/null +++ b/server/src/lib/__tests__/consent.test.ts @@ -0,0 +1,110 @@ +import type { Request } from 'express'; +import { describe, expect, it, vi } from 'vitest'; + +// Mock config before importing the helper. We use a mutable plain object so +// individual tests can flip `marketingOptInVisible` at runtime — the helper +// reads `config.marketingOptInVisible` dynamically, not at import time. +const mockConfig = { marketingOptInVisible: false }; +vi.mock('../config.js', () => ({ config: mockConfig })); + +const { generateUuid, query } = await import('../../db.js'); +const { recordConsent } = await import('../consent.js'); + +function fakeReq(ip = '203.0.113.5', ua = 'jest/1.0'): Request { + return { + ip, + get: (h: string) => (h.toLowerCase() === 'user-agent' ? ua : undefined), + } as unknown as Request; +} + +async function makeUser(): Promise { + const id = generateUuid(); + await query( + 'INSERT INTO users (id, display_name, email) VALUES ($1, $2, $3)', + [id, 'Test User', `${id}@test.local`], + ); + return id; +} + +describe('recordConsent', () => { + it('writes one row per document and sets users columns', async () => { + const userId = await makeUser(); + await recordConsent(userId, 'signup', fakeReq()); + + const rows = await query( + 'SELECT document, version, source, ip, user_agent FROM user_consents WHERE user_id = $1 ORDER BY document', + [userId], + ); + expect(rows.rows).toHaveLength(2); + expect(rows.rows[0].document).toBe('privacy'); + expect(rows.rows[1].document).toBe('tos'); + expect(rows.rows[0].source).toBe('signup'); + expect(rows.rows[0].ip).toBe('203.0.113.5'); + expect(rows.rows[0].user_agent).toBe('jest/1.0'); + + const user = await query( + 'SELECT current_tos_version, current_privacy_version FROM users WHERE id = $1', + [userId], + ); + expect(user.rows[0].current_tos_version).toBeTruthy(); + expect(user.rows[0].current_privacy_version).toBeTruthy(); + }); + + it('is idempotent for the same version', async () => { + const userId = await makeUser(); + await recordConsent(userId, 'signup', fakeReq()); + await recordConsent(userId, 'reaccept_modal', fakeReq()); + + const rows = await query( + 'SELECT COUNT(*) as n FROM user_consents WHERE user_id = $1', + [userId], + ); + expect(Number(rows.rows[0].n)).toBe(2); + }); + + it('opts user into marketing only when env flag is true', async () => { + const original = mockConfig.marketingOptInVisible; + mockConfig.marketingOptInVisible = false; + try { + const userId = await makeUser(); + await recordConsent(userId, 'signup', fakeReq(), { marketingOptIn: true }); + const u = await query('SELECT marketing_opt_in FROM users WHERE id = $1', [userId]); + const v = u.rows[0].marketing_opt_in; + // Marketing should NOT be set when env flag is off, even with input=true. + expect(v === 0 || v === false).toBe(true); + } finally { + mockConfig.marketingOptInVisible = original; + } + }); + + it('opts user into marketing when env flag is true and input is true', async () => { + const original = mockConfig.marketingOptInVisible; + mockConfig.marketingOptInVisible = true; + try { + const userId = await makeUser(); + await recordConsent(userId, 'signup', fakeReq(), { marketingOptIn: true }); + const u = await query('SELECT marketing_opt_in, marketing_opt_in_at FROM users WHERE id = $1', [userId]); + const v = u.rows[0].marketing_opt_in; + expect(v === 1 || v === true).toBe(true); + expect(u.rows[0].marketing_opt_in_at).toBeTruthy(); + } finally { + mockConfig.marketingOptInVisible = original; + } + }); + + it('records opt-out when input is false and user was opted in', async () => { + const original = mockConfig.marketingOptInVisible; + mockConfig.marketingOptInVisible = true; + try { + const userId = await makeUser(); + await recordConsent(userId, 'signup', fakeReq(), { marketingOptIn: true }); + await recordConsent(userId, 'reaccept_modal', fakeReq(), { marketingOptIn: false }); + const u = await query('SELECT marketing_opt_in, marketing_opt_out_at FROM users WHERE id = $1', [userId]); + const v = u.rows[0].marketing_opt_in; + expect(v === 0 || v === false).toBe(true); + expect(u.rows[0].marketing_opt_out_at).toBeTruthy(); + } finally { + mockConfig.marketingOptInVisible = original; + } + }); +}); diff --git a/server/src/lib/config.ts b/server/src/lib/config.ts index 1041a406..91ed277f 100644 --- a/server/src/lib/config.ts +++ b/server/src/lib/config.ts @@ -196,6 +196,13 @@ export const config = Object.freeze({ // Non-image file attachments on bins (on by default; set ATTACHMENTS_ENABLED=false to disable) attachmentsEnabled: parseBool(process.env.ATTACHMENTS_ENABLED, true), + // When true, the optional marketing-email checkbox renders on signup + + // /auth/complete-signup, and `marketingOptIn` body fields are honored on + // /api/auth/register and /api/auth/complete-consent. When false (default), + // marketing input is silently ignored even if a request supplies it + // (defense in depth — the column still exists and stays at 0). + marketingOptInVisible: parseBool(process.env.MARKETING_OPT_IN_VISIBLE, false), + // AI API key encryption (separate from JWT to avoid single point of compromise) aiEncryptionKey: process.env.AI_ENCRYPTION_KEY || null, diff --git a/server/src/lib/consent.ts b/server/src/lib/consent.ts new file mode 100644 index 00000000..519eadf3 --- /dev/null +++ b/server/src/lib/consent.ts @@ -0,0 +1,79 @@ +import type { Request } from 'express'; +import { d, generateUuid, query } from '../db.js'; +import { config } from './config.js'; +import { + CURRENT_PRIVACY_VERSION, + CURRENT_TOS_VERSION, + LEGAL_DOCUMENTS, +} from './legalVersions.js'; + +export type ConsentSource = 'signup' | 'oauth_completion' | 'reaccept_modal' | 'backfill'; + +export interface RecordConsentOptions { + marketingOptIn?: boolean; +} + +/** + * Records ToS + Privacy consent for a user. Idempotent — re-running with the + * same version is a no-op for the audit table (UNIQUE constraint), but always + * refreshes the user's current_*_version columns so they pass requireCurrentConsent. + * + * Marketing branch: + * - undefined → no change. + * - true → ignored unless config.marketingOptInVisible is true (defense in depth). + * - false → always recorded as opt-out (revocations are always honored). + */ +export async function recordConsent( + userId: string, + source: ConsentSource, + req: Request, + opts?: RecordConsentOptions, +): Promise { + const ip = (req.ip ?? '').slice(0, 45); + const userAgent = (req.get('user-agent') ?? '').slice(0, 256); + + const insertConsent = d.insertOrIgnore( + `INSERT INTO user_consents + (id, user_id, document, version, accepted_at, ip, user_agent, source) + VALUES ($1, $2, $3, $4, ${d.now()}, $5, $6, $7)`, + ); + + for (const [document, version] of LEGAL_DOCUMENTS) { + await query(insertConsent, [ + generateUuid(), + userId, + document, + version, + ip || null, + userAgent || null, + source, + ]); + } + + await query( + `UPDATE users + SET current_tos_version = $1, + current_privacy_version = $2, + updated_at = ${d.now()} + WHERE id = $3`, + [CURRENT_TOS_VERSION, CURRENT_PRIVACY_VERSION, userId], + ); + + if (opts?.marketingOptIn === true && config.marketingOptInVisible) { + await query( + `UPDATE users + SET marketing_opt_in = $1, + marketing_opt_in_at = ${d.now()} + WHERE id = $2`, + [1, userId], + ); + } else if (opts?.marketingOptIn === false) { + await query( + `UPDATE users + SET marketing_opt_in = $1, + marketing_opt_out_at = ${d.now()} + WHERE id = $2 AND marketing_opt_in = $3`, + [0, userId, 1], + ); + } +} diff --git a/server/src/lib/legalVersions.ts b/server/src/lib/legalVersions.ts new file mode 100644 index 00000000..7cdbe391 --- /dev/null +++ b/server/src/lib/legalVersions.ts @@ -0,0 +1,13 @@ +// Bump in lockstep with openbin-website/{terms,privacy}.md and the in-app +// LegalPageLayout effective-date display. Format: 'YYYY-MM-DD'. +export const CURRENT_TOS_VERSION = '2026-03-31'; +export const CURRENT_PRIVACY_VERSION = '2026-03-31'; + +export const LEGAL_DOCUMENTS = [ + ['tos', CURRENT_TOS_VERSION], + ['privacy', CURRENT_PRIVACY_VERSION], +] as const; + +export type LegalDocument = (typeof LEGAL_DOCUMENTS)[number][0]; + +export const CONSENT_REQUIRED_CODE = 'CONSENT_REQUIRED'; diff --git a/server/src/middleware/__tests__/requireCurrentConsent.test.ts b/server/src/middleware/__tests__/requireCurrentConsent.test.ts new file mode 100644 index 00000000..b4d2a4f5 --- /dev/null +++ b/server/src/middleware/__tests__/requireCurrentConsent.test.ts @@ -0,0 +1,115 @@ +import type { NextFunction, Request, Response } from 'express'; +import { describe, expect, it, vi } from 'vitest'; + +// Mock config before importing the middleware. Config is Object.freeze'd in +// the real module, so we replace it with a mutable plain object that tests +// can flip per-case. The middleware reads `config.selfHosted` dynamically. +const mockConfig = { selfHosted: true }; +vi.mock('../../lib/config.js', () => ({ config: mockConfig })); + +// Stub the db query so the middleware never touches the real database when +// req.user already carries the version columns we want to assert on. +vi.mock('../../db.js', () => ({ query: vi.fn().mockResolvedValue({ rows: [] }) })); + +const { requireCurrentConsent } = await import('../requireCurrentConsent.js'); +const { CURRENT_PRIVACY_VERSION, CURRENT_TOS_VERSION } = await import( + '../../lib/legalVersions.js' +); + +function makeReq(over: Partial = {}): Request { + return { + method: 'POST', + user: { id: 'u1', email: 'u1@test.local', tokenVersion: 0 }, + authMethod: 'jwt', + ...over, + } as unknown as Request; +} + +function makeRes(): Response { + const res: Partial = {}; + res.status = vi.fn().mockReturnValue(res); + res.json = vi.fn().mockReturnValue(res); + return res as Response; +} + +describe('requireCurrentConsent', () => { + it('passes through on self-hosted', async () => { + const original = mockConfig.selfHosted; + mockConfig.selfHosted = true; + try { + const req = makeReq(); + const res = makeRes(); + const next = vi.fn() as NextFunction; + await requireCurrentConsent(req, res, next); + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + } finally { + mockConfig.selfHosted = original; + } + }); + + it('passes through for API key auth', async () => { + const original = mockConfig.selfHosted; + mockConfig.selfHosted = false; + try { + const req = makeReq({ authMethod: 'api_key' }); + const res = makeRes(); + const next = vi.fn() as NextFunction; + await requireCurrentConsent(req, res, next); + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + } finally { + mockConfig.selfHosted = original; + } + }); + + it('passes through GET requests', async () => { + const original = mockConfig.selfHosted; + mockConfig.selfHosted = false; + try { + const req = makeReq({ method: 'GET' }); + const res = makeRes(); + const next = vi.fn() as NextFunction; + await requireCurrentConsent(req, res, next); + expect(next).toHaveBeenCalledOnce(); + } finally { + mockConfig.selfHosted = original; + } + }); + + it('blocks user with stale tos version', async () => { + const original = mockConfig.selfHosted; + mockConfig.selfHosted = false; + try { + const req = makeReq() as Request & { user: any }; + req.user.currentTosVersion = '2025-01-01'; + req.user.currentPrivacyVersion = CURRENT_PRIVACY_VERSION; + const res = makeRes(); + const next = vi.fn() as NextFunction; + await requireCurrentConsent(req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'CONSENT_REQUIRED' }), + ); + expect(next).not.toHaveBeenCalled(); + } finally { + mockConfig.selfHosted = original; + } + }); + + it('passes when both versions match', async () => { + const original = mockConfig.selfHosted; + mockConfig.selfHosted = false; + try { + const req = makeReq() as Request & { user: any }; + req.user.currentTosVersion = CURRENT_TOS_VERSION; + req.user.currentPrivacyVersion = CURRENT_PRIVACY_VERSION; + const res = makeRes(); + const next = vi.fn() as NextFunction; + await requireCurrentConsent(req, res, next); + expect(next).toHaveBeenCalledOnce(); + } finally { + mockConfig.selfHosted = original; + } + }); +}); diff --git a/server/src/middleware/requireCurrentConsent.ts b/server/src/middleware/requireCurrentConsent.ts new file mode 100644 index 00000000..ea1364f4 --- /dev/null +++ b/server/src/middleware/requireCurrentConsent.ts @@ -0,0 +1,53 @@ +import type { NextFunction, Request, Response } from 'express'; +import { query } from '../db.js'; +import { config } from '../lib/config.js'; +import { + CONSENT_REQUIRED_CODE, + CURRENT_PRIVACY_VERSION, + CURRENT_TOS_VERSION, +} from '../lib/legalVersions.js'; + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +/** + * Blocks mutating requests from authenticated users whose ToS / Privacy consent + * is stale or missing. Self-hosted instances and API-key auth bypass the gate. + * + * Mounted on every router that handles mutations *except* the auth router. + * Read endpoints are intentionally unguarded — users can still see their data + * while their consent is being refreshed. + */ +export async function requireCurrentConsent( + req: Request, + res: Response, + next: NextFunction, +): Promise { + if (config.selfHosted) return next(); + if (req.authMethod === 'api_key') return next(); + if (SAFE_METHODS.has(req.method)) return next(); + if (!req.user) return next(); // requireAuth runs separately and will reject + + // Test seam: tests can pre-populate currentTosVersion / currentPrivacyVersion + // on req.user to skip the DB hit. Production code never sets these fields. + const userAny = req.user as unknown as Record; + let tosVersion = (userAny.currentTosVersion as string | null | undefined) ?? null; + let privacyVersion = (userAny.currentPrivacyVersion as string | null | undefined) ?? null; + + if (tosVersion === null || privacyVersion === null) { + const result = await query<{ current_tos_version: string | null; current_privacy_version: string | null }>( + 'SELECT current_tos_version, current_privacy_version FROM users WHERE id = $1', + [req.user.id], + ); + tosVersion = result.rows[0]?.current_tos_version ?? null; + privacyVersion = result.rows[0]?.current_privacy_version ?? null; + } + + if (tosVersion === CURRENT_TOS_VERSION && privacyVersion === CURRENT_PRIVACY_VERSION) { + return next(); + } + + res.status(403).json({ + error: CONSENT_REQUIRED_CODE, + message: 'You must accept the current Terms of Service and Privacy Policy to continue.', + }); +} diff --git a/server/src/routes/auth/completeConsent.ts b/server/src/routes/auth/completeConsent.ts new file mode 100644 index 00000000..9ccad6ec --- /dev/null +++ b/server/src/routes/auth/completeConsent.ts @@ -0,0 +1,42 @@ +import { Router } from 'express'; +import { asyncHandler } from '../../lib/asyncHandler.js'; +import { type ConsentSource, recordConsent } from '../../lib/consent.js'; +import { ValidationError } from '../../lib/httpErrors.js'; +import { CURRENT_PRIVACY_VERSION, CURRENT_TOS_VERSION } from '../../lib/legalVersions.js'; +import { isSelfHosted } from '../../lib/planGate.js'; +import { authenticate } from '../../middleware/auth.js'; + +const router = Router(); +const VALID_SOURCES: readonly ConsentSource[] = ['oauth_completion', 'reaccept_modal', 'backfill']; + +// Source comes from the query string so the OAuth completion URL is +// self-explanatory in logs; values are server-validated against VALID_SOURCES. +router.post('/complete-consent', authenticate, asyncHandler(async (req, res) => { + if (isSelfHosted()) { + throw new ValidationError('Consent capture is not available on self-hosted instances.'); + } + + const { acceptedTos, acceptedPrivacy, marketingOptIn } = req.body; + if (acceptedTos !== true) { + throw new ValidationError('You must accept the Terms of Service to continue.'); + } + if (acceptedPrivacy !== true) { + throw new ValidationError('You must accept the Privacy Policy to continue.'); + } + + const sourceParam = String(req.query.source ?? ''); + const source: ConsentSource = VALID_SOURCES.includes(sourceParam as ConsentSource) + ? (sourceParam as ConsentSource) + : 'reaccept_modal'; + + await recordConsent(req.user!.id, source, req, { marketingOptIn }); + + // The client refetches /me right after this endpoint resolves, so we just + // confirm what the recorded versions are and skip a redundant SELECT. + res.json({ + currentTosVersion: CURRENT_TOS_VERSION, + currentPrivacyVersion: CURRENT_PRIVACY_VERSION, + }); +})); + +export default router; diff --git a/server/src/routes/auth/index.ts b/server/src/routes/auth/index.ts index bab4d758..05b2bdb1 100644 --- a/server/src/routes/auth/index.ts +++ b/server/src/routes/auth/index.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import accountRouter from './account.js'; +import completeConsentRouter from './completeConsent.js'; import invitePreviewRouter from './invitePreview.js'; import oauthRouter from './oauth.js'; import passwordResetRouter from './passwordReset.js'; @@ -21,6 +22,7 @@ router.use(registerRouter); router.use(sessionRouter); router.use(profileRouter); router.use(passwordResetRouter); +router.use(completeConsentRouter); router.use(accountRouter); router.use(oauthRouter); diff --git a/server/src/routes/auth/profile.ts b/server/src/routes/auth/profile.ts index ccca1ea7..fff8ec38 100644 --- a/server/src/routes/auth/profile.ts +++ b/server/src/routes/auth/profile.ts @@ -18,7 +18,7 @@ const router = Router(); router.get('/me', authenticate, asyncHandler(async (req, res) => { const user = await queryOne>( - 'SELECT id, display_name, email, avatar_path, active_location_id, created_at, updated_at, plan, sub_status, active_until, is_admin, password_hash, deletion_requested_at, deletion_scheduled_at FROM users WHERE id = $1', + 'SELECT id, display_name, email, avatar_path, active_location_id, created_at, updated_at, plan, sub_status, active_until, is_admin, password_hash, deletion_requested_at, deletion_scheduled_at, current_tos_version, current_privacy_version, marketing_opt_in FROM users WHERE id = $1', [req.user!.id], 'User not found', ); @@ -46,6 +46,9 @@ router.get('/me', authenticate, asyncHandler(async (req, res) => { hasPassword: !!user.password_hash, deletionRequestedAt: user.deletion_requested_at || null, deletionScheduledAt: user.deletion_scheduled_at || null, + currentTosVersion: user.current_tos_version || null, + currentPrivacyVersion: user.current_privacy_version || null, + marketingOptIn: !!user.marketing_opt_in, }); })); diff --git a/server/src/routes/auth/register.ts b/server/src/routes/auth/register.ts index 652f871f..6d8484b4 100644 --- a/server/src/routes/auth/register.ts +++ b/server/src/routes/auth/register.ts @@ -3,6 +3,7 @@ import { Router } from 'express'; import { generateUuid, isUniqueViolation, query } from '../../db.js'; import { asyncHandler } from '../../lib/asyncHandler.js'; import { config } from '../../lib/config.js'; +import { recordConsent } from '../../lib/consent.js'; import { setAccessTokenCookie, setRefreshTokenCookie } from '../../lib/cookies.js'; import { getEeHooks } from '../../lib/eeHooks.js'; import { ConflictError, ForbiddenError, ValidationError } from '../../lib/httpErrors.js'; @@ -24,7 +25,16 @@ router.post('/register', asyncHandler(async (req, res) => { throw new ForbiddenError('Registration is currently disabled'); } - const { email, password, displayName, inviteCode } = req.body; + const { email, password, displayName, inviteCode, acceptedTos, acceptedPrivacy, marketingOptIn } = req.body; + + if (!isSelfHosted()) { + if (acceptedTos !== true) { + throw new ValidationError('You must accept the Terms of Service to create an account.'); + } + if (acceptedPrivacy !== true) { + throw new ValidationError('You must accept the Privacy Policy to create an account.'); + } + } // In invite mode, invite code is required if (regMode === 'invite' && !inviteCode) { @@ -90,6 +100,12 @@ router.post('/register', asyncHandler(async (req, res) => { const user = result.rows[0] as { id: string; display_name: string; email: string; created_at: string; active_until: string }; + // Record consent before location join so the audit trail orders + // consent → membership, never the reverse. + if (!isSelfHosted()) { + await recordConsent(user.id, 'signup', req, { marketingOptIn }); + } + // Auto-join location if invite code was valid if (locationToJoin) { await query( diff --git a/server/src/routes/auth/status.ts b/server/src/routes/auth/status.ts index d044b649..532233c6 100644 --- a/server/src/routes/auth/status.ts +++ b/server/src/routes/auth/status.ts @@ -4,6 +4,7 @@ import { asyncHandler } from '../../lib/asyncHandler.js'; import { config } from '../../lib/config.js'; import { setAccessTokenCookie, setRefreshTokenCookie } from '../../lib/cookies.js'; import { ForbiddenError } from '../../lib/httpErrors.js'; +import { CURRENT_PRIVACY_VERSION, CURRENT_TOS_VERSION } from '../../lib/legalVersions.js'; import { getOAuthProviders } from '../../lib/oauth.js'; import { queryMaybeOne, queryOne } from '../../lib/queryHelpers.js'; import { createRefreshToken } from '../../lib/refreshTokens.js'; @@ -24,6 +25,9 @@ router.get('/status', async (_req, res) => { selfHosted: config.selfHosted, attachmentsEnabled: config.attachmentsEnabled, oauthProviders: getOAuthProviders(), + tosVersion: config.selfHosted ? null : CURRENT_TOS_VERSION, + privacyVersion: config.selfHosted ? null : CURRENT_PRIVACY_VERSION, + marketingOptInVisible: config.selfHosted ? false : config.marketingOptInVisible, }; if (config.qrPayloadMode === 'url' && config.baseUrl) { body.baseUrl = config.baseUrl; diff --git a/src/App.tsx b/src/App.tsx index 98e173eb..3e06ccfc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -53,6 +53,10 @@ const PrivacyPage = lazyWithRetry(() => import('@/features/legal/PrivacyPage').then((m) => ({ default: m.PrivacyPage })) ); +const CompleteSignupPage = lazyWithRetry(() => + import('@/features/auth/CompleteSignupPage').then((m) => ({ default: m.CompleteSignupPage })) +); + const PrintPage = lazyWithRetry(() => import('@/features/print/PrintPage').then((m) => ({ default: m.PrintPage })) ); @@ -392,6 +396,7 @@ export default function App() { } /> } /> } /> + } /> } /> {/* Protected routes */} diff --git a/src/features/auth/AuthGuard.tsx b/src/features/auth/AuthGuard.tsx index 49d59e58..48f7dd35 100644 --- a/src/features/auth/AuthGuard.tsx +++ b/src/features/auth/AuthGuard.tsx @@ -1,10 +1,13 @@ -import { Navigate } from 'react-router-dom'; +import { Navigate, useLocation } from 'react-router-dom'; import { useAuth } from '@/lib/auth'; +import { isSelfHostedInstance, useAuthStatusConfig } from '@/lib/qrConfig'; export function AuthGuard({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth(); + const { config: authStatus, loaded: statusLoaded } = useAuthStatusConfig(); + const location = useLocation(); - if (loading) { + if (loading || !statusLoaded) { return (
@@ -16,5 +19,18 @@ export function AuthGuard({ children }: { children: React.ReactNode }) { return ; } + // Cloud-only: redirect users with stale or missing consent to the + // completion interstitial (covers OAuth signups, re-acceptance, and any + // unconsented edge cases). Skip the redirect if we're already on it. + const consentStale = + !isSelfHostedInstance() && + authStatus.tosVersion !== null && + (user.currentTosVersion !== authStatus.tosVersion || + user.currentPrivacyVersion !== authStatus.privacyVersion); + + if (consentStale && location.pathname !== '/auth/complete-signup') { + return ; + } + return <>{children}; } diff --git a/src/features/auth/CompleteSignupPage.tsx b/src/features/auth/CompleteSignupPage.tsx new file mode 100644 index 00000000..6e658668 --- /dev/null +++ b/src/features/auth/CompleteSignupPage.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { BrandIcon } from '@/components/BrandIcon'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { useToast } from '@/components/ui/toast'; +import { apiFetch } from '@/lib/api'; +import { useAuth } from '@/lib/auth'; +import { useAuthStatusConfig } from '@/lib/qrConfig'; +import { cn, focusRing, getErrorMessage } from '@/lib/utils'; +import { ConsentCheckboxes } from './ConsentCheckboxes'; + +export function CompleteSignupPage() { + const { user, refreshSession, logout } = useAuth(); + const { config: authStatus, loaded: statusLoaded } = useAuthStatusConfig(); + const { showToast } = useToast(); + const navigate = useNavigate(); + + const [tosAccepted, setTosAccepted] = useState(false); + const [marketingOptIn, setMarketingOptIn] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const isReacceptance = Boolean(user?.currentTosVersion) || Boolean(user?.currentPrivacyVersion); + const heading = isReacceptance + ? "We've updated our Terms of Service and Privacy Policy" + : 'Almost done — confirm to continue'; + const body = isReacceptance + ? 'Please review and accept the updated documents to keep using your account.' + : 'Just one more step before you can start using your account.'; + + async function handleContinue() { + if (!tosAccepted || submitting) return; + setSubmitting(true); + try { + await apiFetch('/api/auth/complete-consent?source=oauth_completion', { + method: 'POST', + body: { acceptedTos: true, acceptedPrivacy: true, marketingOptIn }, + }); + await refreshSession(); + navigate('/'); + } catch (err) { + showToast({ message: getErrorMessage(err, 'Failed to record consent'), variant: 'error' }); + } finally { + setSubmitting(false); + } + } + + if (!statusLoaded) { + return ( +
+
+
+ ); + } + + return ( +
+
+
+ +

+ {heading} +

+

{body}

+
+ + + + + + + + + + +
+
+ ); +} diff --git a/src/features/auth/ConsentCheckboxes.tsx b/src/features/auth/ConsentCheckboxes.tsx new file mode 100644 index 00000000..fc5c0662 --- /dev/null +++ b/src/features/auth/ConsentCheckboxes.tsx @@ -0,0 +1,64 @@ +import { Link } from 'react-router-dom'; +import { Checkbox } from '@/components/ui/checkbox'; + +interface ConsentCheckboxesProps { + tosAccepted: boolean; + onTosChange: (v: boolean) => void; + marketingOptIn: boolean; + onMarketingChange: (v: boolean) => void; + marketingVisible: boolean; + /** Prefix for checkbox `id` attributes — must be unique per page using this component. */ + idPrefix: string; +} + +export function ConsentCheckboxes({ + tosAccepted, + onTosChange, + marketingOptIn, + onMarketingChange, + marketingVisible, + idPrefix, +}: ConsentCheckboxesProps) { + const tosId = `${idPrefix}-consent-tos`; + const marketingId = `${idPrefix}-consent-marketing`; + const labelClass = + 'flex items-start gap-3 text-[13px] text-[var(--text-primary)] leading-relaxed cursor-pointer'; + const linkClass = + 'text-[var(--accent)] hover:underline focus-visible:underline focus-visible:outline-none'; + + return ( + <> + + + {marketingVisible && ( + + )} + + ); +} diff --git a/src/features/auth/RegisterPage.tsx b/src/features/auth/RegisterPage.tsx index 821fbaab..30e1c0b5 100644 --- a/src/features/auth/RegisterPage.tsx +++ b/src/features/auth/RegisterPage.tsx @@ -15,6 +15,7 @@ import { allChecksPassing, computePasswordChecks } from '@/lib/passwordStrength' import { isSelfHostedInstance, useAuthStatusConfig } from '@/lib/qrConfig'; import { cycleThemePreference, useTheme } from '@/lib/theme'; import { cn, EMAIL_REGEX, focusRing, getErrorMessage } from '@/lib/utils'; +import { ConsentCheckboxes } from './ConsentCheckboxes'; import { SocialButtons, SocialDivider } from './SocialButtons'; export function RegisterPage() { @@ -37,6 +38,8 @@ export function RegisterPage() { viewerCount: number; } | null>(null); const [inviteInvalid, setInviteInvalid] = useState(false); + const [tosAccepted, setTosAccepted] = useState(false); + const [marketingOptIn, setMarketingOptIn] = useState(false); const [touched, setTouched] = useState>({}); const markTouched = useCallback((field: string) => setTouched((t) => ({ ...t, [field]: true })), []); @@ -153,7 +156,13 @@ export function RegisterPage() { } setLoading(true); try { - await register(email.trim(), password, displayName.trim(), inviteCode.trim() || undefined); + await register( + email.trim(), + password, + displayName.trim(), + inviteCode.trim() || undefined, + selfHosted ? undefined : { acceptedTos: tosAccepted, acceptedPrivacy: tosAccepted, marketingOptIn }, + ); navigate('/'); } catch (err) { showToast({ @@ -365,22 +374,26 @@ export function RegisterPage() {
+ {!selfHosted && ( +
+ +
+ )} - {!selfHosted && ( -

- By creating an account, you agree to the{' '} - Terms of Service - {' '}and{' '} - Privacy Policy. -

- )}
diff --git a/src/features/auth/__tests__/AuthGuard.test.tsx b/src/features/auth/__tests__/AuthGuard.test.tsx index 6d4b1f08..aa683ad3 100644 --- a/src/features/auth/__tests__/AuthGuard.test.tsx +++ b/src/features/auth/__tests__/AuthGuard.test.tsx @@ -5,6 +5,21 @@ import { useAuth } from '@/lib/auth'; import { AuthGuard } from '../AuthGuard'; vi.mock('@/lib/auth', () => ({ useAuth: vi.fn() })); +vi.mock('@/lib/qrConfig', () => ({ + isSelfHostedInstance: () => true, + useAuthStatusConfig: () => ({ + config: { + registrationMode: 'open', + registrationEnabled: true, + oauthProviders: [], + demoMode: false, + tosVersion: null, + privacyVersion: null, + marketingOptInVisible: false, + }, + loaded: true, + }), +})); const mockedUseAuth = vi.mocked(useAuth); diff --git a/src/features/auth/__tests__/CompleteSignupPage.test.tsx b/src/features/auth/__tests__/CompleteSignupPage.test.tsx new file mode 100644 index 00000000..151b66ec --- /dev/null +++ b/src/features/auth/__tests__/CompleteSignupPage.test.tsx @@ -0,0 +1,60 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CompleteSignupPage } from '../CompleteSignupPage'; + +vi.mock('@/lib/api', () => ({ + apiFetch: vi.fn(), +})); +vi.mock('@/lib/auth', () => ({ + useAuth: () => ({ + user: { id: 'u1', email: 'u@test.local', currentTosVersion: null, currentPrivacyVersion: null }, + refreshSession: vi.fn(), + logout: vi.fn(), + }), +})); +vi.mock('@/lib/qrConfig', () => ({ + isSelfHostedInstance: () => false, + useAuthStatusConfig: () => ({ + config: { tosVersion: '2026-03-31', privacyVersion: '2026-03-31', marketingOptInVisible: false }, + loaded: true, + }), +})); +vi.mock('@/components/ui/toast', () => ({ + useToast: () => ({ showToast: vi.fn() }), +})); + +import { apiFetch } from '@/lib/api'; + +const mockedApiFetch = apiFetch as unknown as ReturnType; + +describe('CompleteSignupPage', () => { + beforeEach(() => vi.clearAllMocks()); + + it('disables continue until ToS box is checked', () => { + render(); + const button = screen.getByRole('button', { name: /continue/i }); + expect(button).toBeDisabled(); + }); + + it('calls /api/auth/complete-consent on submit', async () => { + mockedApiFetch.mockResolvedValue({ currentTosVersion: '2026-03-31' }); + render(); + const checkbox = screen.getByRole('checkbox', { name: /Terms of Service/i }); + fireEvent.click(checkbox); + const button = screen.getByRole('button', { name: /continue/i }); + expect(button).not.toBeDisabled(); + fireEvent.click(button); + await waitFor(() => { + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/auth/complete-consent'), + expect.objectContaining({ method: 'POST' }), + ); + }); + }); + + it('hides marketing checkbox when marketingOptInVisible is false', () => { + render(); + expect(screen.queryByRole('checkbox', { name: /product updates/i })).toBeNull(); + }); +}); diff --git a/src/features/auth/__tests__/RegisterPage.consent.test.tsx b/src/features/auth/__tests__/RegisterPage.consent.test.tsx new file mode 100644 index 00000000..65b9883a --- /dev/null +++ b/src/features/auth/__tests__/RegisterPage.consent.test.tsx @@ -0,0 +1,82 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RegisterPage } from '../RegisterPage'; + +const registerMock = vi.fn(); +let selfHosted = false; +let marketingVisible = false; + +vi.mock('@/lib/auth', () => ({ + useAuth: () => ({ register: registerMock }), +})); +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/lib/qrConfig', () => ({ + isSelfHostedInstance: () => selfHosted, + useAuthStatusConfig: () => ({ + config: { + registrationMode: 'open', + registrationEnabled: true, + oauthProviders: [], + demoMode: false, + tosVersion: '2026-03-31', + privacyVersion: '2026-03-31', + marketingOptInVisible: marketingVisible, + }, + loaded: true, + }), +})); +vi.mock('@/lib/appSettings', () => ({ + useAppSettings: () => ({ settings: { appName: 'OpenBin' } }), +})); +vi.mock('@/lib/theme', () => ({ + useTheme: () => ({ preference: 'light', setThemePreference: vi.fn() }), + cycleThemePreference: vi.fn(), +})); +vi.mock('@/components/ui/toast', () => ({ + useToast: () => ({ showToast: vi.fn() }), +})); + +beforeEach(() => { + registerMock.mockReset(); + selfHosted = false; + marketingVisible = false; +}); + +describe('RegisterPage — cloud consent block', () => { + it('disables submit until the ToS checkbox is checked', () => { + render(); + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'a@test.local' } }); + fireEvent.change(screen.getByLabelText(/display name/i), { target: { value: 'A' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'StrongPass1!' } }); + fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'StrongPass1!' } }); + expect(screen.getByRole('button', { name: /create account/i })).toBeDisabled(); + }); + + it('hides the consent block on self-hosted', () => { + selfHosted = true; + render(); + expect(screen.queryByRole('checkbox', { name: /Terms of Service/i })).toBeNull(); + }); + + it('hides marketing checkbox unless marketingOptInVisible is true', () => { + render(); + expect(screen.queryByRole('checkbox', { name: /product updates/i })).toBeNull(); + }); + + it('passes acceptedTos and acceptedPrivacy to register()', async () => { + render(); + fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'a@test.local' } }); + fireEvent.change(screen.getByLabelText(/display name/i), { target: { value: 'A' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'StrongPass1!' } }); + fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'StrongPass1!' } }); + fireEvent.click(screen.getByRole('checkbox', { name: /Terms of Service/i })); + fireEvent.click(screen.getByRole('button', { name: /create account/i })); + await waitFor(() => { + expect(registerMock).toHaveBeenCalledWith( + 'a@test.local', 'StrongPass1!', 'A', undefined, + expect.objectContaining({ acceptedTos: true, acceptedPrivacy: true }), + ); + }); + }); +}); diff --git a/src/features/legal/LegalPageLayout.tsx b/src/features/legal/LegalPageLayout.tsx index 8a180b02..9d105817 100644 --- a/src/features/legal/LegalPageLayout.tsx +++ b/src/features/legal/LegalPageLayout.tsx @@ -1,14 +1,12 @@ import { ChevronLeft, Monitor, Moon, Sun } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { BrandIcon } from '@/components/BrandIcon'; import { Card, CardContent } from '@/components/ui/card'; -import { isSelfHostedInstance, waitForConfig } from '@/lib/qrConfig'; +import { getAuthStatusConfig, isSelfHostedInstance, waitForConfig } from '@/lib/qrConfig'; import { cycleThemePreference, useTheme } from '@/lib/theme'; import { cn, focusRing } from '@/lib/utils'; -const EFFECTIVE_DATE = 'March 31, 2026'; - interface LegalPageLayoutProps { title: string; crossLink: { to: string; label: string }; @@ -30,6 +28,21 @@ export function LegalPageLayout({ title, crossLink, children }: LegalPageLayoutP return () => { cancelled = true; }; }, [navigate]); + const effectiveDate = useMemo(() => { + if (!ready) return null; + const status = getAuthStatusConfig(); + const raw = status.tosVersion ?? status.privacyVersion; + if (!raw) return null; + const dt = new Date(`${raw}T00:00:00Z`); + if (Number.isNaN(dt.getTime())) return null; + return dt.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }); + }, [ready]); + if (!ready) return null; const ThemeIcon = preference === 'light' ? Sun : preference === 'dark' ? Moon : Monitor; @@ -55,9 +68,11 @@ export function LegalPageLayout({ title, crossLink, children }: LegalPageLayoutP

{title}

-

- Effective {EFFECTIVE_DATE} -

+ {effectiveDate && ( +

+ Effective {effectiveDate} +

+ )}
diff --git a/src/lib/api.ts b/src/lib/api.ts index 73101f3a..7f91675f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -42,6 +42,13 @@ export class ApiError extends Error { } } +export class ConsentRequiredError extends ApiError { + constructor(message: string) { + super(403, message, 'CONSENT_REQUIRED'); + this.name = 'ConsentRequiredError'; + } +} + interface ApiFetchOptions extends Omit { body?: unknown; timeout?: number; @@ -149,6 +156,9 @@ async function doFetch(path: string, options: ApiFetchOptions, isRetry: boole lastLocationRefresh = Date.now(); notify(Events.LOCATIONS); } + if (code === 'CONSENT_REQUIRED') { + throw new ConsentRequiredError(data.message || 'Consent required'); + } const { error: _e, message: _m, upgrade_url: _u, upgrade_action: _ua, ...rest } = data; throw new ApiError( res.status, diff --git a/src/lib/auth.tsx b/src/lib/auth.tsx index 81f8f866..00aff551 100644 --- a/src/lib/auth.tsx +++ b/src/lib/auth.tsx @@ -13,7 +13,13 @@ interface AuthState { interface AuthContextValue extends AuthState { login: (email: string, password: string) => Promise; - register: (email: string, password: string, displayName: string, inviteCode?: string) => Promise; + register: ( + email: string, + password: string, + displayName: string, + inviteCode?: string, + consent?: { acceptedTos: boolean; acceptedPrivacy: boolean; marketingOptIn?: boolean }, + ) => Promise; logout: () => Promise; refreshSession: () => Promise; setActiveLocationId: (id: string | null) => void; @@ -134,9 +140,20 @@ export function AuthProvider({ children }: { children: ReactNode }) { })); }, []); - const register = useCallback(async (email: string, password: string, displayName: string, inviteCode?: string) => { - const body: Record = { email, password, displayName }; + const register = useCallback(async ( + email: string, + password: string, + displayName: string, + inviteCode?: string, + consent?: { acceptedTos: boolean; acceptedPrivacy: boolean; marketingOptIn?: boolean }, + ) => { + const body: Record = { email, password, displayName }; if (inviteCode) body.inviteCode = inviteCode; + if (consent) { + body.acceptedTos = consent.acceptedTos; + body.acceptedPrivacy = consent.acceptedPrivacy; + if (typeof consent.marketingOptIn === 'boolean') body.marketingOptIn = consent.marketingOptIn; + } const data = await apiFetch<{ token: string; user: User; activeLocationId?: string }>('/api/auth/register', { method: 'POST', body, diff --git a/src/lib/qrConfig.ts b/src/lib/qrConfig.ts index 4fe1cc10..b19ccd8b 100644 --- a/src/lib/qrConfig.ts +++ b/src/lib/qrConfig.ts @@ -14,6 +14,9 @@ export interface AuthStatusConfig { registrationEnabled: boolean; oauthProviders: string[]; demoMode: boolean; + tosVersion: string | null; + privacyVersion: string | null; + marketingOptInVisible: boolean; } let cached: QrConfig = { qrPayloadMode: 'app' }; @@ -24,6 +27,9 @@ let authStatusCached: AuthStatusConfig = { registrationEnabled: true, oauthProviders: [], demoMode: false, + tosVersion: null, + privacyVersion: null, + marketingOptInVisible: false, }; let initPromise: Promise | null = null; @@ -104,6 +110,9 @@ async function _doInit(): Promise { registrationEnabled: data.registrationEnabled !== false, oauthProviders: Array.isArray(data.oauthProviders) ? data.oauthProviders : [], demoMode: data.demoMode === true, + tosVersion: typeof data.tosVersion === 'string' ? data.tosVersion : null, + privacyVersion: typeof data.privacyVersion === 'string' ? data.privacyVersion : null, + marketingOptInVisible: data.marketingOptInVisible === true, }; } catch { // Keep defaults on network failure diff --git a/src/types.ts b/src/types.ts index 754a64e2..aea34576 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,6 +16,9 @@ export interface User { hasPassword?: boolean; deletionRequestedAt?: string | null; deletionScheduledAt?: string | null; + currentTosVersion?: string | null; + currentPrivacyVersion?: string | null; + marketingOptIn?: boolean; } export interface Location {