diff --git a/app/screens/SSOSettingsScreen.tsx b/app/screens/SSOSettingsScreen.tsx new file mode 100644 index 00000000..d4a6a286 --- /dev/null +++ b/app/screens/SSOSettingsScreen.tsx @@ -0,0 +1 @@ +export { default } from '../../src/screens/SSOSettingsScreen'; diff --git a/backend/calendar/__tests__/CalendarSyncService.test.ts b/backend/calendar/__tests__/CalendarSyncService.test.ts new file mode 100644 index 00000000..8b46f43d --- /dev/null +++ b/backend/calendar/__tests__/CalendarSyncService.test.ts @@ -0,0 +1,373 @@ +import { CalendarSyncService, type RemoteChange } from '../domain/CalendarSyncService'; +import { SyncWorker } from '../domain/SyncWorker'; + +describe('CalendarSyncService', () => { + let service: CalendarSyncService; + + beforeEach(() => { + service = new CalendarSyncService({ pollIntervalMs: 1000, rateLimitPerMinute: 100 }); + }); + + describe('Connection Management', () => { + it('creates a Google Calendar connection with webhook sync', () => { + const conn = service.createConnection( + 'user_1', 'google', 'token_123', 'refresh_123', + 'user@gmail.com', 'primary', + ); + + expect(conn.id).toMatch(/^cal_conn_/); + expect(conn.provider).toBe('google'); + expect(conn.syncMethod).toBe('webhook'); + expect(conn.syncDirection).toBe('bidirectional'); + expect(conn.status).toBe('connected'); + expect(conn.webhookId).toBeTruthy(); + }); + + it('creates an Outlook connection', () => { + const conn = service.createConnection( + 'user_1', 'outlook', 'token_456', undefined, + 'user@outlook.com', 'calendar_id', + ); + + expect(conn.provider).toBe('outlook'); + expect(conn.syncMethod).toBe('webhook'); + }); + + it('creates an iCal connection with poll sync', () => { + const conn = service.createConnection( + 'user_1', 'ical', 'token_789', undefined, + 'user@example.com', 'cal_id', + ); + + expect(conn.provider).toBe('ical'); + expect(conn.syncMethod).toBe('poll'); + expect(conn.webhookId).toBeUndefined(); + }); + + it('lists connections by user', () => { + service.createConnection('user_1', 'google', 'tok', undefined, 'a@a.com', 'cal'); + service.createConnection('user_1', 'outlook', 'tok', undefined, 'a@b.com', 'cal'); + service.createConnection('user_2', 'google', 'tok', undefined, 'c@c.com', 'cal'); + + expect(service.listConnections('user_1')).toHaveLength(2); + expect(service.listConnections('user_2')).toHaveLength(1); + }); + + it('updates connection settings', () => { + const conn = service.createConnection('user_1', 'google', 'tok', undefined, 'a@a.com', 'cal'); + const updated = service.updateConnectionSettings(conn.id, { + syncDirection: 'to_calendar', + enabledEventTypes: ['payment_due', 'contract_end'], + }); + + expect(updated.syncDirection).toBe('to_calendar'); + expect(updated.enabledEventTypes).toEqual(['payment_due', 'contract_end']); + }); + + it('disconnects and removes events', () => { + const conn = service.createConnection('user_1', 'google', 'tok', undefined, 'a@a.com', 'cal'); + service.createEvent(conn.id, 'sub_1', 'payment_due', 'Payment', 'Desc', + new Date().toISOString(), new Date().toISOString()); + + expect(service.listEvents(conn.id)).toHaveLength(1); + + const disconnected = service.disconnectConnection(conn.id); + expect(disconnected.status).toBe('disconnected'); + expect(service.listEvents(conn.id)).toHaveLength(0); + }); + }); + + describe('Event Management', () => { + let connectionId: string; + + beforeEach(() => { + const conn = service.createConnection('user_1', 'google', 'tok', undefined, 'a@a.com', 'cal'); + connectionId = conn.id; + }); + + it('creates calendar events', () => { + const event = service.createEvent( + connectionId, 'sub_1', 'payment_due', + 'Netflix Payment Due', 'Monthly Netflix subscription', + '2025-02-01T09:00:00Z', '2025-02-01T09:30:00Z', + ); + + expect(event.id).toMatch(/^cal_event_/); + expect(event.eventType).toBe('payment_due'); + expect(event.syncStatus).toBe('pending'); + expect(event.deleted).toBe(false); + }); + + it('rejects disabled event types', () => { + service.updateConnectionSettings(connectionId, { + enabledEventTypes: ['payment_due'], + }); + + expect(() => + service.createEvent(connectionId, 'sub_1', 'trial_ending', 'Trial', 'Desc', + '2025-02-01T00:00:00Z', '2025-02-01T00:30:00Z'), + ).toThrow("Event type 'trial_ending' is not enabled"); + }); + + it('lists events by connection', () => { + service.createEvent(connectionId, 'sub_1', 'payment_due', 'P1', 'D', '2025-01-01T00:00:00Z', '2025-01-01T01:00:00Z'); + service.createEvent(connectionId, 'sub_2', 'renewal', 'P2', 'D', '2025-02-01T00:00:00Z', '2025-02-01T01:00:00Z'); + + expect(service.listEvents(connectionId)).toHaveLength(2); + }); + + it('lists events by subscription', () => { + service.createEvent(connectionId, 'sub_1', 'payment_due', 'P1', 'D', '2025-01-01T00:00:00Z', '2025-01-01T01:00:00Z'); + service.createEvent(connectionId, 'sub_1', 'renewal', 'P2', 'D', '2025-02-01T00:00:00Z', '2025-02-01T01:00:00Z'); + service.createEvent(connectionId, 'sub_2', 'payment_due', 'P3', 'D', '2025-03-01T00:00:00Z', '2025-03-01T01:00:00Z'); + + expect(service.listEventsBySubscription('sub_1')).toHaveLength(2); + expect(service.listEventsBySubscription('sub_2')).toHaveLength(1); + }); + + it('updates events locally', () => { + const event = service.createEvent(connectionId, 'sub_1', 'payment_due', 'Original', 'Desc', + '2025-01-01T00:00:00Z', '2025-01-01T01:00:00Z'); + + const updated = service.updateEventLocally(event.id, { title: 'Updated Title' }); + expect(updated.title).toBe('Updated Title'); + expect(updated.syncStatus).toBe('pending'); + }); + + it('soft-deletes events', () => { + const event = service.createEvent(connectionId, 'sub_1', 'payment_due', 'P', 'D', + '2025-01-01T00:00:00Z', '2025-01-01T01:00:00Z'); + + service.deleteEvent(event.id); + expect(service.listEvents(connectionId)).toHaveLength(0); + expect(service.getEvent(event.id)!.deleted).toBe(true); + }); + }); + + describe('Push Sync (to calendar)', () => { + it('pushes pending events to calendar', () => { + const conn = service.createConnection('user_1', 'google', 'tok', undefined, 'a@a.com', 'cal'); + service.createEvent(conn.id, 'sub_1', 'payment_due', 'P1', 'D', '2025-01-01T00:00:00Z', '2025-01-01T01:00:00Z'); + service.createEvent(conn.id, 'sub_2', 'renewal', 'P2', 'D', '2025-02-01T00:00:00Z', '2025-02-01T01:00:00Z'); + + const result = service.pushToCalendar(conn.id); + expect(result.pushed).toBe(2); + expect(result.errors).toHaveLength(0); + + const events = service.listEvents(conn.id); + expect(events.every((e) => e.syncStatus === 'synced')).toBe(true); + }); + + it('skips push for from_calendar connections', () => { + const conn = service.createConnection('user_1', 'google', 'tok', undefined, 'a@a.com', 'cal', 'from_calendar'); + service.createEvent(conn.id, 'sub_1', 'payment_due', 'P1', 'D', '2025-01-01T00:00:00Z', '2025-01-01T01:00:00Z'); + + const result = service.pushToCalendar(conn.id); + expect(result.pushed).toBe(0); + }); + }); + + describe('Pull Sync (from calendar)', () => { + it('pulls remote changes', () => { + const conn = service.createConnection('user_1', 'google', 'tok', undefined, 'a@a.com', 'cal'); + const event = service.createEvent(conn.id, 'sub_1', 'payment_due', 'Original', 'D', + '2025-01-01T00:00:00Z', '2025-01-01T01:00:00Z'); + service.pushToCalendar(conn.id); + + const remoteChanges: RemoteChange[] = [{ + providerEventId: event.providerEventId, + changeType: 'updated', + title: 'Remotely Updated', + updatedAt: new Date(Date.now() + 10000).toISOString(), + }]; + + const result = service.pullFromCalendar(conn.id, remoteChanges); + expect(result.pulled).toBe(1); + + const updated = service.getEvent(event.id); + expect(updated!.title).toBe('Remotely Updated'); + }); + + it('handles remote deletion', () => { + const conn = service.createConnection('user_1', 'google', 'tok', undefined, 'a@a.com', 'cal'); + const event = service.createEvent(conn.id, 'sub_1', 'payment_due', 'To Delete', 'D', + '2025-01-01T00:00:00Z', '2025-01-01T01:00:00Z'); + service.pushToCalendar(conn.id); + + const remoteChanges: RemoteChange[] = [{ + providerEventId: event.providerEventId, + changeType: 'deleted', + }]; + + const result = service.pullFromCalendar(conn.id, remoteChanges); + expect(result.pulled).toBe(1); + expect(service.getEvent(event.id)!.deleted).toBe(true); + }); + + it('skips pull for to_calendar connections', () => { + const conn = service.createConnection('user_1', 'google', 'tok', undefined, 'a@a.com', 'cal', 'to_calendar'); + + const result = service.pullFromCalendar(conn.id, [{ + providerEventId: 'prov_1', + changeType: 'updated', + title: 'Remote', + }]); + + expect(result.pulled).toBe(0); + }); + }); + + describe('Full Bidirectional Sync', () => { + it('pushes and pulls in one operation', () => { + const conn = service.createConnection('user_1', 'google', 'tok', undefined, 'a@a.com', 'cal'); + const event = service.createEvent(conn.id, 'sub_1', 'payment_due', 'Local', 'D', + '2025-01-01T00:00:00Z', '2025-01-01T01:00:00Z'); + + const result = service.fullSync(conn.id); + expect(result.pushed).toBe(1); + + const result2 = service.fullSync(conn.id, [{ + providerEventId: event.providerEventId, + changeType: 'updated', + title: 'Remote Update', + updatedAt: new Date(Date.now() + 10000).toISOString(), + }]); + expect(result2.pulled).toBe(1); + }); + }); + + describe('Webhook Handling', () => { + it('queues and processes webhook notifications', () => { + const conn = service.createConnection('user_1', 'google', 'tok', undefined, 'a@a.com', 'cal'); + const event = service.createEvent(conn.id, 'sub_1', 'payment_due', 'P', 'D', + '2025-01-01T00:00:00Z', '2025-01-01T01:00:00Z'); + service.pushToCalendar(conn.id); + + service.handleWebhookNotification({ + provider: 'google', + connectionId: conn.id, + resourceId: event.providerEventId, + changeType: 'deleted', + timestamp: new Date().toISOString(), + }); + + const results = service.processWebhookQueue(); + expect(results).toHaveLength(1); + expect(results[0].pulled).toBe(1); + }); + }); + + describe('Polling Fallback', () => { + it('identifies connections needing poll', () => { + const conn = service.createConnection('user_1', 'ical', 'tok', undefined, 'a@a.com', 'cal'); + + const needingPoll = service.getConnectionsNeedingPoll(); + expect(needingPoll).toHaveLength(1); + expect(needingPoll[0].id).toBe(conn.id); + }); + + it('does not poll recently polled connections', () => { + const conn = service.createConnection('user_1', 'ical', 'tok', undefined, 'a@a.com', 'cal'); + service.pollConnection(conn.id); + + const needingPoll = service.getConnectionsNeedingPoll(); + expect(needingPoll).toHaveLength(0); + }); + + it('does not poll webhook-based connections', () => { + service.createConnection('user_1', 'google', 'tok', undefined, 'a@a.com', 'cal'); + + const needingPoll = service.getConnectionsNeedingPoll(); + expect(needingPoll).toHaveLength(0); + }); + }); + + describe('ICS Export', () => { + it('generates valid ICS content', () => { + const conn = service.createConnection('user_1', 'google', 'tok', undefined, 'a@a.com', 'cal'); + service.createEvent(conn.id, 'sub_1', 'payment_due', 'Netflix Due', 'Monthly charge', + '2025-02-01T09:00:00Z', '2025-02-01T09:30:00Z'); + + const ics = service.generateICS(conn.id); + expect(ics).toContain('BEGIN:VCALENDAR'); + expect(ics).toContain('BEGIN:VEVENT'); + expect(ics).toContain('SUMMARY:Netflix Due'); + expect(ics).toContain('END:VCALENDAR'); + }); + }); + + describe('Sync Preferences', () => { + it('stores and retrieves sync preferences', () => { + service.setSyncPreferences({ + userId: 'user_1', + enabledEventTypes: ['payment_due', 'renewal'], + defaultSyncDirection: 'bidirectional', + reminderMinutesBefore: [60, 1440], + }); + + const prefs = service.getSyncPreferences('user_1'); + expect(prefs).toBeDefined(); + expect(prefs!.enabledEventTypes).toEqual(['payment_due', 'renewal']); + }); + + it('returns undefined for unknown user', () => { + expect(service.getSyncPreferences('unknown')).toBeUndefined(); + }); + }); +}); + +describe('SyncWorker', () => { + let service: CalendarSyncService; + let worker: SyncWorker; + + beforeEach(() => { + service = new CalendarSyncService({ pollIntervalMs: 100, rateLimitPerMinute: 100 }); + worker = new SyncWorker(service, { pollIntervalMs: 100 }); + }); + + afterEach(() => { + worker.stop(); + }); + + it('starts and stops', () => { + expect(worker.isRunning()).toBe(false); + worker.start(); + expect(worker.isRunning()).toBe(true); + worker.stop(); + expect(worker.isRunning()).toBe(false); + }); + + it('triggers immediate sync', () => { + const conn = service.createConnection('user_1', 'google', 'tok', undefined, 'a@a.com', 'cal'); + service.createEvent(conn.id, 'sub_1', 'payment_due', 'P', 'D', + '2025-01-01T00:00:00Z', '2025-01-01T01:00:00Z'); + + const result = worker.triggerImmediateSync(conn.id); + expect(result.pushed).toBe(1); + }); + + it('processes pending webhooks', () => { + const conn = service.createConnection('user_1', 'google', 'tok', undefined, 'a@a.com', 'cal'); + const event = service.createEvent(conn.id, 'sub_1', 'payment_due', 'P', 'D', + '2025-01-01T00:00:00Z', '2025-01-01T01:00:00Z'); + service.pushToCalendar(conn.id); + + service.handleWebhookNotification({ + provider: 'google', + connectionId: conn.id, + resourceId: event.providerEventId, + changeType: 'updated', + timestamp: new Date().toISOString(), + }); + + const results = worker.processWebhooks(); + expect(results).toHaveLength(1); + }); + + it('runs poll cycle for eligible connections', () => { + service.createConnection('user_1', 'ical', 'tok', undefined, 'a@a.com', 'cal'); + + const results = worker.runPollCycle(); + expect(results).toHaveLength(1); + }); +}); diff --git a/backend/calendar/controller/calendarSyncController.ts b/backend/calendar/controller/calendarSyncController.ts new file mode 100644 index 00000000..0a76991f --- /dev/null +++ b/backend/calendar/controller/calendarSyncController.ts @@ -0,0 +1,215 @@ +import { CalendarSyncService, type RemoteChange } from '../domain/CalendarSyncService'; +import type { + CalendarConnection, + CalendarEvent, + CalendarEventType, + CalendarProvider, + CalendarSyncPreferences, + SyncDirection, + SyncResult, + WebhookNotification, +} from '../domain/types'; + +interface ControllerResult { + success: boolean; + data?: T; + error?: string; + status?: number; +} + +export function createCalendarSyncController(deps: { + syncService: CalendarSyncService; +}) { + const { syncService } = deps; + + return { + createConnection(body: { + userId: string; + provider: CalendarProvider; + accessToken: string; + refreshToken?: string; + accountEmail: string; + calendarId: string; + syncDirection?: SyncDirection; + enabledEventTypes?: CalendarEventType[]; + }): ControllerResult { + try { + if (!body.userId || !body.provider || !body.accessToken || !body.accountEmail) { + return { success: false, error: 'Missing required fields', status: 400 }; + } + + const conn = syncService.createConnection( + body.userId, + body.provider, + body.accessToken, + body.refreshToken, + body.accountEmail, + body.calendarId, + body.syncDirection, + body.enabledEventTypes, + ); + return { success: true, data: conn }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 500 }; + } + }, + + getConnection(connectionId: string): ControllerResult { + const conn = syncService.getConnection(connectionId); + if (!conn) return { success: false, error: 'Connection not found', status: 404 }; + return { success: true, data: conn }; + }, + + listConnections(userId: string): ControllerResult { + const connections = syncService.listConnections(userId); + return { success: true, data: connections }; + }, + + updateConnectionSettings(connectionId: string, body: { + syncDirection?: SyncDirection; + enabledEventTypes?: CalendarEventType[]; + selectedCalendarId?: string; + }): ControllerResult { + try { + const conn = syncService.updateConnectionSettings(connectionId, body); + return { success: true, data: conn }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + disconnectConnection(connectionId: string): ControllerResult { + try { + const conn = syncService.disconnectConnection(connectionId); + return { success: true, data: conn }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + createEvent(connectionId: string, body: { + subscriptionId: string; + eventType: CalendarEventType; + title: string; + description: string; + startTime: string; + endTime: string; + allDay?: boolean; + }): ControllerResult { + try { + if (!body.subscriptionId || !body.eventType || !body.title) { + return { success: false, error: 'Missing required fields', status: 400 }; + } + const event = syncService.createEvent( + connectionId, + body.subscriptionId, + body.eventType, + body.title, + body.description, + body.startTime, + body.endTime, + body.allDay, + ); + return { success: true, data: event }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + listEvents(connectionId: string): ControllerResult { + try { + const events = syncService.listEvents(connectionId); + return { success: true, data: events }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + listEventsBySubscription(subscriptionId: string): ControllerResult { + const events = syncService.listEventsBySubscription(subscriptionId); + return { success: true, data: events }; + }, + + updateEvent(eventId: string, body: { + title?: string; + description?: string; + startTime?: string; + endTime?: string; + }): ControllerResult { + try { + const event = syncService.updateEventLocally(eventId, body); + return { success: true, data: event }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + deleteEvent(eventId: string): ControllerResult { + try { + const event = syncService.deleteEvent(eventId); + return { success: true, data: event }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + pushSync(connectionId: string): ControllerResult { + try { + const result = syncService.pushToCalendar(connectionId); + return { success: true, data: result }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 500 }; + } + }, + + pullSync(connectionId: string, remoteChanges: RemoteChange[]): ControllerResult { + try { + const result = syncService.pullFromCalendar(connectionId, remoteChanges); + return { success: true, data: result }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 500 }; + } + }, + + fullSync(connectionId: string, remoteChanges: RemoteChange[] = []): ControllerResult { + try { + const result = syncService.fullSync(connectionId, remoteChanges); + return { success: true, data: result }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 500 }; + } + }, + + handleWebhook(notification: WebhookNotification): ControllerResult { + try { + syncService.handleWebhookNotification(notification); + return { success: true }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + exportICS(connectionId: string): ControllerResult<{ ics: string }> { + try { + const ics = syncService.generateICS(connectionId); + return { success: true, data: { ics } }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + setSyncPreferences(preferences: CalendarSyncPreferences): ControllerResult { + try { + syncService.setSyncPreferences(preferences); + return { success: true }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + getSyncPreferences(userId: string): ControllerResult { + const prefs = syncService.getSyncPreferences(userId); + return { success: true, data: prefs }; + }, + }; +} diff --git a/backend/calendar/domain/CalendarSyncService.ts b/backend/calendar/domain/CalendarSyncService.ts new file mode 100644 index 00000000..82216218 --- /dev/null +++ b/backend/calendar/domain/CalendarSyncService.ts @@ -0,0 +1,515 @@ +import { randomBytes } from 'crypto'; +import type { + CalendarConnection, + CalendarEvent, + CalendarEventType, + CalendarProvider, + CalendarSyncPreferences, + SyncConflict, + SyncDirection, + SyncError, + SyncResult, + SyncWorkerConfig, + WebhookNotification, +} from './types'; +import { DEFAULT_SYNC_CONFIG } from './types'; + +function generateId(prefix: string): string { + return `${prefix}_${randomBytes(12).toString('hex')}`; +} + +export class CalendarSyncService { + private connections = new Map(); + private events = new Map(); + private preferences = new Map(); + private webhookQueue: WebhookNotification[] = []; + private rateLimitCounters = new Map(); + private config: SyncWorkerConfig; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_SYNC_CONFIG, ...config }; + } + + // ── Connection Management ─────────────────────────────────────────── + + createConnection( + userId: string, + provider: CalendarProvider, + accessToken: string, + refreshToken: string | undefined, + accountEmail: string, + calendarId: string, + syncDirection: SyncDirection = 'bidirectional', + enabledEventTypes: CalendarEventType[] = ['payment_due', 'renewal', 'trial_ending'], + ): CalendarConnection { + const now = new Date().toISOString(); + const connection: CalendarConnection = { + id: generateId('cal_conn'), + userId, + provider, + accessToken, + refreshToken, + calendarId, + accountEmail, + syncDirection, + syncMethod: provider === 'ical' ? 'poll' : 'webhook', + enabledEventTypes, + status: 'connected', + createdAt: now, + updatedAt: now, + }; + + this.connections.set(connection.id, connection); + + if (connection.syncMethod === 'webhook' && provider !== 'ical') { + this.registerWebhook(connection); + } + + return connection; + } + + getConnection(connectionId: string): CalendarConnection | undefined { + return this.connections.get(connectionId); + } + + listConnections(userId: string): CalendarConnection[] { + return Array.from(this.connections.values()).filter( + (c) => c.userId === userId, + ); + } + + updateConnectionSettings( + connectionId: string, + updates: { + syncDirection?: SyncDirection; + enabledEventTypes?: CalendarEventType[]; + selectedCalendarId?: string; + }, + ): CalendarConnection { + const conn = this.requireConnection(connectionId); + if (updates.syncDirection !== undefined) conn.syncDirection = updates.syncDirection; + if (updates.enabledEventTypes !== undefined) conn.enabledEventTypes = updates.enabledEventTypes; + if (updates.selectedCalendarId !== undefined) conn.selectedCalendarId = updates.selectedCalendarId; + conn.updatedAt = new Date().toISOString(); + return conn; + } + + disconnectConnection(connectionId: string): CalendarConnection { + const conn = this.requireConnection(connectionId); + conn.status = 'disconnected'; + conn.updatedAt = new Date().toISOString(); + + const connEvents = Array.from(this.events.values()).filter( + (e) => e.connectionId === connectionId, + ); + for (const event of connEvents) { + this.events.delete(event.id); + } + + return conn; + } + + // ── Event Management ──────────────────────────────────────────────── + + createEvent( + connectionId: string, + subscriptionId: string, + eventType: CalendarEventType, + title: string, + description: string, + startTime: string, + endTime: string, + allDay = false, + ): CalendarEvent { + const conn = this.requireConnection(connectionId); + if (!conn.enabledEventTypes.includes(eventType)) { + throw new Error(`Event type '${eventType}' is not enabled for this connection`); + } + + const now = new Date().toISOString(); + const event: CalendarEvent = { + id: generateId('cal_event'), + connectionId, + subscriptionId, + providerEventId: generateId('prov_evt'), + eventType, + title, + description, + startTime, + endTime, + allDay, + syncStatus: 'pending', + lastSyncedAt: now, + localUpdatedAt: now, + deleted: false, + }; + + this.events.set(event.id, event); + return event; + } + + getEvent(eventId: string): CalendarEvent | undefined { + return this.events.get(eventId); + } + + listEvents(connectionId: string): CalendarEvent[] { + return Array.from(this.events.values()).filter( + (e) => e.connectionId === connectionId && !e.deleted, + ); + } + + listEventsBySubscription(subscriptionId: string): CalendarEvent[] { + return Array.from(this.events.values()).filter( + (e) => e.subscriptionId === subscriptionId && !e.deleted, + ); + } + + updateEventLocally( + eventId: string, + updates: Partial>, + ): CalendarEvent { + const event = this.requireEvent(eventId); + if (updates.title !== undefined) event.title = updates.title; + if (updates.description !== undefined) event.description = updates.description; + if (updates.startTime !== undefined) event.startTime = updates.startTime; + if (updates.endTime !== undefined) event.endTime = updates.endTime; + event.localUpdatedAt = new Date().toISOString(); + event.syncStatus = 'pending'; + return event; + } + + deleteEvent(eventId: string): CalendarEvent { + const event = this.requireEvent(eventId); + event.deleted = true; + event.localUpdatedAt = new Date().toISOString(); + event.syncStatus = 'pending'; + return event; + } + + // ── Two-Way Sync ──────────────────────────────────────────────────── + + pushToCalendar(connectionId: string): SyncResult { + const conn = this.requireConnection(connectionId); + if (conn.syncDirection === 'from_calendar') { + return this.emptySyncResult(connectionId); + } + + if (!this.checkRateLimit(connectionId)) { + return { + ...this.emptySyncResult(connectionId), + errors: [{ eventId: '', error: 'Rate limit exceeded', retryable: true }], + }; + } + + const pendingEvents = Array.from(this.events.values()).filter( + (e) => e.connectionId === connectionId && e.syncStatus === 'pending', + ); + + let pushed = 0; + const errors: SyncError[] = []; + + for (const event of pendingEvents) { + try { + if (event.deleted) { + event.syncStatus = 'synced'; + } else { + event.syncStatus = 'synced'; + event.lastSyncedAt = new Date().toISOString(); + event.providerEventId = event.providerEventId || generateId('prov_evt'); + } + pushed++; + } catch (err) { + errors.push({ + eventId: event.id, + error: (err as Error).message, + retryable: true, + }); + event.syncStatus = 'failed'; + } + } + + conn.lastSyncedAt = new Date().toISOString(); + conn.updatedAt = conn.lastSyncedAt; + + return { + connectionId, + syncedAt: conn.lastSyncedAt, + pushed, + pulled: 0, + conflicts: [], + errors, + }; + } + + pullFromCalendar(connectionId: string, remoteChanges: RemoteChange[] = []): SyncResult { + const conn = this.requireConnection(connectionId); + if (conn.syncDirection === 'to_calendar') { + return this.emptySyncResult(connectionId); + } + + if (!this.checkRateLimit(connectionId)) { + return { + ...this.emptySyncResult(connectionId), + errors: [{ eventId: '', error: 'Rate limit exceeded', retryable: true }], + }; + } + + let pulled = 0; + const conflicts: SyncConflict[] = []; + const errors: SyncError[] = []; + + for (const change of remoteChanges) { + try { + const existing = Array.from(this.events.values()).find( + (e) => e.providerEventId === change.providerEventId && e.connectionId === connectionId, + ); + + if (change.changeType === 'deleted') { + if (existing) { + existing.deleted = true; + existing.syncStatus = 'synced'; + existing.lastSyncedAt = new Date().toISOString(); + pulled++; + } + continue; + } + + if (existing) { + const conflict = this.detectConflict(existing, change); + if (conflict) { + conflicts.push(conflict); + this.resolveConflict(existing, change, conflict); + } else { + this.applyRemoteChange(existing, change); + } + pulled++; + } + } catch (err) { + errors.push({ + eventId: change.providerEventId, + error: (err as Error).message, + retryable: true, + }); + } + } + + conn.lastSyncedAt = new Date().toISOString(); + conn.lastPollAt = conn.lastSyncedAt; + conn.updatedAt = conn.lastSyncedAt; + + return { + connectionId, + syncedAt: conn.lastSyncedAt, + pushed: 0, + pulled, + conflicts, + errors, + }; + } + + fullSync(connectionId: string, remoteChanges: RemoteChange[] = []): SyncResult { + const pushResult = this.pushToCalendar(connectionId); + const pullResult = this.pullFromCalendar(connectionId, remoteChanges); + + return { + connectionId, + syncedAt: new Date().toISOString(), + pushed: pushResult.pushed, + pulled: pullResult.pulled, + conflicts: [...pushResult.conflicts, ...pullResult.conflicts], + errors: [...pushResult.errors, ...pullResult.errors], + }; + } + + // ── Webhook Handling ──────────────────────────────────────────────── + + handleWebhookNotification(notification: WebhookNotification): void { + this.webhookQueue.push(notification); + } + + processWebhookQueue(): SyncResult[] { + const results: SyncResult[] = []; + const pending = [...this.webhookQueue]; + this.webhookQueue = []; + + const grouped = new Map(); + for (const n of pending) { + const existing = grouped.get(n.connectionId) ?? []; + existing.push(n); + grouped.set(n.connectionId, existing); + } + + for (const [connectionId, notifications] of grouped) { + const remoteChanges: RemoteChange[] = notifications.map((n) => ({ + providerEventId: n.resourceId, + changeType: n.changeType, + title: undefined, + startTime: undefined, + endTime: undefined, + updatedAt: n.timestamp, + })); + + const result = this.pullFromCalendar(connectionId, remoteChanges); + results.push(result); + } + + return results; + } + + // ── Polling Fallback ──────────────────────────────────────────────── + + getConnectionsNeedingPoll(): CalendarConnection[] { + const now = Date.now(); + return Array.from(this.connections.values()).filter((conn) => { + if (conn.status !== 'connected') return false; + if (conn.syncMethod !== 'poll') return false; + + const lastPoll = conn.lastPollAt ? new Date(conn.lastPollAt).getTime() : 0; + return now - lastPoll >= this.config.pollIntervalMs; + }); + } + + pollConnection(connectionId: string, remoteChanges: RemoteChange[] = []): SyncResult { + const conn = this.requireConnection(connectionId); + conn.lastPollAt = new Date().toISOString(); + return this.fullSync(connectionId, remoteChanges); + } + + // ── Sync Preferences ─────────────────────────────────────────────── + + setSyncPreferences(preferences: CalendarSyncPreferences): void { + this.preferences.set(preferences.userId, preferences); + } + + getSyncPreferences(userId: string): CalendarSyncPreferences | undefined { + return this.preferences.get(userId); + } + + // ── ICS Generation ────────────────────────────────────────────────── + + generateICS(connectionId: string): string { + const events = this.listEvents(connectionId); + const lines: string[] = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//SubTrackr//Calendar Sync//EN', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + ]; + + for (const event of events) { + const start = new Date(event.startTime).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); + const end = new Date(event.endTime).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); + + lines.push('BEGIN:VEVENT'); + lines.push(`UID:${event.id}@subtrackr`); + lines.push(`DTSTAMP:${start}`); + lines.push(`DTSTART:${start}`); + lines.push(`DTEND:${end}`); + lines.push(`SUMMARY:${event.title.replace(/[;,\\]/g, '\\$&')}`); + lines.push(`DESCRIPTION:${event.description.replace(/[;,\\]/g, '\\$&')}`); + lines.push('END:VEVENT'); + } + + lines.push('END:VCALENDAR'); + return lines.join('\r\n'); + } + + // ── Internals ─────────────────────────────────────────────────────── + + private registerWebhook(connection: CalendarConnection): void { + connection.webhookId = generateId('webhook'); + connection.webhookExpiry = new Date( + Date.now() + 7 * 24 * 60 * 60 * 1000, + ).toISOString(); + } + + private requireConnection(connectionId: string): CalendarConnection { + const conn = this.connections.get(connectionId); + if (!conn) throw new Error(`Calendar connection ${connectionId} not found`); + return conn; + } + + private requireEvent(eventId: string): CalendarEvent { + const event = this.events.get(eventId); + if (!event) throw new Error(`Calendar event ${eventId} not found`); + return event; + } + + private emptySyncResult(connectionId: string): SyncResult { + return { + connectionId, + syncedAt: new Date().toISOString(), + pushed: 0, + pulled: 0, + conflicts: [], + errors: [], + }; + } + + private checkRateLimit(connectionId: string): boolean { + const now = Date.now(); + const counter = this.rateLimitCounters.get(connectionId); + + if (!counter || now - counter.windowStart > 60_000) { + this.rateLimitCounters.set(connectionId, { count: 1, windowStart: now }); + return true; + } + + if (counter.count >= this.config.rateLimitPerMinute) { + return false; + } + + counter.count++; + return true; + } + + private detectConflict(existing: CalendarEvent, remote: RemoteChange): SyncConflict | null { + if (!remote.updatedAt || !existing.localUpdatedAt) return null; + + const remoteTime = new Date(remote.updatedAt).getTime(); + const localTime = new Date(existing.localUpdatedAt).getTime(); + const lastSync = existing.lastSyncedAt ? new Date(existing.lastSyncedAt).getTime() : 0; + + if (remoteTime > lastSync && localTime > lastSync) { + if (remote.title && remote.title !== existing.title) { + return { + eventId: existing.id, + field: 'title', + localValue: existing.title, + remoteValue: remote.title, + resolvedWith: remoteTime > localTime ? 'remote' : 'local', + }; + } + } + + return null; + } + + private resolveConflict(existing: CalendarEvent, remote: RemoteChange, conflict: SyncConflict): void { + if (conflict.resolvedWith === 'remote') { + this.applyRemoteChange(existing, remote); + } + existing.syncStatus = 'synced'; + } + + private applyRemoteChange(event: CalendarEvent, change: RemoteChange): void { + if (change.title !== undefined) event.title = change.title; + if (change.startTime !== undefined) event.startTime = change.startTime; + if (change.endTime !== undefined) event.endTime = change.endTime; + event.providerUpdatedAt = change.updatedAt; + event.lastSyncedAt = new Date().toISOString(); + event.syncStatus = 'synced'; + } +} + +export interface RemoteChange { + providerEventId: string; + changeType: 'created' | 'updated' | 'deleted'; + title?: string; + startTime?: string; + endTime?: string; + updatedAt?: string; +} + +export const calendarSyncService = new CalendarSyncService(); diff --git a/backend/calendar/domain/SyncWorker.ts b/backend/calendar/domain/SyncWorker.ts new file mode 100644 index 00000000..376fa01e --- /dev/null +++ b/backend/calendar/domain/SyncWorker.ts @@ -0,0 +1,66 @@ +import { CalendarSyncService, type RemoteChange } from './CalendarSyncService'; +import type { SyncResult, SyncWorkerConfig } from './types'; +import { DEFAULT_SYNC_CONFIG } from './types'; + +export class SyncWorker { + private service: CalendarSyncService; + private config: SyncWorkerConfig; + private running = false; + private pollTimer: ReturnType | null = null; + private webhookTimer: ReturnType | null = null; + + constructor(service: CalendarSyncService, config: Partial = {}) { + this.service = service; + this.config = { ...DEFAULT_SYNC_CONFIG, ...config }; + } + + start(): void { + if (this.running) return; + this.running = true; + + this.pollTimer = setInterval(() => { + this.runPollCycle(); + }, this.config.pollIntervalMs); + + this.webhookTimer = setInterval(() => { + this.processWebhooks(); + }, 5_000); + } + + stop(): void { + this.running = false; + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = null; + } + if (this.webhookTimer) { + clearInterval(this.webhookTimer); + this.webhookTimer = null; + } + } + + isRunning(): boolean { + return this.running; + } + + runPollCycle(remoteChangesPerConnection?: Map): SyncResult[] { + const connections = this.service.getConnectionsNeedingPoll(); + const results: SyncResult[] = []; + + for (const conn of connections) { + const changes = remoteChangesPerConnection?.get(conn.id) ?? []; + const result = this.service.pollConnection(conn.id, changes); + results.push(result); + } + + return results; + } + + processWebhooks(): SyncResult[] { + return this.service.processWebhookQueue(); + } + + triggerImmediateSync(connectionId: string, remoteChanges: RemoteChange[] = []): SyncResult { + return this.service.fullSync(connectionId, remoteChanges); + } +} diff --git a/backend/calendar/domain/types.ts b/backend/calendar/domain/types.ts new file mode 100644 index 00000000..cd90e378 --- /dev/null +++ b/backend/calendar/domain/types.ts @@ -0,0 +1,119 @@ +export type CalendarProvider = 'google' | 'outlook' | 'ical'; +export type CalendarEventType = + | 'payment_due' + | 'payment_received' + | 'trial_ending' + | 'renewal' + | 'contract_end'; +export type SyncDirection = 'to_calendar' | 'from_calendar' | 'bidirectional'; +export type SyncMethod = 'webhook' | 'poll'; +export type SyncStatus = 'pending' | 'synced' | 'failed' | 'conflict'; + +export interface CalendarConnection { + id: string; + userId: string; + provider: CalendarProvider; + accessToken: string; + refreshToken?: string; + calendarId: string; + accountEmail: string; + syncDirection: SyncDirection; + syncMethod: SyncMethod; + enabledEventTypes: CalendarEventType[]; + selectedCalendarId?: string; + webhookId?: string; + webhookExpiry?: string; + lastSyncedAt?: string; + lastPollAt?: string; + status: 'connected' | 'disconnected' | 'error'; + errorMessage?: string; + createdAt: string; + updatedAt: string; +} + +export interface CalendarEvent { + id: string; + connectionId: string; + subscriptionId: string; + providerEventId: string; + eventType: CalendarEventType; + title: string; + description: string; + startTime: string; + endTime: string; + allDay: boolean; + syncStatus: SyncStatus; + lastSyncedAt: string; + providerUpdatedAt?: string; + localUpdatedAt: string; + deleted: boolean; +} + +export interface SyncResult { + connectionId: string; + syncedAt: string; + pushed: number; + pulled: number; + conflicts: SyncConflict[]; + errors: SyncError[]; +} + +export interface SyncConflict { + eventId: string; + field: string; + localValue: string; + remoteValue: string; + resolvedWith: 'local' | 'remote'; +} + +export interface SyncError { + eventId: string; + error: string; + retryable: boolean; +} + +export interface WebhookNotification { + provider: CalendarProvider; + connectionId: string; + resourceId: string; + changeType: 'created' | 'updated' | 'deleted'; + timestamp: string; +} + +export interface SyncWorkerConfig { + pollIntervalMs: number; + webhookRenewalBeforeExpiryMs: number; + maxRetries: number; + rateLimitPerMinute: number; +} + +export interface CalendarSyncPreferences { + userId: string; + enabledEventTypes: CalendarEventType[]; + defaultSyncDirection: SyncDirection; + preferredCalendarId?: string; + reminderMinutesBefore: number[]; +} + +export const DEFAULT_SYNC_CONFIG: SyncWorkerConfig = { + pollIntervalMs: 60 * 60 * 1000, + webhookRenewalBeforeExpiryMs: 24 * 60 * 60 * 1000, + maxRetries: 3, + rateLimitPerMinute: 60, +}; + +export const ALL_EVENT_TYPES: CalendarEventType[] = [ + 'payment_due', + 'payment_received', + 'trial_ending', + 'renewal', + 'contract_end', +]; + +export const EVENT_TYPE_LABELS: Record = { + payment_due: 'Payment Due', + payment_received: 'Payment Received', + trial_ending: 'Trial Ending', + renewal: 'Renewal', + contract_end: 'Contract End', +}; diff --git a/backend/calendar/index.ts b/backend/calendar/index.ts new file mode 100644 index 00000000..e0d959ae --- /dev/null +++ b/backend/calendar/index.ts @@ -0,0 +1,24 @@ +export { CalendarSyncService, calendarSyncService } from './domain/CalendarSyncService'; +export type { RemoteChange } from './domain/CalendarSyncService'; +export { SyncWorker } from './domain/SyncWorker'; +export { createCalendarSyncController } from './controller/calendarSyncController'; +export type { + CalendarConnection, + CalendarEvent, + CalendarEventType, + CalendarProvider, + CalendarSyncPreferences, + SyncConflict, + SyncDirection, + SyncError, + SyncMethod, + SyncResult, + SyncStatus, + SyncWorkerConfig, + WebhookNotification, +} from './domain/types'; +export { + ALL_EVENT_TYPES, + DEFAULT_SYNC_CONFIG, + EVENT_TYPE_LABELS, +} from './domain/types'; diff --git a/backend/sso/__tests__/SCIMService.test.ts b/backend/sso/__tests__/SCIMService.test.ts new file mode 100644 index 00000000..d6840a9b --- /dev/null +++ b/backend/sso/__tests__/SCIMService.test.ts @@ -0,0 +1,301 @@ +import { SCIMService } from '../domain/SCIMService'; +import type { RoleMapping } from '../domain/types'; + +const DEFAULT_MAPPINGS: RoleMapping[] = [ + { idpGroup: 'admins', subtrackrRole: 'admin' }, + { idpGroup: 'finance', subtrackrRole: 'billing' }, + { idpGroup: 'engineering', subtrackrRole: 'viewer' }, +]; + +describe('SCIMService', () => { + let service: SCIMService; + + beforeEach(() => { + service = new SCIMService(); + }); + + describe('User Provisioning', () => { + it('creates a new SCIM user', () => { + const user = service.createUser( + 'org_1', 'idp_1', 'ext_001', + 'alice@example.com', 'Alice Smith', 'Alice', 'Smith', + ['admins'], DEFAULT_MAPPINGS, + ); + + expect(user.id).toMatch(/^scim_user_/); + expect(user.email).toBe('alice@example.com'); + expect(user.role).toBe('admin'); + expect(user.status).toBe('active'); + }); + + it('assigns default viewer role for unmapped groups', () => { + const user = service.createUser( + 'org_1', 'idp_1', 'ext_002', + 'bob@example.com', 'Bob Jones', 'Bob', 'Jones', + ['unknown_group'], DEFAULT_MAPPINGS, + ); + + expect(user.role).toBe('viewer'); + }); + + it('reactivates a deactivated user on re-provision', () => { + const user = service.createUser( + 'org_1', 'idp_1', 'ext_001', + 'alice@example.com', 'Alice Smith', 'Alice', 'Smith', + ['admins'], DEFAULT_MAPPINGS, + ); + + service.deactivateUser(user.id); + expect(service.getUser(user.id)!.status).toBe('deactivated'); + + const reactivated = service.createUser( + 'org_1', 'idp_1', 'ext_001', + 'alice.new@example.com', 'Alice New', 'Alice', 'New', + ['finance'], DEFAULT_MAPPINGS, + ); + + expect(reactivated.id).toBe(user.id); + expect(reactivated.status).toBe('active'); + expect(reactivated.email).toBe('alice.new@example.com'); + expect(reactivated.role).toBe('billing'); + }); + }); + + describe('User Lookup', () => { + it('finds user by ID', () => { + const created = service.createUser( + 'org_1', 'idp_1', 'ext_001', + 'alice@example.com', 'Alice', 'Alice', 'Smith', + [], DEFAULT_MAPPINGS, + ); + + const found = service.getUser(created.id); + expect(found).toBeDefined(); + expect(found!.email).toBe('alice@example.com'); + }); + + it('finds user by external ID', () => { + service.createUser( + 'org_1', 'idp_1', 'ext_001', + 'alice@example.com', 'Alice', 'Alice', 'Smith', + [], DEFAULT_MAPPINGS, + ); + + const found = service.getUserByExternalId('org_1', 'ext_001'); + expect(found).toBeDefined(); + expect(found!.email).toBe('alice@example.com'); + }); + + it('returns undefined for unknown user', () => { + expect(service.getUser('nonexistent')).toBeUndefined(); + expect(service.getUserByExternalId('org_1', 'nope')).toBeUndefined(); + }); + }); + + describe('User Listing', () => { + it('lists users in an organization with pagination', () => { + for (let i = 0; i < 5; i++) { + service.createUser( + 'org_1', 'idp_1', `ext_${i}`, + `user${i}@example.com`, `User ${i}`, 'First', 'Last', + [], DEFAULT_MAPPINGS, + ); + } + + const page1 = service.listUsers('org_1', 1, 3); + expect(page1.totalResults).toBe(5); + expect(page1.Resources).toHaveLength(3); + expect(page1.startIndex).toBe(1); + + const page2 = service.listUsers('org_1', 4, 3); + expect(page2.Resources).toHaveLength(2); + }); + + it('filters users by email', () => { + service.createUser('org_1', 'idp_1', 'ext_1', 'alice@example.com', 'Alice', 'A', 'S', [], DEFAULT_MAPPINGS); + service.createUser('org_1', 'idp_1', 'ext_2', 'bob@example.com', 'Bob', 'B', 'J', [], DEFAULT_MAPPINGS); + + const result = service.listUsers('org_1', 1, 100, 'userName eq "alice@example.com"'); + expect(result.totalResults).toBe(1); + expect(result.Resources[0].email).toBe('alice@example.com'); + }); + + it('filters users by external ID', () => { + service.createUser('org_1', 'idp_1', 'ext_1', 'alice@example.com', 'Alice', 'A', 'S', [], DEFAULT_MAPPINGS); + service.createUser('org_1', 'idp_1', 'ext_2', 'bob@example.com', 'Bob', 'B', 'J', [], DEFAULT_MAPPINGS); + + const result = service.listUsers('org_1', 1, 100, 'externalId eq "ext_2"'); + expect(result.totalResults).toBe(1); + expect(result.Resources[0].externalId).toBe('ext_2'); + }); + }); + + describe('User Updates', () => { + it('updates user fields', () => { + const user = service.createUser( + 'org_1', 'idp_1', 'ext_001', + 'alice@example.com', 'Alice', 'Alice', 'Smith', + ['engineering'], DEFAULT_MAPPINGS, + ); + + const updated = service.updateUser(user.id, { + email: 'alice.new@example.com', + displayName: 'Alice New', + groups: ['admins'], + }, DEFAULT_MAPPINGS); + + expect(updated.email).toBe('alice.new@example.com'); + expect(updated.displayName).toBe('Alice New'); + expect(updated.role).toBe('admin'); + }); + + it('applies SCIM PATCH to deactivate user', () => { + const user = service.createUser( + 'org_1', 'idp_1', 'ext_001', + 'alice@example.com', 'Alice', 'Alice', 'Smith', + [], DEFAULT_MAPPINGS, + ); + + const patched = service.patchUser(user.id, [ + { op: 'replace', path: 'active', value: false }, + ], DEFAULT_MAPPINGS); + + expect(patched.status).toBe('deactivated'); + }); + + it('applies SCIM PATCH to add groups', () => { + const user = service.createUser( + 'org_1', 'idp_1', 'ext_001', + 'alice@example.com', 'Alice', 'Alice', 'Smith', + [], DEFAULT_MAPPINGS, + ); + + const patched = service.patchUser(user.id, [ + { op: 'add', path: 'groups', value: [{ value: 'admins' }] }, + ], DEFAULT_MAPPINGS); + + expect(patched.groups).toContain('admins'); + expect(patched.role).toBe('admin'); + }); + + it('applies SCIM PATCH to update display name', () => { + const user = service.createUser( + 'org_1', 'idp_1', 'ext_001', + 'alice@example.com', 'Alice', 'Alice', 'Smith', + [], DEFAULT_MAPPINGS, + ); + + const patched = service.patchUser(user.id, [ + { op: 'replace', path: 'displayName', value: 'Alice Updated' }, + ], DEFAULT_MAPPINGS); + + expect(patched.displayName).toBe('Alice Updated'); + }); + }); + + describe('User Lifecycle', () => { + it('deactivates a user', () => { + const user = service.createUser( + 'org_1', 'idp_1', 'ext_001', + 'alice@example.com', 'Alice', 'Alice', 'Smith', + [], DEFAULT_MAPPINGS, + ); + + const deactivated = service.deactivateUser(user.id); + expect(deactivated.status).toBe('deactivated'); + expect(deactivated.deactivatedAt).toBeTruthy(); + }); + + it('suspends a user', () => { + const user = service.createUser( + 'org_1', 'idp_1', 'ext_001', + 'alice@example.com', 'Alice', 'Alice', 'Smith', + [], DEFAULT_MAPPINGS, + ); + + const suspended = service.suspendUser(user.id); + expect(suspended.status).toBe('suspended'); + }); + + it('deletes a user', () => { + const user = service.createUser( + 'org_1', 'idp_1', 'ext_001', + 'alice@example.com', 'Alice', 'Alice', 'Smith', + [], DEFAULT_MAPPINGS, + ); + + service.deleteUser(user.id); + expect(service.getUser(user.id)).toBeUndefined(); + expect(service.getUserByExternalId('org_1', 'ext_001')).toBeUndefined(); + }); + + it('delete is idempotent for unknown user', () => { + expect(() => service.deleteUser('nonexistent')).not.toThrow(); + }); + }); + + describe('JIT Provisioning', () => { + it('provisions a new user on first SSO login', () => { + const user = service.jitProvision( + 'org_1', 'idp_1', + 'alice@example.com', 'Alice Smith', + ['admins'], DEFAULT_MAPPINGS, + ); + + expect(user.email).toBe('alice@example.com'); + expect(user.displayName).toBe('Alice Smith'); + expect(user.givenName).toBe('Alice'); + expect(user.familyName).toBe('Smith'); + expect(user.role).toBe('admin'); + expect(user.status).toBe('active'); + }); + + it('returns existing active user on subsequent SSO login', () => { + const first = service.jitProvision( + 'org_1', 'idp_1', + 'alice@example.com', 'Alice Smith', + ['admins'], DEFAULT_MAPPINGS, + ); + + const second = service.jitProvision( + 'org_1', 'idp_1', + 'alice@example.com', 'Alice Smith', + ['admins'], DEFAULT_MAPPINGS, + ); + + expect(second.id).toBe(first.id); + }); + }); + + describe('Group Membership Sync', () => { + it('syncs group membership additions and removals', () => { + const alice = service.createUser( + 'org_1', 'idp_1', 'ext_alice', + 'alice@example.com', 'Alice', 'A', 'S', + ['engineering'], DEFAULT_MAPPINGS, + ); + const bob = service.createUser( + 'org_1', 'idp_1', 'ext_bob', + 'bob@example.com', 'Bob', 'B', 'J', + ['engineering'], DEFAULT_MAPPINGS, + ); + service.createUser( + 'org_1', 'idp_1', 'ext_carol', + 'carol@example.com', 'Carol', 'C', 'W', + [], DEFAULT_MAPPINGS, + ); + + const result = service.syncGroupMembership( + 'org_1', 'idp_1', 'engineering', + ['ext_alice', 'ext_carol'], + DEFAULT_MAPPINGS, + ); + + expect(result.unchanged).toContain('ext_alice'); + expect(result.removed).toContain('ext_bob'); + expect(result.added).toContain('ext_carol'); + + expect(service.getUser(bob.id)!.groups).not.toContain('engineering'); + }); + }); +}); diff --git a/backend/sso/__tests__/SSOService.test.ts b/backend/sso/__tests__/SSOService.test.ts new file mode 100644 index 00000000..6e86f4a1 --- /dev/null +++ b/backend/sso/__tests__/SSOService.test.ts @@ -0,0 +1,342 @@ +import { SSOService } from '../domain/SSOService'; +import type { IdPCertificate, SAMLConfiguration } from '../domain/types'; + +function makeCert(overrides: Partial = {}): IdPCertificate { + return { + fingerprint: 'abc123', + notBefore: new Date().toISOString(), + notAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + isPrimary: true, + ...overrides, + }; +} + +function makeSAMLConfig(overrides: Partial = {}): SAMLConfiguration { + return { + entityId: 'https://idp.example.com', + ssoUrl: 'https://idp.example.com/sso', + certificates: [makeCert()], + nameIdFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + signAuthnRequests: true, + wantAssertionsSigned: true, + ...overrides, + }; +} + +describe('SSOService', () => { + let service: SSOService; + + beforeEach(() => { + service = new SSOService(); + }); + + describe('Identity Provider Management', () => { + it('creates a SAML identity provider', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + expect(idp.id).toMatch(/^idp_/); + expect(idp.organizationId).toBe('org_1'); + expect(idp.name).toBe('Okta'); + expect(idp.protocol).toBe('saml2'); + expect(idp.status).toBe('pending_setup'); + expect(idp.bypassCodes).toHaveLength(2); + }); + + it('creates an OIDC identity provider', () => { + const idp = service.createIdentityProvider('org_1', 'Azure AD', 'oidc'); + expect(idp.protocol).toBe('oidc'); + expect(idp.status).toBe('pending_setup'); + }); + + it('lists providers by organization', () => { + service.createIdentityProvider('org_1', 'Okta', 'saml2'); + service.createIdentityProvider('org_1', 'Azure AD', 'oidc'); + service.createIdentityProvider('org_2', 'OneLogin', 'saml2'); + + const org1Providers = service.listIdentityProviders('org_1'); + expect(org1Providers).toHaveLength(2); + + const org2Providers = service.listIdentityProviders('org_2'); + expect(org2Providers).toHaveLength(1); + }); + + it('retrieves a provider by ID', () => { + const created = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + const fetched = service.getIdentityProvider(created.id); + expect(fetched).toBeDefined(); + expect(fetched!.name).toBe('Okta'); + }); + + it('returns undefined for unknown provider', () => { + expect(service.getIdentityProvider('nonexistent')).toBeUndefined(); + }); + }); + + describe('SAML Configuration', () => { + it('configures SAML and activates provider', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + const config = makeSAMLConfig(); + + const updated = service.configureSAML(idp.id, config); + expect(updated.status).toBe('active'); + expect(updated.samlConfig).toBeDefined(); + expect(updated.samlConfig!.entityId).toBe('https://idp.example.com'); + }); + + it('rejects SAML config on OIDC provider', () => { + const idp = service.createIdentityProvider('org_1', 'Azure', 'oidc'); + expect(() => service.configureSAML(idp.id, makeSAMLConfig())).toThrow( + 'not configured for SAML 2.0', + ); + }); + + it('rejects empty certificates', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + expect(() => + service.configureSAML(idp.id, makeSAMLConfig({ certificates: [] })), + ).toThrow('At least one certificate is required'); + }); + + it('uploads SAML metadata XML', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + const xml = + '' + + '' + + ''; + + const updated = service.uploadSAMLMetadata(idp.id, xml); + expect(updated.status).toBe('active'); + expect(updated.samlConfig!.entityId).toBe('https://okta.example.com'); + }); + }); + + describe('OIDC Configuration', () => { + it('configures OIDC and activates provider', () => { + const idp = service.createIdentityProvider('org_1', 'Azure AD', 'oidc'); + const updated = service.configureOIDC(idp.id, { + issuer: 'https://login.microsoftonline.com/tenant', + authorizationEndpoint: 'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize', + tokenEndpoint: 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token', + userinfoEndpoint: 'https://graph.microsoft.com/oidc/userinfo', + jwksUri: 'https://login.microsoftonline.com/tenant/discovery/v2.0/keys', + clientId: 'client_123', + clientSecretHash: 'hashed_secret', + scopes: ['openid', 'profile', 'email'], + }); + + expect(updated.status).toBe('active'); + expect(updated.oidcConfig).toBeDefined(); + }); + + it('rejects OIDC config on SAML provider', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + expect(() => + service.configureOIDC(idp.id, { + issuer: 'https://example.com', + authorizationEndpoint: 'https://example.com/auth', + tokenEndpoint: 'https://example.com/token', + userinfoEndpoint: 'https://example.com/userinfo', + jwksUri: 'https://example.com/jwks', + clientId: 'client', + clientSecretHash: 'hash', + scopes: ['openid'], + }), + ).toThrow('not configured for OIDC'); + }); + }); + + describe('Role Mapping', () => { + it('sets role mappings', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + const updated = service.setRoleMappings(idp.id, [ + { idpGroup: 'admins', subtrackrRole: 'admin' }, + { idpGroup: 'finance', subtrackrRole: 'billing' }, + ]); + expect(updated.roleMappings).toHaveLength(2); + }); + + it('resolves role from groups', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + service.setRoleMappings(idp.id, [ + { idpGroup: 'admins', subtrackrRole: 'admin' }, + { idpGroup: 'finance', subtrackrRole: 'billing' }, + ]); + + expect(service.resolveRoleFromGroups(idp.id, ['admins'])).toBe('admin'); + expect(service.resolveRoleFromGroups(idp.id, ['finance'])).toBe('billing'); + expect(service.resolveRoleFromGroups(idp.id, ['unknown'])).toBe('viewer'); + }); + }); + + describe('JIT Provisioning', () => { + it('enables/disables JIT provisioning', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + expect(idp.jitProvisioningEnabled).toBe(false); + + const enabled = service.setJITProvisioning(idp.id, true); + expect(enabled.jitProvisioningEnabled).toBe(true); + + const disabled = service.setJITProvisioning(idp.id, false); + expect(disabled.jitProvisioningEnabled).toBe(false); + }); + }); + + describe('SSO Login Flow', () => { + it('initiates SAML login', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + service.configureSAML(idp.id, makeSAMLConfig()); + + const { redirectUrl, state } = service.initiateSSOLogin({ + identityProviderId: idp.id, + }); + + expect(redirectUrl).toContain('https://idp.example.com/sso'); + expect(redirectUrl).toContain('SAMLRequest='); + expect(state).toBeTruthy(); + }); + + it('initiates OIDC login', () => { + const idp = service.createIdentityProvider('org_1', 'Azure AD', 'oidc'); + service.configureOIDC(idp.id, { + issuer: 'https://login.microsoftonline.com/tenant', + authorizationEndpoint: 'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize', + tokenEndpoint: 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token', + userinfoEndpoint: 'https://graph.microsoft.com/oidc/userinfo', + jwksUri: 'https://login.microsoftonline.com/tenant/discovery/v2.0/keys', + clientId: 'client_123', + clientSecretHash: 'hashed_secret', + scopes: ['openid', 'profile', 'email'], + }); + + const { redirectUrl, state } = service.initiateSSOLogin({ + identityProviderId: idp.id, + }); + + expect(redirectUrl).toContain('authorize'); + expect(redirectUrl).toContain('client_id=client_123'); + expect(state).toBeTruthy(); + }); + + it('rejects login for inactive provider', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + expect(() => service.initiateSSOLogin({ identityProviderId: idp.id })).toThrow( + 'is not active', + ); + }); + + it('handles SSO callback and creates session', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + service.configureSAML(idp.id, makeSAMLConfig()); + + const { state } = service.initiateSSOLogin({ identityProviderId: idp.id }); + const session = service.handleSSOCallback({ state }); + + expect(session.id).toMatch(/^sso_sess_/); + expect(session.identityProviderId).toBe(idp.id); + expect(session.protocol).toBe('saml2'); + expect(session.authenticatedAt).toBeTruthy(); + expect(session.expiresAt).toBeTruthy(); + }); + }); + + describe('Session Management', () => { + it('retrieves and validates sessions', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + service.configureSAML(idp.id, makeSAMLConfig()); + const { state } = service.initiateSSOLogin({ identityProviderId: idp.id }); + const session = service.handleSSOCallback({ state }); + + const fetched = service.getSession(session.id); + expect(fetched).toBeDefined(); + expect(fetched!.id).toBe(session.id); + }); + + it('returns undefined for expired session', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + service.configureSAML(idp.id, makeSAMLConfig()); + const { state } = service.initiateSSOLogin({ identityProviderId: idp.id }); + const session = service.handleSSOCallback({ state }); + + // Manually expire the session + const stored = service.getSession(session.id)!; + (stored as any).expiresAt = new Date(Date.now() - 1000).toISOString(); + + expect(service.getSession(session.id)).toBeUndefined(); + }); + + it('revokes sessions', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + service.configureSAML(idp.id, makeSAMLConfig()); + const { state } = service.initiateSSOLogin({ identityProviderId: idp.id }); + const session = service.handleSSOCallback({ state }); + + service.revokeSession(session.id); + expect(service.getSession(session.id)).toBeUndefined(); + }); + }); + + describe('Certificate Management', () => { + it('detects expiring certificates', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + const expiringCert = makeCert({ + notAfter: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(), + }); + service.configureSAML(idp.id, makeSAMLConfig({ certificates: [expiringCert] })); + + const expiring = service.getExpiringCertificates(idp.id, 30); + expect(expiring).toHaveLength(1); + }); + + it('does not flag non-expiring certificates', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + service.configureSAML(idp.id, makeSAMLConfig()); + + const expiring = service.getExpiringCertificates(idp.id, 30); + expect(expiring).toHaveLength(0); + }); + + it('rotates certificates', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + service.configureSAML(idp.id, makeSAMLConfig()); + + const newCert = makeCert({ fingerprint: 'new_cert_123' }); + const updated = service.rotateCertificate(idp.id, newCert); + + expect(updated.samlConfig!.certificates.find((c) => c.fingerprint === 'new_cert_123')).toBeDefined(); + expect(updated.samlConfig!.certificates.find((c) => c.isPrimary)?.fingerprint).toBe('new_cert_123'); + }); + }); + + describe('Bypass Codes', () => { + it('validates and consumes bypass codes', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + const code = idp.bypassCodes[0]; + + expect(service.validateBypassCode(idp.id, code)).toBe(true); + expect(service.validateBypassCode(idp.id, code)).toBe(false); + }); + + it('regenerates bypass codes', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + const originalCodes = [...idp.bypassCodes]; + + const newCodes = service.regenerateBypassCodes(idp.id); + expect(newCodes).toHaveLength(2); + expect(newCodes).not.toEqual(originalCodes); + }); + + it('rejects invalid bypass code', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + expect(service.validateBypassCode(idp.id, 'INVALID')).toBe(false); + }); + }); + + describe('Provider Deactivation', () => { + it('deactivates an active provider', () => { + const idp = service.createIdentityProvider('org_1', 'Okta', 'saml2'); + service.configureSAML(idp.id, makeSAMLConfig()); + + const deactivated = service.deactivateProvider(idp.id); + expect(deactivated.status).toBe('inactive'); + }); + }); +}); diff --git a/backend/sso/controller/ssoController.ts b/backend/sso/controller/ssoController.ts new file mode 100644 index 00000000..5660fe4b --- /dev/null +++ b/backend/sso/controller/ssoController.ts @@ -0,0 +1,302 @@ +import { SSOService } from '../domain/SSOService'; +import { SCIMService } from '../domain/SCIMService'; +import type { + IdentityProvider, + OIDCConfiguration, + RoleMapping, + SAMLConfiguration, + SCIMPatchRequest, + SCIMUser, + SSOCallbackPayload, + SSOProtocol, + SSOSession, + SubTrackrRole, +} from '../domain/types'; + +interface ControllerResult { + success: boolean; + data?: T; + error?: string; + status?: number; +} + +export function createSSOController(deps: { + ssoService: SSOService; + scimService: SCIMService; +}) { + const { ssoService, scimService } = deps; + + return { + createIdentityProvider(body: { + organizationId: string; + name: string; + protocol: SSOProtocol; + }): ControllerResult { + try { + if (!body.organizationId || !body.name || !body.protocol) { + return { success: false, error: 'Missing required fields', status: 400 }; + } + if (body.protocol !== 'saml2' && body.protocol !== 'oidc') { + return { success: false, error: 'Protocol must be saml2 or oidc', status: 400 }; + } + const idp = ssoService.createIdentityProvider(body.organizationId, body.name, body.protocol); + return { success: true, data: idp }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 500 }; + } + }, + + getIdentityProvider(id: string): ControllerResult { + const idp = ssoService.getIdentityProvider(id); + if (!idp) return { success: false, error: 'Identity provider not found', status: 404 }; + return { success: true, data: idp }; + }, + + listIdentityProviders(organizationId: string): ControllerResult { + const providers = ssoService.listIdentityProviders(organizationId); + return { success: true, data: providers }; + }, + + configureSAML(idpId: string, config: SAMLConfiguration): ControllerResult { + try { + const idp = ssoService.configureSAML(idpId, config); + return { success: true, data: idp }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + configureOIDC(idpId: string, config: OIDCConfiguration): ControllerResult { + try { + const idp = ssoService.configureOIDC(idpId, config); + return { success: true, data: idp }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + uploadSAMLMetadata(idpId: string, metadataXml: string): ControllerResult { + try { + if (!metadataXml || metadataXml.trim().length === 0) { + return { success: false, error: 'Metadata XML is required', status: 400 }; + } + const idp = ssoService.uploadSAMLMetadata(idpId, metadataXml); + return { success: true, data: idp }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + configureSAMLFromUrl(idpId: string, metadataUrl: string): ControllerResult { + try { + if (!metadataUrl) { + return { success: false, error: 'Metadata URL is required', status: 400 }; + } + const idp = ssoService.configureSAMLFromUrl(idpId, metadataUrl); + return { success: true, data: idp }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + setRoleMappings(idpId: string, mappings: RoleMapping[]): ControllerResult { + try { + const idp = ssoService.setRoleMappings(idpId, mappings); + return { success: true, data: idp }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + setJITProvisioning(idpId: string, enabled: boolean): ControllerResult { + try { + const idp = ssoService.setJITProvisioning(idpId, enabled); + return { success: true, data: idp }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + initiateSSOLogin(body: { identityProviderId: string; relayState?: string }): ControllerResult<{ redirectUrl: string; state: string }> { + try { + if (!body.identityProviderId) { + return { success: false, error: 'identityProviderId is required', status: 400 }; + } + const result = ssoService.initiateSSOLogin(body); + return { success: true, data: result }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + handleSSOCallback(payload: SSOCallbackPayload): ControllerResult { + try { + const session = ssoService.handleSSOCallback(payload); + return { success: true, data: session }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 401 }; + } + }, + + deactivateProvider(idpId: string): ControllerResult { + try { + const idp = ssoService.deactivateProvider(idpId); + return { success: true, data: idp }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + regenerateBypassCodes(idpId: string): ControllerResult { + try { + const codes = ssoService.regenerateBypassCodes(idpId); + return { success: true, data: codes }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + validateBypassCode(idpId: string, code: string): ControllerResult<{ valid: boolean }> { + try { + const valid = ssoService.validateBypassCode(idpId, code); + return { success: true, data: { valid } }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + getExpiringCertificates(idpId: string, daysThreshold?: number): ControllerResult { + try { + const certs = ssoService.getExpiringCertificates(idpId, daysThreshold); + return { success: true, data: certs }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + // SCIM endpoints + + scimCreateUser(organizationId: string, identityProviderId: string, body: { + externalId: string; + email: string; + displayName: string; + givenName: string; + familyName: string; + groups?: string[]; + }): ControllerResult { + try { + const idp = ssoService.getIdentityProvider(identityProviderId); + if (!idp) return { success: false, error: 'Identity provider not found', status: 404 }; + + const user = scimService.createUser( + organizationId, + identityProviderId, + body.externalId, + body.email, + body.displayName, + body.givenName, + body.familyName, + body.groups ?? [], + idp.roleMappings, + ); + return { success: true, data: user }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + scimGetUser(userId: string): ControllerResult { + const user = scimService.getUser(userId); + if (!user) return { success: false, error: 'SCIM user not found', status: 404 }; + return { success: true, data: user }; + }, + + scimListUsers(organizationId: string, query: { + startIndex?: number; + count?: number; + filter?: string; + }): ControllerResult { + const result = scimService.listUsers( + organizationId, + query.startIndex, + query.count, + query.filter, + ); + return { success: true, data: result }; + }, + + scimUpdateUser(userId: string, identityProviderId: string, body: { + email?: string; + displayName?: string; + givenName?: string; + familyName?: string; + groups?: string[]; + }): ControllerResult { + try { + const idp = ssoService.getIdentityProvider(identityProviderId); + if (!idp) return { success: false, error: 'Identity provider not found', status: 404 }; + + const user = scimService.updateUser(userId, body, idp.roleMappings); + return { success: true, data: user }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + scimPatchUser(userId: string, identityProviderId: string, patch: SCIMPatchRequest): ControllerResult { + try { + const idp = ssoService.getIdentityProvider(identityProviderId); + if (!idp) return { success: false, error: 'Identity provider not found', status: 404 }; + + const user = scimService.patchUser(userId, patch.Operations, idp.roleMappings); + return { success: true, data: user }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + scimDeactivateUser(userId: string): ControllerResult { + try { + const user = scimService.deactivateUser(userId); + return { success: true, data: user }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + scimDeleteUser(userId: string): ControllerResult { + try { + scimService.deleteUser(userId); + return { success: true }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + + jitProvision(organizationId: string, identityProviderId: string, body: { + email: string; + displayName: string; + groups?: string[]; + }): ControllerResult { + try { + const idp = ssoService.getIdentityProvider(identityProviderId); + if (!idp) return { success: false, error: 'Identity provider not found', status: 404 }; + if (!idp.jitProvisioningEnabled) { + return { success: false, error: 'JIT provisioning is not enabled', status: 403 }; + } + + const user = scimService.jitProvision( + organizationId, + identityProviderId, + body.email, + body.displayName, + body.groups ?? [], + idp.roleMappings, + ); + return { success: true, data: user }; + } catch (err) { + return { success: false, error: (err as Error).message, status: 400 }; + } + }, + }; +} diff --git a/backend/sso/domain/SCIMService.ts b/backend/sso/domain/SCIMService.ts new file mode 100644 index 00000000..81e67528 --- /dev/null +++ b/backend/sso/domain/SCIMService.ts @@ -0,0 +1,319 @@ +import { randomBytes } from 'crypto'; +import type { + RoleMapping, + SCIMListResponse, + SCIMPatchOperation, + SCIMUser, + SCIMUserStatus, + SubTrackrRole, +} from './types'; + +const SCIM_SCHEMAS = { + user: 'urn:ietf:params:scim:schemas:core:2.0:User', + listResponse: 'urn:ietf:params:scim:api:messages:2.0:ListResponse', + patchOp: 'urn:ietf:params:scim:api:messages:2.0:PatchOp', +}; + +function generateId(prefix: string): string { + return `${prefix}_${randomBytes(12).toString('hex')}`; +} + +export class SCIMService { + private users = new Map(); + private externalIdIndex = new Map(); + + createUser( + organizationId: string, + identityProviderId: string, + externalId: string, + email: string, + displayName: string, + givenName: string, + familyName: string, + groups: string[], + roleMappings: RoleMapping[], + ): SCIMUser { + const existingId = this.externalIdIndex.get(`${organizationId}:${externalId}`); + if (existingId) { + return this.reactivateUser(existingId, email, displayName, givenName, familyName, groups, roleMappings); + } + + const now = new Date().toISOString(); + const role = this.resolveRole(groups, roleMappings); + + const user: SCIMUser = { + id: generateId('scim_user'), + externalId, + organizationId, + identityProviderId, + email, + displayName, + givenName, + familyName, + role, + status: 'active', + groups, + provisionedAt: now, + lastSyncedAt: now, + }; + + this.users.set(user.id, user); + this.externalIdIndex.set(`${organizationId}:${externalId}`, user.id); + return user; + } + + getUser(userId: string): SCIMUser | undefined { + return this.users.get(userId); + } + + getUserByExternalId(organizationId: string, externalId: string): SCIMUser | undefined { + const id = this.externalIdIndex.get(`${organizationId}:${externalId}`); + return id ? this.users.get(id) : undefined; + } + + listUsers( + organizationId: string, + startIndex = 1, + count = 100, + filter?: string, + ): SCIMListResponse { + let users = Array.from(this.users.values()).filter( + (u) => u.organizationId === organizationId, + ); + + if (filter) { + const emailMatch = filter.match(/userName eq "([^"]+)"/); + if (emailMatch) { + users = users.filter((u) => u.email === emailMatch[1]); + } + const externalIdMatch = filter.match(/externalId eq "([^"]+)"/); + if (externalIdMatch) { + users = users.filter((u) => u.externalId === externalIdMatch[1]); + } + } + + const totalResults = users.length; + const paged = users.slice(startIndex - 1, startIndex - 1 + count); + + return { + schemas: [SCIM_SCHEMAS.listResponse], + totalResults, + startIndex, + itemsPerPage: paged.length, + Resources: paged, + }; + } + + updateUser( + userId: string, + updates: Partial>, + roleMappings: RoleMapping[], + ): SCIMUser { + const user = this.users.get(userId); + if (!user) throw new Error(`SCIM user ${userId} not found`); + + if (updates.email !== undefined) user.email = updates.email; + if (updates.displayName !== undefined) user.displayName = updates.displayName; + if (updates.givenName !== undefined) user.givenName = updates.givenName; + if (updates.familyName !== undefined) user.familyName = updates.familyName; + if (updates.groups !== undefined) { + user.groups = updates.groups; + user.role = this.resolveRole(updates.groups, roleMappings); + } + + user.lastSyncedAt = new Date().toISOString(); + return user; + } + + patchUser( + userId: string, + operations: SCIMPatchOperation[], + roleMappings: RoleMapping[], + ): SCIMUser { + const user = this.users.get(userId); + if (!user) throw new Error(`SCIM user ${userId} not found`); + + for (const op of operations) { + this.applyPatchOperation(user, op, roleMappings); + } + + user.lastSyncedAt = new Date().toISOString(); + return user; + } + + deactivateUser(userId: string): SCIMUser { + const user = this.users.get(userId); + if (!user) throw new Error(`SCIM user ${userId} not found`); + + user.status = 'deactivated'; + user.deactivatedAt = new Date().toISOString(); + user.lastSyncedAt = user.deactivatedAt; + return user; + } + + suspendUser(userId: string): SCIMUser { + const user = this.users.get(userId); + if (!user) throw new Error(`SCIM user ${userId} not found`); + + user.status = 'suspended'; + user.lastSyncedAt = new Date().toISOString(); + return user; + } + + deleteUser(userId: string): void { + const user = this.users.get(userId); + if (!user) return; + + this.externalIdIndex.delete(`${user.organizationId}:${user.externalId}`); + this.users.delete(userId); + } + + jitProvision( + organizationId: string, + identityProviderId: string, + email: string, + displayName: string, + groups: string[], + roleMappings: RoleMapping[], + ): SCIMUser { + const externalId = email; + const existing = this.getUserByExternalId(organizationId, externalId); + if (existing && existing.status === 'active') { + existing.lastSyncedAt = new Date().toISOString(); + return existing; + } + + const nameParts = displayName.split(' '); + return this.createUser( + organizationId, + identityProviderId, + externalId, + email, + displayName, + nameParts[0] ?? displayName, + nameParts.slice(1).join(' ') || displayName, + groups, + roleMappings, + ); + } + + syncGroupMembership( + organizationId: string, + identityProviderId: string, + groupName: string, + memberExternalIds: string[], + roleMappings: RoleMapping[], + ): { added: string[]; removed: string[]; unchanged: string[] } { + const added: string[] = []; + const removed: string[] = []; + const unchanged: string[] = []; + + const orgUsers = Array.from(this.users.values()).filter( + (u) => u.organizationId === organizationId && u.identityProviderId === identityProviderId, + ); + + const currentMembers = orgUsers.filter((u) => u.groups.includes(groupName)); + const currentExternalIds = new Set(currentMembers.map((u) => u.externalId)); + const targetExternalIds = new Set(memberExternalIds); + + for (const user of currentMembers) { + if (!targetExternalIds.has(user.externalId)) { + user.groups = user.groups.filter((g) => g !== groupName); + user.role = this.resolveRole(user.groups, roleMappings); + user.lastSyncedAt = new Date().toISOString(); + removed.push(user.externalId); + } else { + unchanged.push(user.externalId); + } + } + + for (const extId of memberExternalIds) { + if (!currentExternalIds.has(extId)) { + const user = this.getUserByExternalId(organizationId, extId); + if (user) { + user.groups = [...new Set([...user.groups, groupName])]; + user.role = this.resolveRole(user.groups, roleMappings); + user.lastSyncedAt = new Date().toISOString(); + added.push(extId); + } + } + } + + return { added, removed, unchanged }; + } + + private reactivateUser( + userId: string, + email: string, + displayName: string, + givenName: string, + familyName: string, + groups: string[], + roleMappings: RoleMapping[], + ): SCIMUser { + const user = this.users.get(userId)!; + user.email = email; + user.displayName = displayName; + user.givenName = givenName; + user.familyName = familyName; + user.groups = groups; + user.role = this.resolveRole(groups, roleMappings); + user.status = 'active'; + user.deactivatedAt = undefined; + user.lastSyncedAt = new Date().toISOString(); + return user; + } + + private resolveRole(groups: string[], roleMappings: RoleMapping[]): SubTrackrRole { + for (const mapping of roleMappings) { + if (groups.includes(mapping.idpGroup)) { + return mapping.subtrackrRole; + } + } + return 'viewer'; + } + + private applyPatchOperation( + user: SCIMUser, + op: SCIMPatchOperation, + roleMappings: RoleMapping[], + ): void { + const path = op.path?.toLowerCase(); + + switch (op.op) { + case 'replace': + if (path === 'active' && op.value === false) { + user.status = 'deactivated'; + user.deactivatedAt = new Date().toISOString(); + } else if (path === 'active' && op.value === true) { + user.status = 'active'; + user.deactivatedAt = undefined; + } else if (path === 'displayname' && typeof op.value === 'string') { + user.displayName = op.value; + } else if (path === 'emails' && Array.isArray(op.value)) { + const primary = (op.value as Array<{ value: string; primary?: boolean }>).find( + (e) => e.primary, + ); + if (primary) user.email = primary.value; + } + break; + + case 'add': + if (path === 'groups' && Array.isArray(op.value)) { + const newGroups = (op.value as Array<{ value: string }>).map((g) => g.value); + user.groups = [...new Set([...user.groups, ...newGroups])]; + user.role = this.resolveRole(user.groups, roleMappings); + } + break; + + case 'remove': + if (path?.startsWith('groups') && typeof op.value === 'string') { + user.groups = user.groups.filter((g) => g !== op.value); + user.role = this.resolveRole(user.groups, roleMappings); + } + break; + } + } +} + +export const scimService = new SCIMService(); diff --git a/backend/sso/domain/SSOService.ts b/backend/sso/domain/SSOService.ts new file mode 100644 index 00000000..4c577fcb --- /dev/null +++ b/backend/sso/domain/SSOService.ts @@ -0,0 +1,362 @@ +import { createHash, randomBytes } from 'crypto'; +import type { + IdentityProvider, + IdPCertificate, + OIDCConfiguration, + RoleMapping, + SAMLConfiguration, + SAMLMetadata, + SSOCallbackPayload, + SSOLoginRequest, + SSOProtocol, + SSOSession, + SubTrackrRole, +} from './types'; + +function generateId(prefix: string): string { + return `${prefix}_${randomBytes(12).toString('hex')}`; +} + +function generateBypassCode(): string { + return randomBytes(4).toString('hex').toUpperCase(); +} + +export class SSOService { + private providers = new Map(); + private sessions = new Map(); + private pendingStates = new Map(); + + createIdentityProvider( + organizationId: string, + name: string, + protocol: SSOProtocol, + ): IdentityProvider { + const now = new Date().toISOString(); + const idp: IdentityProvider = { + id: generateId('idp'), + organizationId, + name, + protocol, + status: 'pending_setup', + roleMappings: [], + jitProvisioningEnabled: false, + ipAllowlist: [], + bypassCodes: [generateBypassCode(), generateBypassCode()], + createdAt: now, + updatedAt: now, + }; + + this.providers.set(idp.id, idp); + return idp; + } + + getIdentityProvider(id: string): IdentityProvider | undefined { + return this.providers.get(id); + } + + listIdentityProviders(organizationId: string): IdentityProvider[] { + return Array.from(this.providers.values()).filter( + (p) => p.organizationId === organizationId, + ); + } + + configureSAML(idpId: string, config: SAMLConfiguration): IdentityProvider { + const idp = this.requireProvider(idpId); + if (idp.protocol !== 'saml2') { + throw new Error(`Identity provider ${idpId} is not configured for SAML 2.0`); + } + + this.validateCertificates(config.certificates); + + idp.samlConfig = config; + idp.status = 'active'; + idp.updatedAt = new Date().toISOString(); + return idp; + } + + configureOIDC(idpId: string, config: OIDCConfiguration): IdentityProvider { + const idp = this.requireProvider(idpId); + if (idp.protocol !== 'oidc') { + throw new Error(`Identity provider ${idpId} is not configured for OIDC`); + } + + idp.oidcConfig = config; + idp.status = 'active'; + idp.updatedAt = new Date().toISOString(); + return idp; + } + + uploadSAMLMetadata(idpId: string, metadataXml: string): IdentityProvider { + const metadata = this.parseSAMLMetadata(metadataXml); + return this.configureSAML(idpId, { + entityId: metadata.entityId, + ssoUrl: metadata.ssoUrl, + sloUrl: metadata.sloUrl, + certificates: metadata.certificates, + nameIdFormat: metadata.nameIdFormat, + signAuthnRequests: true, + wantAssertionsSigned: true, + }); + } + + configureSAMLFromUrl(idpId: string, metadataUrl: string): IdentityProvider { + const simulatedXml = `` + + `` + + ``; + return this.uploadSAMLMetadata(idpId, simulatedXml); + } + + setRoleMappings(idpId: string, mappings: RoleMapping[]): IdentityProvider { + const idp = this.requireProvider(idpId); + idp.roleMappings = mappings; + idp.updatedAt = new Date().toISOString(); + return idp; + } + + setJITProvisioning(idpId: string, enabled: boolean): IdentityProvider { + const idp = this.requireProvider(idpId); + idp.jitProvisioningEnabled = enabled; + idp.updatedAt = new Date().toISOString(); + return idp; + } + + setIPAllowlist(idpId: string, ips: string[]): IdentityProvider { + const idp = this.requireProvider(idpId); + idp.ipAllowlist = ips; + idp.updatedAt = new Date().toISOString(); + return idp; + } + + regenerateBypassCodes(idpId: string): string[] { + const idp = this.requireProvider(idpId); + idp.bypassCodes = [generateBypassCode(), generateBypassCode()]; + idp.updatedAt = new Date().toISOString(); + return idp.bypassCodes; + } + + validateBypassCode(idpId: string, code: string): boolean { + const idp = this.requireProvider(idpId); + const index = idp.bypassCodes.indexOf(code); + if (index === -1) return false; + idp.bypassCodes.splice(index, 1); + return true; + } + + deactivateProvider(idpId: string): IdentityProvider { + const idp = this.requireProvider(idpId); + idp.status = 'inactive'; + idp.updatedAt = new Date().toISOString(); + return idp; + } + + initiateSSOLogin(request: SSOLoginRequest): { redirectUrl: string; state: string } { + const idp = this.requireProvider(request.identityProviderId); + if (idp.status !== 'active') { + throw new Error(`Identity provider ${idp.id} is not active`); + } + + const state = randomBytes(16).toString('hex'); + this.pendingStates.set(state, { + idpId: idp.id, + relayState: request.relayState, + createdAt: new Date().toISOString(), + }); + + let redirectUrl: string; + if (idp.protocol === 'saml2' && idp.samlConfig) { + const params = new URLSearchParams({ + SAMLRequest: Buffer.from(``).toString('base64'), + RelayState: request.relayState ?? '', + }); + redirectUrl = `${idp.samlConfig.ssoUrl}?${params.toString()}`; + } else if (idp.protocol === 'oidc' && idp.oidcConfig) { + const params = new URLSearchParams({ + client_id: idp.oidcConfig.clientId, + response_type: 'code', + scope: idp.oidcConfig.scopes.join(' '), + redirect_uri: 'https://app.subtrackr.io/sso/callback', + state, + nonce: randomBytes(16).toString('hex'), + }); + redirectUrl = `${idp.oidcConfig.authorizationEndpoint}?${params.toString()}`; + } else { + throw new Error(`Identity provider ${idp.id} is not fully configured`); + } + + return { redirectUrl, state }; + } + + handleSSOCallback(payload: SSOCallbackPayload): SSOSession { + const stateKey = payload.state ?? this.extractStateFromSAML(payload.samlResponse); + if (!stateKey) { + throw new Error('Missing state in SSO callback'); + } + + const pending = this.pendingStates.get(stateKey); + if (!pending) { + throw new Error('Invalid or expired SSO state'); + } + + this.pendingStates.delete(stateKey); + const idp = this.requireProvider(pending.idpId); + + const now = new Date(); + const expiresAt = new Date(now.getTime() + 8 * 60 * 60 * 1000); + + const attributes = this.extractAttributes(idp, payload); + const email = (attributes.email as string) ?? `user_${stateKey.slice(0, 8)}@${idp.name}.sso`; + const nameId = (attributes.nameId as string) ?? email; + + const session: SSOSession = { + id: generateId('sso_sess'), + userId: this.resolveUserId(email, idp), + identityProviderId: idp.id, + protocol: idp.protocol, + nameId, + sessionIndex: stateKey, + attributes, + authenticatedAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + }; + + this.sessions.set(session.id, session); + return session; + } + + resolveRoleFromGroups(idpId: string, groups: string[]): SubTrackrRole { + const idp = this.requireProvider(idpId); + for (const mapping of idp.roleMappings) { + if (groups.includes(mapping.idpGroup)) { + return mapping.subtrackrRole; + } + } + return 'viewer'; + } + + getSession(sessionId: string): SSOSession | undefined { + const session = this.sessions.get(sessionId); + if (!session) return undefined; + + if (new Date(session.expiresAt) < new Date()) { + this.sessions.delete(sessionId); + return undefined; + } + + return session; + } + + revokeSession(sessionId: string): void { + this.sessions.delete(sessionId); + } + + isCertificateExpiringSoon(cert: IdPCertificate, daysThreshold = 30): boolean { + const expiryDate = new Date(cert.notAfter); + const warningDate = new Date(); + warningDate.setDate(warningDate.getDate() + daysThreshold); + return expiryDate <= warningDate; + } + + getExpiringCertificates(idpId: string, daysThreshold = 30): IdPCertificate[] { + const idp = this.requireProvider(idpId); + const certs = idp.samlConfig?.certificates ?? []; + return certs.filter((c) => this.isCertificateExpiringSoon(c, daysThreshold)); + } + + rotateCertificate(idpId: string, newCert: IdPCertificate): IdentityProvider { + const idp = this.requireProvider(idpId); + if (!idp.samlConfig) { + throw new Error(`Identity provider ${idpId} has no SAML configuration`); + } + + idp.samlConfig.certificates.forEach((c) => (c.isPrimary = false)); + newCert.isPrimary = true; + idp.samlConfig.certificates.push(newCert); + + // Remove expired certificates (keep last 2 for grace period) + const validCerts = idp.samlConfig.certificates + .filter((c) => new Date(c.notAfter) > new Date()) + .slice(-3); + idp.samlConfig.certificates = validCerts; + + idp.updatedAt = new Date().toISOString(); + return idp; + } + + private requireProvider(idpId: string): IdentityProvider { + const idp = this.providers.get(idpId); + if (!idp) { + throw new Error(`Identity provider ${idpId} not found`); + } + return idp; + } + + private validateCertificates(certs: IdPCertificate[]): void { + if (certs.length === 0) { + throw new Error('At least one certificate is required'); + } + const hasPrimary = certs.some((c) => c.isPrimary); + if (!hasPrimary) { + certs[0].isPrimary = true; + } + } + + private parseSAMLMetadata(xml: string): SAMLMetadata { + const entityIdMatch = xml.match(/entityID="([^"]+)"/); + const ssoUrlMatch = xml.match(/Location="([^"]+)"/); + + return { + raw: xml, + entityId: entityIdMatch?.[1] ?? 'unknown', + ssoUrl: ssoUrlMatch?.[1] ?? 'unknown', + certificates: [{ + fingerprint: createHash('sha256').update(xml).digest('hex'), + notBefore: new Date().toISOString(), + notAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + isPrimary: true, + }], + nameIdFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + }; + } + + private extractStateFromSAML(samlResponse?: string): string | undefined { + if (!samlResponse) return undefined; + try { + const decoded = Buffer.from(samlResponse, 'base64').toString('utf8'); + const idMatch = decoded.match(/InResponseTo="([^"]+)"/); + return idMatch?.[1]; + } catch { + return undefined; + } + } + + private extractAttributes( + idp: IdentityProvider, + payload: SSOCallbackPayload, + ): Record { + const attrs: Record = {}; + + if (idp.protocol === 'saml2' && payload.samlResponse) { + try { + const decoded = Buffer.from(payload.samlResponse, 'base64').toString('utf8'); + const emailMatch = decoded.match(/email[^>]*>([^<]+)]*>([^<]+); + authenticatedAt: string; + expiresAt: string; +} + +export interface SSOLoginRequest { + identityProviderId: string; + relayState?: string; +} + +export interface SSOCallbackPayload { + samlResponse?: string; + code?: string; + state?: string; + relayState?: string; +} + +export interface SAMLMetadata { + raw: string; + entityId: string; + ssoUrl: string; + sloUrl?: string; + certificates: IdPCertificate[]; + nameIdFormat: string; +} + +export interface SCIMListResponse { + schemas: string[]; + totalResults: number; + startIndex: number; + itemsPerPage: number; + Resources: T[]; +} + +export interface SCIMPatchOperation { + op: 'add' | 'replace' | 'remove'; + path?: string; + value?: unknown; +} + +export interface SCIMPatchRequest { + schemas: string[]; + Operations: SCIMPatchOperation[]; +} diff --git a/backend/sso/index.ts b/backend/sso/index.ts new file mode 100644 index 00000000..3374542a --- /dev/null +++ b/backend/sso/index.ts @@ -0,0 +1,17 @@ +export { SSOService, ssoService } from './domain/SSOService'; +export { SCIMService, scimService } from './domain/SCIMService'; +export { createSSOController } from './controller/ssoController'; +export type { + IdentityProvider, + SCIMUser, + SSOSession, + SSOProtocol, + SAMLConfiguration, + OIDCConfiguration, + RoleMapping, + SubTrackrRole, + IdPCertificate, + SCIMListResponse, + SCIMPatchRequest, + SCIMPatchOperation, +} from './domain/types'; diff --git a/src/screens/CalendarIntegrationScreen.tsx b/src/screens/CalendarIntegrationScreen.tsx index a039fba6..9341bd0d 100644 --- a/src/screens/CalendarIntegrationScreen.tsx +++ b/src/screens/CalendarIntegrationScreen.tsx @@ -15,11 +15,16 @@ import { Card } from '../components/common/Card'; import { useCalendarStore } from '../store/calendarStore'; import { useSubscriptionStore } from '../store/subscriptionStore'; import { + ALL_CALENDAR_EVENT_TYPES, + CALENDAR_EVENT_TYPE_LABELS, CALENDAR_PROVIDERS, REMINDER_OFFSET_OPTIONS, REMINDER_PRESETS, SUBSCRIPTION_TIMEZONES, + SYNC_DIRECTION_LABELS, + type CalendarEventType, type CalendarProvider, + type SyncDirection, } from '../types/calendar'; import { borderRadius, colors, spacing, typography } from '../utils/constants'; @@ -68,6 +73,8 @@ const CalendarIntegrationScreen: React.FC = () => { checkConflicts, exportCalendar, setTimezone, + setSyncDirection, + toggleEventType, } = useCalendarStore(); const subscriptions = useSubscriptionStore((state) => state.subscriptions); @@ -324,6 +331,88 @@ const CalendarIntegrationScreen: React.FC = () => { })} + {integrations.length > 0 && ( + + Two-way sync settings + + Control how events flow between SubTrackr and your calendars. With two-way sync, + changes on your calendar (reschedule, snooze, delete) are reflected in SubTrackr. + + + {integrations.map((integration) => ( + + {providerLabels[integration.provider]} + + Sync direction + + {(['to_calendar', 'from_calendar', 'bidirectional'] as SyncDirection[]).map( + (dir) => { + const currentDir = + integration.syncSettings?.syncDirection ?? 'bidirectional'; + const isSelected = currentDir === dir; + return ( + setSyncDirection(integration.id, dir)}> + + {SYNC_DIRECTION_LABELS[dir]} + + + ); + }, + )} + + + Event types to sync + + {ALL_CALENDAR_EVENT_TYPES.map((eventType: CalendarEventType) => { + const enabled = + integration.syncSettings?.enabledEventTypes?.includes(eventType) ?? + ['payment_due', 'renewal', 'trial_ending'].includes(eventType); + return ( + toggleEventType(integration.id, eventType)}> + + {CALENDAR_EVENT_TYPE_LABELS[eventType]} + + + ); + })} + + + + Sync method: {integration.syncSettings?.syncMethod ?? 'webhook'} (real-time via + webhook, fallback to hourly poll) + + + {integration.syncSettings?.lastSyncResult && ( + + + Last sync: {new Date(integration.syncSettings.lastSyncResult.syncedAt).toLocaleString()} + {' — '} + {integration.syncSettings.lastSyncResult.pushed} pushed,{' '} + {integration.syncSettings.lastSyncResult.pulled} pulled + {integration.syncSettings.lastSyncResult.conflicts > 0 && + `, ${integration.syncSettings.lastSyncResult.conflicts} conflicts`} + + + )} + + ))} + + )} + Reminder customization @@ -655,6 +744,49 @@ const styles = StyleSheet.create({ conflictDate: { ...typography.body, color: colors.text, fontWeight: '600' }, conflictDetail: { ...typography.caption, color: colors.textSecondary }, conflictSub: { ...typography.small, color: colors.textSecondary, paddingLeft: spacing.sm }, + syncSettingLabel: { + ...typography.caption, + color: colors.textSecondary, + fontWeight: '600', + marginTop: spacing.sm, + marginBottom: spacing.xs, + }, + syncDirectionRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + }, + syncDirChip: { + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + borderRadius: borderRadius.full, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, + eventTypeGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + }, + eventTypeChip: { + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + borderRadius: borderRadius.full, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, + syncResultRow: { + marginTop: spacing.sm, + padding: spacing.sm, + borderRadius: borderRadius.md, + backgroundColor: `${colors.primary}10`, + }, + syncResultText: { + ...typography.small, + color: colors.textSecondary, + }, }); export default CalendarIntegrationScreen; diff --git a/src/screens/SSOSettingsScreen.tsx b/src/screens/SSOSettingsScreen.tsx new file mode 100644 index 00000000..63dea51c --- /dev/null +++ b/src/screens/SSOSettingsScreen.tsx @@ -0,0 +1,562 @@ +import React, { useState, useCallback } from 'react'; +import { + Alert, + SafeAreaView, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import { Card } from '../components/common/Card'; +import { useSSOStore } from '../store/ssoStore'; +import { + AVAILABLE_ROLES, + SSO_PROVIDER_PRESETS, + type IdentityProvider, + type SSOProtocol, + type SubTrackrRole, +} from '../types/sso'; +import { borderRadius, colors, spacing, typography } from '../utils/constants'; + +const statusColors: Record = { + active: '#22c55e', + inactive: '#ef4444', + pending_setup: '#f59e0b', +}; + +const protocolLabels: Record = { + saml2: 'SAML 2.0', + oidc: 'OpenID Connect', +}; + +const roleLabels: Record = { + admin: 'Admin', + viewer: 'Viewer', + billing: 'Billing', +}; + +const SSOSettingsScreen: React.FC = () => { + const { + providers, + scimUsers, + error, + addProvider, + removeProvider, + activateProvider, + deactivateProvider, + setRoleMappings, + toggleJIT, + uploadMetadata, + deactivateSCIMUser, + clearError, + } = useSSOStore(); + + const [showAddProvider, setShowAddProvider] = useState(false); + const [newProviderName, setNewProviderName] = useState(''); + const [newProviderProtocol, setNewProviderProtocol] = useState('saml2'); + const [expandedProviderId, setExpandedProviderId] = useState(null); + const [newGroupName, setNewGroupName] = useState(''); + const [newGroupRole, setNewGroupRole] = useState('viewer'); + + const handleAddProvider = useCallback(() => { + if (!newProviderName.trim()) { + Alert.alert('Validation', 'Provider name is required.'); + return; + } + addProvider('org_default', newProviderName.trim(), newProviderProtocol); + setNewProviderName(''); + setShowAddProvider(false); + Alert.alert('Provider Added', `${newProviderName} has been added. Configure it to activate.`); + }, [newProviderName, newProviderProtocol, addProvider]); + + const handleRemoveProvider = useCallback( + (provider: IdentityProvider) => { + Alert.alert( + 'Remove Provider', + `Remove ${provider.name}? All SSO sessions through this provider will be terminated.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Remove', + style: 'destructive', + onPress: () => removeProvider(provider.id), + }, + ], + ); + }, + [removeProvider], + ); + + const handleUploadMetadata = useCallback( + (provider: IdentityProvider) => { + const sampleXml = + `` + + `` + + ``; + + uploadMetadata(provider.id, sampleXml); + Alert.alert('Metadata Uploaded', `${provider.name} SAML metadata has been configured.`); + }, + [uploadMetadata], + ); + + const handleAddRoleMapping = useCallback( + (provider: IdentityProvider) => { + if (!newGroupName.trim()) { + Alert.alert('Validation', 'Group name is required.'); + return; + } + const updated = [ + ...provider.roleMappings, + { idpGroup: newGroupName.trim(), subtrackrRole: newGroupRole }, + ]; + setRoleMappings(provider.id, updated); + setNewGroupName(''); + }, + [newGroupName, newGroupRole, setRoleMappings], + ); + + const handleRemoveRoleMapping = useCallback( + (provider: IdentityProvider, index: number) => { + const updated = provider.roleMappings.filter((_, i) => i !== index); + setRoleMappings(provider.id, updated); + }, + [setRoleMappings], + ); + + const renderProviderCard = (provider: IdentityProvider) => { + const isExpanded = expandedProviderId === provider.id; + const providerUsers = scimUsers.filter( + (u) => u.status === 'active', + ); + + return ( + + setExpandedProviderId(isExpanded ? null : provider.id)} + style={styles.cardHeader} + > + + {provider.name} + + + + {provider.status.replace('_', ' ')} + + + + {protocolLabels[provider.protocol]} + + + + {isExpanded ? '▲' : '▼'} + + + {isExpanded && ( + + {/* Configuration Section */} + {provider.protocol === 'saml2' && ( + + SAML Configuration + {provider.samlConfig ? ( + + Entity ID + {provider.samlConfig.entityId} + SSO URL + {provider.samlConfig.ssoUrl} + Certificates + + {provider.samlConfig.certificates.length} certificate(s) configured + + + ) : ( + handleUploadMetadata(provider)} + > + Upload IdP Metadata (XML) + + )} + + )} + + {provider.protocol === 'oidc' && ( + + OIDC Configuration + {provider.oidcConfig ? ( + + Issuer + {provider.oidcConfig.issuer} + Client ID + {provider.oidcConfig.clientId} + + ) : ( + Not configured — set up via API + )} + + )} + + {/* JIT Provisioning */} + + + Just-In-Time Provisioning + toggleJIT(provider.id)} + trackColor={{ false: '#ccc', true: colors.primary }} + /> + + + Automatically create user accounts on first SSO login + + + + {/* Role Mappings */} + + Role Mappings + {provider.roleMappings.map((mapping, index) => ( + + + {mapping.idpGroup} → {roleLabels[mapping.subtrackrRole]} + + handleRemoveRoleMapping(provider, index)}> + + + + ))} + + + + + {AVAILABLE_ROLES.map((role) => ( + setNewGroupRole(role)} + > + + {roleLabels[role]} + + + ))} + + handleAddRoleMapping(provider)} + > + Add + + + + + {/* SCIM Users */} + + + Provisioned Users ({providerUsers.length}) + + {providerUsers.slice(0, 5).map((user) => ( + + + {user.displayName} + {user.email} + + + + {roleLabels[user.role]} + + deactivateSCIMUser(user.id)}> + Deactivate + + + + ))} + + + {/* Provider Actions */} + + {provider.status === 'active' ? ( + deactivateProvider(provider.id)} + > + Deactivate Provider + + ) : ( + activateProvider(provider.id)} + > + Activate Provider + + )} + handleRemoveProvider(provider)} + > + Remove Provider + + + + )} + + ); + }; + + return ( + + + Enterprise SSO + + Configure SAML 2.0 and OpenID Connect identity providers for single sign-on + + + {error && ( + + {error} + Dismiss + + )} + + {/* Quick Setup Presets */} + {providers.length === 0 && ( + + Quick Setup + + Choose a supported identity provider to get started + + + {SSO_PROVIDER_PRESETS.map((preset) => ( + { + addProvider('org_default', preset.name, preset.protocol); + Alert.alert('Provider Added', `${preset.name} has been added.`); + }} + > + {preset.name} + {protocolLabels[preset.protocol]} + + ))} + + + )} + + {/* Provider List */} + {providers.map(renderProviderCard)} + + {/* Add Provider */} + {showAddProvider ? ( + + Add Identity Provider + + + {(['saml2', 'oidc'] as SSOProtocol[]).map((p) => ( + setNewProviderProtocol(p)} + > + + {protocolLabels[p]} + + + ))} + + + + Create Provider + + setShowAddProvider(false)} + > + Cancel + + + + ) : ( + setShowAddProvider(true)} + > + + Add Identity Provider + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + scrollContent: { padding: spacing.md }, + title: { ...typography.h1, marginBottom: spacing.xs }, + subtitle: { ...typography.body, color: '#666', marginBottom: spacing.lg }, + card: { marginBottom: spacing.md }, + cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + cardBody: { marginTop: spacing.md }, + providerInfo: { flex: 1 }, + providerName: { ...typography.h3, marginBottom: spacing.xs }, + badges: { flexDirection: 'row', gap: spacing.xs }, + badge: { + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: borderRadius.sm, + backgroundColor: '#f0f0f0', + }, + protocolBadge: { backgroundColor: '#e0e7ff' }, + roleBadge: { backgroundColor: '#fef3c7' }, + badgeText: { fontSize: 11, fontWeight: '600' }, + expandIcon: { fontSize: 12, color: '#999' }, + section: { marginBottom: spacing.md, paddingTop: spacing.sm, borderTopWidth: 1, borderTopColor: '#f0f0f0' }, + sectionTitle: { ...typography.h4, marginBottom: spacing.xs }, + helperText: { fontSize: 13, color: '#888', marginBottom: spacing.sm }, + configLabel: { fontSize: 12, color: '#666', marginTop: spacing.xs }, + configValue: { fontSize: 14, marginBottom: spacing.xs }, + switchRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + mappingRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: spacing.xs, + borderBottomWidth: 1, + borderBottomColor: '#f5f5f5', + }, + mappingText: { fontSize: 14 }, + removeText: { color: '#ef4444', fontSize: 13, fontWeight: '600' }, + addMappingRow: { marginTop: spacing.sm }, + groupInput: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: borderRadius.sm, + padding: spacing.sm, + marginBottom: spacing.xs, + fontSize: 14, + }, + roleSelector: { flexDirection: 'row', gap: spacing.xs, marginBottom: spacing.xs }, + roleChip: { + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: borderRadius.sm, + backgroundColor: '#f0f0f0', + }, + roleChipActive: { backgroundColor: colors.primary }, + roleChipText: { fontSize: 12, color: '#666' }, + roleChipTextActive: { color: '#fff' }, + addButton: { + backgroundColor: colors.primary, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.sm, + alignSelf: 'flex-start', + }, + addButtonText: { color: '#fff', fontSize: 13, fontWeight: '600' }, + userRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: '#f5f5f5', + }, + userName: { fontSize: 14, fontWeight: '600' }, + userEmail: { fontSize: 12, color: '#888' }, + userActions: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm }, + providerActions: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm }, + actionButton: { + backgroundColor: colors.primary, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: borderRadius.md, + alignItems: 'center', + flex: 1, + }, + actionButtonText: { color: '#fff', fontWeight: '600', fontSize: 14 }, + dangerButton: { backgroundColor: '#fee2e2' }, + dangerButtonText: { color: '#ef4444', fontWeight: '600', fontSize: 14 }, + cancelButton: { backgroundColor: '#f5f5f5' }, + cancelButtonText: { color: '#666', fontWeight: '600', fontSize: 14 }, + input: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: borderRadius.md, + padding: spacing.sm, + marginBottom: spacing.sm, + fontSize: 14, + }, + protocolSelector: { flexDirection: 'row', gap: spacing.sm, marginBottom: spacing.md }, + protocolOption: { + flex: 1, + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + backgroundColor: '#f0f0f0', + alignItems: 'center', + }, + protocolOptionActive: { backgroundColor: colors.primary }, + protocolOptionText: { fontWeight: '600', color: '#666' }, + protocolOptionTextActive: { color: '#fff' }, + formActions: { flexDirection: 'row', gap: spacing.sm }, + presetGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm }, + presetButton: { + width: '47%', + padding: spacing.md, + borderRadius: borderRadius.md, + backgroundColor: '#f8f9fa', + borderWidth: 1, + borderColor: '#e9ecef', + alignItems: 'center', + }, + presetName: { fontWeight: '700', fontSize: 14, marginBottom: 2 }, + presetProtocol: { fontSize: 11, color: '#888' }, + addProviderButton: { + padding: spacing.md, + borderRadius: borderRadius.md, + borderWidth: 1, + borderColor: colors.primary, + borderStyle: 'dashed', + alignItems: 'center', + }, + addProviderButtonText: { color: colors.primary, fontWeight: '600' }, + errorBanner: { + backgroundColor: '#fee2e2', + padding: spacing.sm, + borderRadius: borderRadius.md, + marginBottom: spacing.md, + flexDirection: 'row', + justifyContent: 'space-between', + }, + errorText: { color: '#ef4444', flex: 1, fontSize: 13 }, + errorDismiss: { color: '#ef4444', fontWeight: '700', fontSize: 13 }, +}); + +export default SSOSettingsScreen; diff --git a/src/store/calendarStore.ts b/src/store/calendarStore.ts index b2511ea2..9f4bdbb9 100644 --- a/src/store/calendarStore.ts +++ b/src/store/calendarStore.ts @@ -18,13 +18,16 @@ import { } from '../services/calendarService'; import type { CalendarExportPayload, + CalendarEventType, CalendarIntegration, CalendarProvider, CalendarSyncedEvent, + CalendarSyncSettings, OneTimeScheduledPayment, PendingCalendarAuthorization, ProratedAdjustment, ScheduleConflict, + SyncDirection, } from '../types/calendar'; import { REMINDER_PRESETS } from '../types/calendar'; import type { Subscription } from '../types/subscription'; @@ -74,6 +77,11 @@ interface CalendarState { reason: string ) => ProratedAdjustment; setTimezone: (timezone: string) => void; + setSyncDirection: (connectionId: string, direction: SyncDirection) => void; + toggleEventType: (connectionId: string, eventType: CalendarEventType) => void; + setEnabledEventTypes: (connectionId: string, eventTypes: CalendarEventType[]) => void; + triggerBidirectionalSync: (subscription: Subscription) => Promise; + getSyncSettings: (connectionId: string) => CalendarSyncSettings | undefined; } function removeProviderPendingState( @@ -277,6 +285,121 @@ export const useCalendarStore = create()( set({ timezone }); }, + setSyncDirection: (connectionId, direction) => { + set((state) => ({ + integrations: state.integrations.map((integration) => { + if (integration.id !== connectionId) return integration; + const syncSettings: CalendarSyncSettings = { + ...(integration.syncSettings ?? { + syncDirection: 'bidirectional', + enabledEventTypes: ['payment_due', 'renewal', 'trial_ending'], + syncMethod: 'webhook' as const, + }), + syncDirection: direction, + }; + return { ...integration, syncSettings }; + }), + })); + }, + + toggleEventType: (connectionId, eventType) => { + set((state) => ({ + integrations: state.integrations.map((integration) => { + if (integration.id !== connectionId) return integration; + const current = integration.syncSettings?.enabledEventTypes ?? [ + 'payment_due', 'renewal', 'trial_ending', + ]; + const updated = current.includes(eventType) + ? current.filter((t) => t !== eventType) + : [...current, eventType]; + const syncSettings: CalendarSyncSettings = { + ...(integration.syncSettings ?? { + syncDirection: 'bidirectional' as SyncDirection, + enabledEventTypes: current, + syncMethod: 'webhook' as const, + }), + enabledEventTypes: updated, + }; + return { ...integration, syncSettings }; + }), + })); + }, + + setEnabledEventTypes: (connectionId, eventTypes) => { + set((state) => ({ + integrations: state.integrations.map((integration) => { + if (integration.id !== connectionId) return integration; + const syncSettings: CalendarSyncSettings = { + ...(integration.syncSettings ?? { + syncDirection: 'bidirectional' as SyncDirection, + enabledEventTypes: [], + syncMethod: 'webhook' as const, + }), + enabledEventTypes: eventTypes, + }; + return { ...integration, syncSettings }; + }), + })); + }, + + triggerBidirectionalSync: async (subscription) => { + const { integrations, syncedEvents } = get(); + const activeIntegrations = integrations.filter(isConnected); + if (activeIntegrations.length === 0) return; + + const untouchedEvents = syncedEvents.filter( + (event) => event.subscriptionId !== subscription.id, + ); + const nextSyncedEvents: CalendarSyncedEvent[] = [...untouchedEvents]; + const syncTime = new Date().toISOString(); + + for (const integration of activeIntegrations) { + const direction = integration.syncSettings?.syncDirection ?? 'bidirectional'; + if (direction === 'from_calendar') continue; + + const template = buildSubscriptionCalendarEvent( + subscription, + integration.reminderOffsets, + ); + const upserted = await syncToCalendar( + subscription.id, + [template], + integration, + syncedEvents, + ); + nextSyncedEvents.push(...upserted); + } + + set((state) => ({ + syncedEvents: nextSyncedEvents, + integrations: state.integrations.map((integration) => { + const wasActive = activeIntegrations.some((entry) => entry.id === integration.id); + if (!wasActive) return integration; + return { + ...integration, + lastSyncedAt: syncTime, + syncSettings: integration.syncSettings + ? { + ...integration.syncSettings, + lastSyncResult: { + syncedAt: syncTime, + pushed: nextSyncedEvents.length - untouchedEvents.length, + pulled: 0, + conflicts: 0, + errors: 0, + }, + } + : undefined, + }; + }), + })); + }, + + getSyncSettings: (connectionId) => { + const integration = get().integrations.find((i) => i.id === connectionId); + return integration?.syncSettings; + }, + syncSubscriptionToCalendars: async (subscription) => { const { integrations, syncedEvents } = get(); const activeIntegrations = integrations.filter(isConnected); diff --git a/src/store/ssoStore.ts b/src/store/ssoStore.ts new file mode 100644 index 00000000..6a7a8f32 --- /dev/null +++ b/src/store/ssoStore.ts @@ -0,0 +1,199 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; +import { asyncStorageAdapter } from '../utils/storage'; +import type { + IdentityProvider, + RoleMapping, + SCIMUser, + SSOProtocol, + SubTrackrRole, +} from '../types/sso'; + +const STORAGE_KEY = 'subtrackr-sso-settings'; + +function generateId(prefix: string): string { + return `${prefix}_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`; +} + +interface SSOState { + providers: IdentityProvider[]; + scimUsers: SCIMUser[]; + isLoading: boolean; + error: string | null; + + addProvider: (organizationId: string, name: string, protocol: SSOProtocol) => IdentityProvider; + removeProvider: (id: string) => void; + activateProvider: (id: string) => void; + deactivateProvider: (id: string) => void; + setRoleMappings: (id: string, mappings: RoleMapping[]) => void; + toggleJIT: (id: string) => void; + uploadMetadata: (id: string, metadataXml: string) => void; + setIPAllowlist: (id: string, ips: string[]) => void; + + addSCIMUser: (user: Omit) => SCIMUser; + deactivateSCIMUser: (userId: string) => void; + updateSCIMUserRole: (userId: string, role: SubTrackrRole) => void; + removeSCIMUser: (userId: string) => void; + + clearError: () => void; +} + +export const useSSOStore = create()( + persist( + (set, get) => ({ + providers: [], + scimUsers: [], + isLoading: false, + error: null, + + addProvider: (organizationId, name, protocol) => { + const now = new Date().toISOString(); + const provider: IdentityProvider = { + id: generateId('idp'), + organizationId, + name, + protocol, + status: 'pending_setup', + roleMappings: [], + jitProvisioningEnabled: false, + ipAllowlist: [], + bypassCodeCount: 2, + createdAt: now, + updatedAt: now, + }; + + set((state) => ({ providers: [...state.providers, provider] })); + return provider; + }, + + removeProvider: (id) => { + set((state) => ({ + providers: state.providers.filter((p) => p.id !== id), + scimUsers: state.scimUsers.filter((u) => u.id !== id), + })); + }, + + activateProvider: (id) => { + set((state) => ({ + providers: state.providers.map((p) => + p.id === id ? { ...p, status: 'active' as const, updatedAt: new Date().toISOString() } : p, + ), + })); + }, + + deactivateProvider: (id) => { + set((state) => ({ + providers: state.providers.map((p) => + p.id === id ? { ...p, status: 'inactive' as const, updatedAt: new Date().toISOString() } : p, + ), + })); + }, + + setRoleMappings: (id, mappings) => { + set((state) => ({ + providers: state.providers.map((p) => + p.id === id ? { ...p, roleMappings: mappings, updatedAt: new Date().toISOString() } : p, + ), + })); + }, + + toggleJIT: (id) => { + set((state) => ({ + providers: state.providers.map((p) => + p.id === id + ? { ...p, jitProvisioningEnabled: !p.jitProvisioningEnabled, updatedAt: new Date().toISOString() } + : p, + ), + })); + }, + + uploadMetadata: (id, metadataXml) => { + const entityIdMatch = metadataXml.match(/entityID="([^"]+)"/); + const ssoUrlMatch = metadataXml.match(/Location="([^"]+)"/); + + if (!entityIdMatch || !ssoUrlMatch) { + set({ error: 'Invalid SAML metadata XML' }); + return; + } + + set((state) => ({ + providers: state.providers.map((p) => { + if (p.id !== id) return p; + return { + ...p, + status: 'active' as const, + samlConfig: { + entityId: entityIdMatch[1], + ssoUrl: ssoUrlMatch[1], + certificates: [{ + fingerprint: `cert_${Date.now().toString(36)}`, + notBefore: new Date().toISOString(), + notAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + isPrimary: true, + }], + nameIdFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + signAuthnRequests: true, + wantAssertionsSigned: true, + }, + updatedAt: new Date().toISOString(), + }; + }), + error: null, + })); + }, + + setIPAllowlist: (id, ips) => { + set((state) => ({ + providers: state.providers.map((p) => + p.id === id ? { ...p, ipAllowlist: ips, updatedAt: new Date().toISOString() } : p, + ), + })); + }, + + addSCIMUser: (userData) => { + const now = new Date().toISOString(); + const user: SCIMUser = { + ...userData, + id: generateId('scim_user'), + provisionedAt: now, + lastSyncedAt: now, + }; + + set((state) => ({ scimUsers: [...state.scimUsers, user] })); + return user; + }, + + deactivateSCIMUser: (userId) => { + set((state) => ({ + scimUsers: state.scimUsers.map((u) => + u.id === userId ? { ...u, status: 'deactivated' as const, lastSyncedAt: new Date().toISOString() } : u, + ), + })); + }, + + updateSCIMUserRole: (userId, role) => { + set((state) => ({ + scimUsers: state.scimUsers.map((u) => + u.id === userId ? { ...u, role, lastSyncedAt: new Date().toISOString() } : u, + ), + })); + }, + + removeSCIMUser: (userId) => { + set((state) => ({ + scimUsers: state.scimUsers.filter((u) => u.id !== userId), + })); + }, + + clearError: () => set({ error: null }), + }), + { + name: STORAGE_KEY, + storage: createJSONStorage(() => asyncStorageAdapter), + partialize: (state) => ({ + providers: state.providers, + scimUsers: state.scimUsers, + }), + }, + ), +); diff --git a/src/types/calendar.ts b/src/types/calendar.ts index 79a58935..b3fed542 100644 --- a/src/types/calendar.ts +++ b/src/types/calendar.ts @@ -78,6 +78,7 @@ export interface CalendarIntegration { connectedAt: string; lastSyncedAt?: string; reminderOffsets: number[]; + syncSettings?: CalendarSyncSettings; } export type CalendarEventKind = 'billing_reminder' | 'one_time_payment'; @@ -160,6 +161,50 @@ export interface ReminderOffsetOption { offset: number; } +export type SyncDirection = 'to_calendar' | 'from_calendar' | 'bidirectional'; +export type CalendarEventType = + | 'payment_due' + | 'payment_received' + | 'trial_ending' + | 'renewal' + | 'contract_end'; +export type SyncMethod = 'webhook' | 'poll'; + +export interface CalendarSyncSettings { + syncDirection: SyncDirection; + enabledEventTypes: CalendarEventType[]; + syncMethod: SyncMethod; + lastSyncResult?: { + syncedAt: string; + pushed: number; + pulled: number; + conflicts: number; + errors: number; + }; +} + +export const ALL_CALENDAR_EVENT_TYPES: CalendarEventType[] = [ + 'payment_due', + 'payment_received', + 'trial_ending', + 'renewal', + 'contract_end', +]; + +export const CALENDAR_EVENT_TYPE_LABELS: Record = { + payment_due: 'Payment Due', + payment_received: 'Payment Received', + trial_ending: 'Trial Ending', + renewal: 'Renewal', + contract_end: 'Contract End', +}; + +export const SYNC_DIRECTION_LABELS: Record = { + to_calendar: 'SubTrackr → Calendar', + from_calendar: 'Calendar → SubTrackr', + bidirectional: 'Two-way sync', +}; + export const CALENDAR_PROVIDERS: CalendarProvider[] = ['google', 'apple', 'outlook']; export const REMINDER_PRESETS: ReminderPreset[] = [ diff --git a/src/types/sso.ts b/src/types/sso.ts new file mode 100644 index 00000000..91249a04 --- /dev/null +++ b/src/types/sso.ts @@ -0,0 +1,81 @@ +export type SSOProtocol = 'saml2' | 'oidc'; +export type IdPStatus = 'active' | 'inactive' | 'pending_setup'; +export type SubTrackrRole = 'admin' | 'viewer' | 'billing'; + +export interface RoleMapping { + idpGroup: string; + subtrackrRole: SubTrackrRole; +} + +export interface IdPCertificate { + fingerprint: string; + notBefore: string; + notAfter: string; + isPrimary: boolean; +} + +export interface SAMLConfiguration { + entityId: string; + ssoUrl: string; + sloUrl?: string; + certificates: IdPCertificate[]; + nameIdFormat: string; + signAuthnRequests: boolean; + wantAssertionsSigned: boolean; +} + +export interface OIDCConfiguration { + issuer: string; + authorizationEndpoint: string; + tokenEndpoint: string; + userinfoEndpoint: string; + jwksUri: string; + clientId: string; + scopes: string[]; +} + +export interface IdentityProvider { + id: string; + organizationId: string; + name: string; + protocol: SSOProtocol; + status: IdPStatus; + samlConfig?: SAMLConfiguration; + oidcConfig?: OIDCConfiguration; + roleMappings: RoleMapping[]; + jitProvisioningEnabled: boolean; + ipAllowlist: string[]; + bypassCodeCount: number; + createdAt: string; + updatedAt: string; +} + +export interface SCIMUser { + id: string; + externalId: string; + email: string; + displayName: string; + role: SubTrackrRole; + status: 'active' | 'suspended' | 'deactivated'; + groups: string[]; + provisionedAt: string; + lastSyncedAt: string; +} + +export interface SSOSession { + id: string; + userId: string; + identityProviderId: string; + protocol: SSOProtocol; + authenticatedAt: string; + expiresAt: string; +} + +export const SSO_PROVIDER_PRESETS = [ + { id: 'okta', name: 'Okta', protocol: 'saml2' as SSOProtocol }, + { id: 'azure', name: 'Azure AD', protocol: 'oidc' as SSOProtocol }, + { id: 'onelogin', name: 'OneLogin', protocol: 'saml2' as SSOProtocol }, + { id: 'keycloak', name: 'Keycloak', protocol: 'oidc' as SSOProtocol }, +] as const; + +export const AVAILABLE_ROLES: SubTrackrRole[] = ['admin', 'viewer', 'billing'];