diff --git a/test/test.alerts.node.ts b/test/test.alerts.node.ts index 87a4618..f20c060 100644 --- a/test/test.alerts.node.ts +++ b/test/test.alerts.node.ts @@ -261,6 +261,42 @@ tap.test('routes organization-scoped alerts to org admins by rule', async () => } }); +tap.test('uses built-in organization fallback rules for app connection events', async () => { + const { manager, users, roles, passportDevices, deliveredHints, restore } = createTestAlertManager(); + + try { + addUser(users, { id: 'owner-1', email: 'owner@example.com' }); + addPassportDevice(passportDevices, { id: 'owner-device', userId: 'owner-1', label: 'Owner Phone' }); + + const ownerRole = new Role(); + ownerRole.id = 'role-owner'; + ownerRole.data = { + userId: 'owner-1', + organizationId: 'org-1', + roles: ['owner'], + }; + roles.set(ownerRole.id, ownerRole); + + const createdAlerts = await manager.createAlertsForEvent({ + category: 'admin', + eventType: 'org_app_connected', + severity: 'medium', + title: 'Organization app connected', + body: 'A new app was connected.', + actorUserId: 'owner-1', + organizationId: 'org-1', + relatedEntityId: 'app-1', + relatedEntityType: 'global-app', + }); + + expect(createdAlerts).toHaveLength(1); + expect(createdAlerts[0].data.recipientUserId).toEqual('owner-1'); + expect(deliveredHints).toHaveLength(1); + } finally { + restore(); + } +}); + tap.test('lists alerts, resolves hint lookups, and marks alerts seen', async () => { const { manager, alerts, restore } = createTestAlertManager(); @@ -299,6 +335,15 @@ tap.test('lists alerts, resolves hint lookups, and marks alerts seen', async () expect(seenAlert.data.notification.status).toEqual('seen'); expect(seenAlert.data.seenAt).toBeGreaterThan(0); expect(alerts.get('alert-1')?.data.notification.status).toEqual('seen'); + + const dismissedAlert = await manager.dismissAlert('user-1', 'hint-1'); + expect(dismissedAlert.data.dismissedAt).toBeGreaterThan(0); + + const defaultList = await manager.listAlertsForUser('user-1'); + expect(defaultList).toHaveLength(0); + + const fullList = await manager.listAlertsForUser('user-1', true); + expect(fullList).toHaveLength(1); } finally { restore(); } diff --git a/test/test.passport.node.ts b/test/test.passport.node.ts index 773aa3f..87fd482 100644 --- a/test/test.passport.node.ts +++ b/test/test.passport.node.ts @@ -275,6 +275,14 @@ tap.test('creates and approves a passport challenge with DER signatures and evid notificationTitle: 'Office entry request', requireLocation: true, requireNfc: true, + locationPolicy: { + mode: 'geofence', + label: 'HQ Berlin', + latitude: 53.0793, + longitude: 8.8017, + radiusMeters: 80, + maxAccuracyMeters: 25, + }, }); expect(deliveredHintIds).toHaveLength(1); @@ -286,6 +294,15 @@ tap.test('creates and approves a passport challenge with DER signatures and evid deviceId: passportDevice.id, signatureBase64: signer.sign(challengeResult.signingPayload), signatureFormat: 'der', + location: { + latitude: 53.5, + longitude: 8.1, + accuracyMeters: 12, + capturedAt: Date.now(), + }, + nfc: { + readerId: 'door-reader-a', + }, }) ).rejects.toThrow(); @@ -308,6 +325,7 @@ tap.test('creates and approves a passport challenge with DER signatures and evid expect(approvedChallenge.data.status).toEqual('approved'); expect(approvedChallenge.data.evidence?.signatureFormat).toEqual('der'); expect(approvedChallenge.data.evidence?.location?.accuracyMeters).toEqual(12); + expect(approvedChallenge.data.evidence?.locationEvaluation?.matched).toBeTrue(); expect(approvedChallenge.data.evidence?.nfc?.readerId).toEqual('door-reader-a'); expect(activityLogCalls.at(-1)?.action).toEqual('passport_challenge_approved'); } finally { diff --git a/ts/reception/classes.alertmanager.ts b/ts/reception/classes.alertmanager.ts index c97ff8d..4e1b050 100644 --- a/ts/reception/classes.alertmanager.ts +++ b/ts/reception/classes.alertmanager.ts @@ -36,7 +36,10 @@ export class AlertManager { action: 'listPassportAlerts', } ); - const alerts = await this.listAlertsForUser(passportDevice.data.userId); + const alerts = await this.listAlertsForUser( + passportDevice.data.userId, + !!requestArg.includeDismissed + ); return { alerts: alerts.map((alertArg) => ({ id: alertArg.id, data: alertArg.data })), }; @@ -82,6 +85,25 @@ export class AlertManager { ) ); + this.typedRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'dismissPassportAlert', + async (requestArg) => { + const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest( + requestArg, + { + action: 'dismissPassportAlert', + signedFields: [`hint_id=${requestArg.hintId}`], + } + ); + await this.dismissAlert(passportDevice.data.userId, requestArg.hintId); + return { + success: true, + }; + } + ) + ); + this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'upsertAlertRule', @@ -263,46 +285,81 @@ export class AlertManager { } if (optionsArg.eventType === 'global_admin_access') { - const fallbackRule = new AlertRule(); - fallbackRule.id = 'builtin-global-admin-access'; - fallbackRule.data = { + return [this.createBuiltInRule('builtin-global-admin-access', { scope: 'global', - organizationId: undefined, eventType: 'global_admin_access', minimumSeverity: 'high', recipientMode: 'global_admins', - recipientUserIds: [], - push: true, - enabled: true, - createdByUserId: 'system', - createdAt: 0, - updatedAt: 0, - }; - return [fallbackRule]; + })]; } if (optionsArg.eventType === 'global_app_credentials_regenerated') { - const fallbackRule = new AlertRule(); - fallbackRule.id = 'builtin-global-app-credentials-regenerated'; - fallbackRule.data = { + return [this.createBuiltInRule('builtin-global-app-credentials-regenerated', { scope: 'global', - organizationId: undefined, eventType: 'global_app_credentials_regenerated', minimumSeverity: 'critical', recipientMode: 'global_admins', - recipientUserIds: [], - push: true, - enabled: true, - createdByUserId: 'system', - createdAt: 0, - updatedAt: 0, + })]; + } + + if (optionsArg.organizationId) { + const organizationFallbackMap: Record< + string, + { + minimumSeverity: plugins.idpInterfaces.data.TAlertSeverity; + } + > = { + org_app_connected: { minimumSeverity: 'medium' }, + org_app_disconnected: { minimumSeverity: 'medium' }, + org_invitation_created: { minimumSeverity: 'low' }, + org_invitation_resent: { minimumSeverity: 'low' }, + org_member_removed: { minimumSeverity: 'high' }, + org_member_roles_updated: { minimumSeverity: 'high' }, + org_ownership_transferred: { minimumSeverity: 'critical' }, }; - return [fallbackRule]; + const fallbackConfig = organizationFallbackMap[optionsArg.eventType]; + if (fallbackConfig) { + return [this.createBuiltInRule(`builtin-${optionsArg.eventType}`, { + scope: 'organization', + organizationId: optionsArg.organizationId, + eventType: optionsArg.eventType, + minimumSeverity: fallbackConfig.minimumSeverity, + recipientMode: 'org_admins', + })]; + } } return []; } + private createBuiltInRule( + ruleIdArg: string, + optionsArg: { + scope: plugins.idpInterfaces.data.TAlertRuleScope; + organizationId?: string; + eventType: string; + minimumSeverity: plugins.idpInterfaces.data.TAlertSeverity; + recipientMode: plugins.idpInterfaces.data.TAlertRuleRecipientMode; + } + ) { + const fallbackRule = new AlertRule(); + fallbackRule.id = ruleIdArg; + fallbackRule.data = { + scope: optionsArg.scope, + organizationId: optionsArg.organizationId, + eventType: optionsArg.eventType, + minimumSeverity: optionsArg.minimumSeverity, + recipientMode: optionsArg.recipientMode, + recipientUserIds: [], + push: true, + enabled: true, + createdByUserId: 'system', + createdAt: 0, + updatedAt: 0, + }; + return fallbackRule; + } + public async createAlertsForEvent(optionsArg: { category: plugins.idpInterfaces.data.TAlertCategory; eventType: string; @@ -378,11 +435,13 @@ export class AlertManager { return createdAlerts; } - public async listAlertsForUser(userIdArg: string) { + public async listAlertsForUser(userIdArg: string, includeDismissedArg = false) { const alerts = await this.CAlert.getInstances({ 'data.recipientUserId': userIdArg, }); - return alerts.sort((leftArg, rightArg) => rightArg.data.createdAt - leftArg.data.createdAt); + return alerts + .filter((alertArg) => includeDismissedArg || !alertArg.data.dismissedAt) + .sort((leftArg, rightArg) => rightArg.data.createdAt - leftArg.data.createdAt); } public async getAlertByHint(userIdArg: string, hintIdArg: string) { @@ -408,6 +467,25 @@ export class AlertManager { return alert; } + public async dismissAlert(userIdArg: string, hintIdArg: string) { + const alert = await this.getAlertByHint(userIdArg, hintIdArg); + if (!alert) { + throw new plugins.typedrequest.TypedResponseError('Alert not found'); + } + + alert.data.dismissedAt = Date.now(); + if (!alert.data.seenAt) { + alert.data.seenAt = Date.now(); + } + alert.data.notification = { + ...alert.data.notification, + status: 'seen', + seenAt: alert.data.notification.seenAt || Date.now(), + }; + await alert.save(); + return alert; + } + public async reDeliverPendingAlerts() { const alerts = await this.CAlert.getInstances({}); for (const alert of alerts) { diff --git a/ts/reception/classes.appconnectionmanager.ts b/ts/reception/classes.appconnectionmanager.ts index 4b51a2f..f57a83c 100644 --- a/ts/reception/classes.appconnectionmanager.ts +++ b/ts/reception/classes.appconnectionmanager.ts @@ -11,6 +11,29 @@ export class AppConnectionManager { public CAppConnection = plugins.smartdata.setDefaultManagerForDoc(this, AppConnection); + private async emitOrganizationAlert(optionsArg: { + organizationId: string; + eventType: string; + severity: plugins.idpInterfaces.data.TAlertSeverity; + title: string; + body: string; + actorUserId: string; + relatedEntityId?: string; + relatedEntityType?: string; + }) { + await this.receptionRef.alertManager.createAlertsForEvent({ + category: 'admin', + organizationId: optionsArg.organizationId, + eventType: optionsArg.eventType, + severity: optionsArg.severity, + title: optionsArg.title, + body: optionsArg.body, + actorUserId: optionsArg.actorUserId, + relatedEntityId: optionsArg.relatedEntityId, + relatedEntityType: optionsArg.relatedEntityType, + }); + } + constructor(receptionRefArg: Reception) { this.receptionRef = receptionRefArg; this.receptionRef.typedrouter.addTypedRouter(this.typedrouter); @@ -131,6 +154,17 @@ export class AppConnectionManager { await connection.save(); } + await this.emitOrganizationAlert({ + organizationId: requestArg.organizationId, + eventType: 'org_app_connected', + severity: 'medium', + title: 'Organization app connected', + body: `${user.data.email} connected ${app.data.name} to this organization.`, + actorUserId: user.id, + relatedEntityId: app.id, + relatedEntityType: 'global-app', + }); + return { success: true, connection: await connection.createSavableObject(), @@ -145,6 +179,17 @@ export class AppConnectionManager { await connection.disconnect(); + await this.emitOrganizationAlert({ + organizationId: requestArg.organizationId, + eventType: 'org_app_disconnected', + severity: 'medium', + title: 'Organization app disconnected', + body: `${user.data.email} disconnected ${app.data.name} from this organization.`, + actorUserId: user.id, + relatedEntityId: app.id, + relatedEntityType: 'global-app', + }); + return { success: true, connection: await connection.createSavableObject(), diff --git a/ts/reception/classes.passportchallenge.ts b/ts/reception/classes.passportchallenge.ts index ff75460..431356e 100644 --- a/ts/reception/classes.passportchallenge.ts +++ b/ts/reception/classes.passportchallenge.ts @@ -30,6 +30,7 @@ export class PassportChallenge extends plugins.smartdata.SmartDataDbDoc< deviceLabel: undefined, requireLocation: false, requireNfc: false, + locationPolicy: undefined, requestedCapabilities: undefined, }, evidence: undefined, @@ -56,4 +57,10 @@ export class PassportChallenge extends plugins.smartdata.SmartDataDbDoc< this.data.status = 'expired'; await this.save(); } + + public async markRejected() { + this.data.status = 'rejected'; + this.data.completedAt = Date.now(); + await this.save(); + } } diff --git a/ts/reception/classes.passportmanager.ts b/ts/reception/classes.passportmanager.ts index 99a4a7c..a7d4e75 100644 --- a/ts/reception/classes.passportmanager.ts +++ b/ts/reception/classes.passportmanager.ts @@ -137,6 +137,47 @@ export class PassportManager { ) ); + this.typedRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'getPassportDashboard', + async (requestArg) => { + const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, { + action: 'getPassportDashboard', + }); + + const user = await this.receptionRef.userManager.CUser.getInstance({ + id: passportDevice.data.userId, + }); + const organizations = user + ? await this.receptionRef.organizationmanager.getAllOrganizationsForUser(user) + : []; + const devices = await this.getPassportDevicesForUser(passportDevice.data.userId); + const challenges = await this.listPendingChallengesForDevice(passportDevice.id); + const alerts = await this.receptionRef.alertManager.listAlertsForUser(passportDevice.data.userId); + + return { + profile: { + userId: passportDevice.data.userId, + name: user?.data?.name || user?.data?.email || 'Passport User', + handle: user?.data?.username || user?.data?.email || passportDevice.data.userId, + organizations: organizations.map((organizationArg) => ({ + id: organizationArg.id, + name: organizationArg.data.name, + })), + deviceCount: devices.length, + recoverySummary: 'Recovery workflows are not configured yet for this passport.', + }, + devices: devices.map((deviceArg) => ({ id: deviceArg.id, data: deviceArg.data })), + challenges: challenges.map((challengeArg) => ({ + challenge: { id: challengeArg.id, data: challengeArg.data }, + signingPayload: this.buildChallengeSigningPayload(challengeArg), + })), + alerts: alerts.map((alertArg) => ({ id: alertArg.id, data: alertArg.data })), + }; + } + ) + ); + this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'approvePassportChallenge', @@ -161,6 +202,26 @@ export class PassportManager { ) ); + this.typedRouter.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'rejectPassportChallenge', + async (requestArg) => { + const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, { + action: 'rejectPassportChallenge', + signedFields: [`challenge_id=${requestArg.challengeId}`], + }); + const challenge = await this.rejectPassportChallenge(passportDevice.id, requestArg.challengeId); + return { + success: true, + challenge: { + id: challenge.id, + data: challenge.data, + }, + }; + } + ) + ); + this.typedRouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'registerPassportPushToken', @@ -225,8 +286,11 @@ export class PassportManager { return { challenge: passportChallenge ? { - id: passportChallenge.id, - data: passportChallenge.data, + challenge: { + id: passportChallenge.id, + data: passportChallenge.data, + }, + signingPayload: this.buildChallengeSigningPayload(passportChallenge), } : undefined, }; @@ -374,9 +438,43 @@ export class PassportManager { `audience=${challengeArg.data.metadata.audience || ''}`, `require_location=${challengeArg.data.metadata.requireLocation}`, `require_nfc=${challengeArg.data.metadata.requireNfc}`, + `location_policy=${challengeArg.data.metadata.locationPolicy ? JSON.stringify(challengeArg.data.metadata.locationPolicy) : ''}`, ].join('\n'); } + private evaluateLocationPolicy( + locationPolicyArg: plugins.idpInterfaces.data.IPassportLocationPolicy, + locationEvidenceArg: plugins.idpInterfaces.data.IPassportLocationEvidence + ) { + const earthRadiusMeters = 6371000; + const latitude1 = (locationPolicyArg.latitude * Math.PI) / 180; + const latitude2 = (locationEvidenceArg.latitude * Math.PI) / 180; + const deltaLatitude = ((locationEvidenceArg.latitude - locationPolicyArg.latitude) * Math.PI) / 180; + const deltaLongitude = ((locationEvidenceArg.longitude - locationPolicyArg.longitude) * Math.PI) / 180; + + const haversine = + Math.sin(deltaLatitude / 2) * Math.sin(deltaLatitude / 2) + + Math.cos(latitude1) * Math.cos(latitude2) * Math.sin(deltaLongitude / 2) * Math.sin(deltaLongitude / 2); + const distanceMeters = 2 * earthRadiusMeters * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)); + + const accuracyAccepted = + !locationPolicyArg.maxAccuracyMeters || + locationEvidenceArg.accuracyMeters <= locationPolicyArg.maxAccuracyMeters; + const withinGeofence = distanceMeters <= locationPolicyArg.radiusMeters; + + return { + matched: accuracyAccepted && withinGeofence, + distanceMeters, + accuracyAccepted, + evaluatedAt: Date.now(), + reason: !accuracyAccepted + ? `Accuracy ${locationEvidenceArg.accuracyMeters}m exceeds allowed ${locationPolicyArg.maxAccuracyMeters}m` + : !withinGeofence + ? `Location is ${Math.round(distanceMeters)}m away from ${locationPolicyArg.label || 'required area'}` + : undefined, + }; + } + private createPairingPayload( pairingTokenArg: string, challengeArg: PassportChallenge, @@ -450,6 +548,7 @@ export class PassportManager { deviceLabel: optionsArg.deviceLabel, requireLocation: false, requireNfc: false, + locationPolicy: undefined, requestedCapabilities: this.normalizeCapabilities(optionsArg.capabilities), }, evidence: undefined, @@ -602,6 +701,7 @@ export class PassportManager { notificationTitle?: string; requireLocation?: boolean; requireNfc?: boolean; + locationPolicy?: plugins.idpInterfaces.data.IPassportLocationPolicy; } ) { const passportDevices = await this.getPassportDevicesForUser(userIdArg); @@ -631,8 +731,9 @@ export class PassportManager { audience: optionsArg.audience, notificationTitle: optionsArg.notificationTitle, deviceLabel: targetDevice.data.label, - requireLocation: !!optionsArg.requireLocation, + requireLocation: !!optionsArg.requireLocation || !!optionsArg.locationPolicy, requireNfc: !!optionsArg.requireNfc, + locationPolicy: optionsArg.locationPolicy, }, evidence: undefined, notification: { @@ -716,9 +817,21 @@ export class PassportManager { throw new plugins.typedrequest.TypedResponseError('Passport signature invalid'); } + const locationEvaluation = + passportChallenge.data.metadata.locationPolicy && optionsArg.location + ? this.evaluateLocationPolicy(passportChallenge.data.metadata.locationPolicy, optionsArg.location) + : undefined; + + if (passportChallenge.data.metadata.locationPolicy && !locationEvaluation?.matched) { + throw new plugins.typedrequest.TypedResponseError( + locationEvaluation?.reason || 'Location evidence did not satisfy the office policy' + ); + } + await passportChallenge.markApproved({ signatureFormat: optionsArg.signatureFormat || 'raw', location: optionsArg.location, + locationEvaluation, nfc: optionsArg.nfc, }); @@ -738,6 +851,36 @@ export class PassportManager { return passportChallenge; } + public async rejectPassportChallenge(deviceIdArg: string, challengeIdArg: string) { + const passportChallenge = await this.CPassportChallenge.getInstance({ + id: challengeIdArg, + 'data.deviceId': deviceIdArg, + 'data.status': 'pending', + }); + if (!passportChallenge) { + throw new plugins.typedrequest.TypedResponseError('Passport challenge not found'); + } + + if (passportChallenge.isExpired()) { + await passportChallenge.markExpired(); + throw new plugins.typedrequest.TypedResponseError('Passport challenge expired'); + } + + await passportChallenge.markRejected(); + + await this.receptionRef.activityLogManager.logActivity( + passportChallenge.data.userId, + 'passport_challenge_rejected', + `Rejected passport challenge ${passportChallenge.data.type}`, + { + targetId: passportChallenge.id, + targetType: 'passport-challenge', + } + ); + + return passportChallenge; + } + public async listPendingChallengesForDevice(deviceIdArg: string) { const passportChallenges = await this.CPassportChallenge.getInstances({ 'data.deviceId': deviceIdArg, diff --git a/ts/reception/classes.userinvitationmanager.ts b/ts/reception/classes.userinvitationmanager.ts index e2dfea9..da39fd6 100644 --- a/ts/reception/classes.userinvitationmanager.ts +++ b/ts/reception/classes.userinvitationmanager.ts @@ -14,6 +14,36 @@ export class UserInvitationManager { public CUserInvitation = plugins.smartdata.setDefaultManagerForDoc(this, UserInvitation); + private async emitOrganizationAlert(optionsArg: { + organizationId: string; + eventType: string; + severity: plugins.idpInterfaces.data.TAlertSeverity; + title: string; + body: string; + actorUserId: string; + relatedEntityId?: string; + relatedEntityType?: string; + }) { + await this.receptionRef.alertManager.createAlertsForEvent({ + category: 'admin', + organizationId: optionsArg.organizationId, + eventType: optionsArg.eventType, + severity: optionsArg.severity, + title: optionsArg.title, + body: optionsArg.body, + actorUserId: optionsArg.actorUserId, + relatedEntityId: optionsArg.relatedEntityId, + relatedEntityType: optionsArg.relatedEntityType, + }); + } + + private async getOrganizationName(organizationIdArg: string) { + const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({ + id: organizationIdArg, + }); + return organization?.data.name || 'this organization'; + } + constructor(receptionRefArg: Reception) { this.receptionRef = receptionRefArg; this.receptionRef.typedrouter.addTypedRouter(this.typedrouter); @@ -85,11 +115,24 @@ export class UserInvitationManager { isNew = true; } - // Send invitation email - await this.sendInvitationEmail(invitation, requestArg.organizationId); + // Send invitation email + await this.sendInvitationEmail(invitation, requestArg.organizationId); - return { - success: true, + await this.emitOrganizationAlert({ + organizationId: requestArg.organizationId, + eventType: 'org_invitation_created', + severity: 'low', + title: 'Organization invitation created', + body: `${user.data.email} invited ${email} to ${await this.getOrganizationName( + requestArg.organizationId + )}.`, + actorUserId: user.id, + relatedEntityId: invitation.id, + relatedEntityType: 'invitation', + }); + + return { + success: true, invitation: await invitation.createSavableObject(), isNew, }; @@ -189,6 +232,17 @@ export class UserInvitationManager { await invitation.regenerateToken(); await this.sendInvitationEmail(invitation, requestArg.organizationId); + await this.emitOrganizationAlert({ + organizationId: requestArg.organizationId, + eventType: 'org_invitation_resent', + severity: 'low', + title: 'Organization invitation resent', + body: `${user.data.email} resent an invitation to ${invitation.data.email}.`, + actorUserId: user.id, + relatedEntityId: invitation.id, + relatedEntityType: 'invitation', + }); + return { success: true, message: 'Invitation resent.' }; } ) @@ -231,10 +285,12 @@ export class UserInvitationManager { await role.delete(); - // Remove org from user's connectedOrgs - const memberUser = await this.receptionRef.userManager.CUser.getInstance({ + const removedUser = await this.receptionRef.userManager.CUser.getInstance({ id: requestArg.userId, }); + + // Remove org from user's connectedOrgs + const memberUser = removedUser; if (memberUser && memberUser.data.connectedOrgs) { memberUser.data.connectedOrgs = memberUser.data.connectedOrgs.filter( orgId => orgId !== requestArg.organizationId @@ -242,6 +298,19 @@ export class UserInvitationManager { await memberUser.save(); } + await this.emitOrganizationAlert({ + organizationId: requestArg.organizationId, + eventType: 'org_member_removed', + severity: 'high', + title: 'Organization member removed', + body: `${user.data.email} removed ${removedUser?.data?.email || requestArg.userId} from ${await this.getOrganizationName( + requestArg.organizationId + )}.`, + actorUserId: user.id, + relatedEntityId: requestArg.userId, + relatedEntityType: 'user', + }); + return { success: true }; } ) @@ -283,6 +352,20 @@ export class UserInvitationManager { role.data.roles = requestArg.roles; await role.save(); + const updatedUser = await this.receptionRef.userManager.CUser.getInstance({ + id: requestArg.userId, + }); + await this.emitOrganizationAlert({ + organizationId: requestArg.organizationId, + eventType: 'org_member_roles_updated', + severity: 'high', + title: 'Organization member roles updated', + body: `${user.data.email} changed roles for ${updatedUser?.data?.email || requestArg.userId} to ${requestArg.roles.join(', ')}.`, + actorUserId: user.id, + relatedEntityId: requestArg.userId, + relatedEntityType: 'user', + }); + return { success: true, role: await role.createSavableObject() }; } ) @@ -332,6 +415,20 @@ export class UserInvitationManager { } await currentUserRole.save(); + const newOwner = await this.receptionRef.userManager.CUser.getInstance({ + id: requestArg.newOwnerId, + }); + await this.emitOrganizationAlert({ + organizationId: requestArg.organizationId, + eventType: 'org_ownership_transferred', + severity: 'critical', + title: 'Organization ownership transferred', + body: `${user.data.email} transferred ownership to ${newOwner?.data?.email || requestArg.newOwnerId}.`, + actorUserId: user.id, + relatedEntityId: requestArg.newOwnerId, + relatedEntityType: 'user', + }); + return { success: true }; } ) diff --git a/ts_interfaces/data/activity.ts b/ts_interfaces/data/activity.ts index def6b81..9913bab 100644 --- a/ts_interfaces/data/activity.ts +++ b/ts_interfaces/data/activity.ts @@ -6,6 +6,7 @@ export type TActivityAction = | 'passport_device_enrolled' | 'passport_device_revoked' | 'passport_challenge_approved' + | 'passport_challenge_rejected' | 'org_created' | 'org_joined' | 'org_left' diff --git a/ts_interfaces/data/passportchallenge.ts b/ts_interfaces/data/passportchallenge.ts index 1d8d056..e3ddbcc 100644 --- a/ts_interfaces/data/passportchallenge.ts +++ b/ts_interfaces/data/passportchallenge.ts @@ -24,6 +24,15 @@ export interface IPassportNfcEvidence { readerId?: string; } +export interface IPassportLocationPolicy { + mode: 'geofence'; + label?: string; + latitude: number; + longitude: number; + radiusMeters: number; + maxAccuracyMeters?: number; +} + export interface IPassportChallenge { id: string; data: { @@ -40,11 +49,19 @@ export interface IPassportChallenge { deviceLabel?: string; requireLocation: boolean; requireNfc: boolean; + locationPolicy?: IPassportLocationPolicy; requestedCapabilities?: Partial; }; evidence?: { signatureFormat?: TPassportSignatureFormat; location?: IPassportLocationEvidence; + locationEvaluation?: { + matched: boolean; + distanceMeters?: number; + accuracyAccepted?: boolean; + evaluatedAt: number; + reason?: string; + }; nfc?: IPassportNfcEvidence; }; notification?: { diff --git a/ts_interfaces/request/alert.ts b/ts_interfaces/request/alert.ts index 1aa5e87..6dd29b7 100644 --- a/ts_interfaces/request/alert.ts +++ b/ts_interfaces/request/alert.ts @@ -8,7 +8,9 @@ export interface IReq_ListPassportAlerts IReq_ListPassportAlerts > { method: 'listPassportAlerts'; - request: IPassportDeviceSignedRequest; + request: IPassportDeviceSignedRequest & { + includeDismissed?: boolean; + }; response: { alerts: data.IAlert[]; }; @@ -42,6 +44,20 @@ export interface IReq_MarkPassportAlertSeen }; } +export interface IReq_DismissPassportAlert + extends plugins.typedRequestInterfaces.implementsTR< + plugins.typedRequestInterfaces.ITypedRequest, + IReq_DismissPassportAlert + > { + method: 'dismissPassportAlert'; + request: IPassportDeviceSignedRequest & { + hintId: string; + }; + response: { + success: boolean; + }; +} + export interface IReq_UpsertAlertRule extends plugins.typedRequestInterfaces.implementsTR< plugins.typedRequestInterfaces.ITypedRequest, diff --git a/ts_interfaces/request/passport.ts b/ts_interfaces/request/passport.ts index fed551b..b65d943 100644 --- a/ts_interfaces/request/passport.ts +++ b/ts_interfaces/request/passport.ts @@ -95,6 +95,7 @@ export interface IReq_CreatePassportChallenge notificationTitle?: string; requireLocation?: boolean; requireNfc?: boolean; + locationPolicy?: data.IPassportLocationPolicy; }; response: { challengeId: string; @@ -125,6 +126,21 @@ export interface IReq_ApprovePassportChallenge }; } +export interface IReq_RejectPassportChallenge + extends plugins.typedRequestInterfaces.implementsTR< + plugins.typedRequestInterfaces.ITypedRequest, + IReq_RejectPassportChallenge + > { + method: 'rejectPassportChallenge'; + request: IPassportDeviceSignedRequest & { + challengeId: string; + }; + response: { + success: boolean; + challenge: data.IPassportChallenge; + }; +} + export interface IReq_RegisterPassportPushToken extends plugins.typedRequestInterfaces.implementsTR< plugins.typedRequestInterfaces.ITypedRequest, @@ -164,7 +180,10 @@ export interface IReq_GetPassportChallengeByHint hintId: string; }; response: { - challenge?: data.IPassportChallenge; + challenge?: { + challenge: data.IPassportChallenge; + signingPayload: string; + }; }; } @@ -181,3 +200,28 @@ export interface IReq_MarkPassportChallengeSeen success: boolean; }; } + +export interface IReq_GetPassportDashboard + extends plugins.typedRequestInterfaces.implementsTR< + plugins.typedRequestInterfaces.ITypedRequest, + IReq_GetPassportDashboard + > { + method: 'getPassportDashboard'; + request: IPassportDeviceSignedRequest; + response: { + profile: { + userId: string; + name: string; + handle: string; + organizations: Array<{ id: string; name: string }>; + deviceCount: number; + recoverySummary: string; + }; + devices: data.IPassportDevice[]; + challenges: Array<{ + challenge: data.IPassportChallenge; + signingPayload: string; + }>; + alerts: data.IAlert[]; + }; +}