From 2c45dbaa5fd2d5c1f91a69d07af9e6bbcfbcfd09 Mon Sep 17 00:00:00 2001 From: Owoh Chidubem Alexander Date: Tue, 2 Jun 2026 02:50:12 +0100 Subject: [PATCH] feat: implement fine-grained RBAC service with support for role hierarchy, temporary elevation, and audit logging. --- .../services/__tests__/accessControl.test.ts | 231 ++++ backend/services/accessControl.ts | 661 ++++++++++ backend/services/index.ts | 15 + docs/permissions.md | 188 +++ package-lock.json | 1093 +++++++++++++---- src/components/admin/FeatureManagement.tsx | 267 +++- 6 files changed, 2204 insertions(+), 251 deletions(-) create mode 100644 backend/services/__tests__/accessControl.test.ts create mode 100644 backend/services/accessControl.ts create mode 100644 docs/permissions.md diff --git a/backend/services/__tests__/accessControl.test.ts b/backend/services/__tests__/accessControl.test.ts new file mode 100644 index 00000000..dde081b7 --- /dev/null +++ b/backend/services/__tests__/accessControl.test.ts @@ -0,0 +1,231 @@ +import { AccessControlService, ROLE_HIERARCHY, ROLE_PERMISSIONS } from '../accessControl'; +import { AuditService } from '../auditService'; +import { AlertingService } from '../alerting'; + +describe('AccessControlService', () => { + let svc: AccessControlService; + let audit: AuditService; + let alerting: AlertingService; + + beforeEach(() => { + audit = new AuditService('test-secret'); + alerting = new AlertingService(); + svc = new AccessControlService(audit, alerting); + svc.bootstrap('root-admin'); + }); + + describe('role hierarchy', () => { + it('orders roles correctly', () => { + expect(ROLE_HIERARCHY.admin).toBeGreaterThan(ROLE_HIERARCHY.manager); + expect(ROLE_HIERARCHY.manager).toBeGreaterThan(ROLE_HIERARCHY.viewer); + }); + + it('validates hierarchy has no duplicates', () => { + expect(svc.validateRoleHierarchy()).toBe(true); + }); + }); + + describe('bootstrap', () => { + it('only allows one bootstrap', () => { + expect(() => svc.bootstrap('other')).toThrow('already bootstrapped'); + }); + }); + + describe('role assignment', () => { + it('assigns a role to a user', () => { + svc.assignRole('user-1', 'admin', 'root-admin'); + const assignment = svc.getAssignment('user-1'); + expect(assignment).not.toBeNull(); + expect(assignment!.role).toBe('admin'); + }); + + it('throws when viewer tries to assign admin', () => { + svc.assignRole('viewer-user', 'viewer', 'root-admin'); + expect(() => svc.assignRole('user-3', 'admin', 'viewer-user')).toThrow(); + }); + + it('revokes a role', () => { + svc.assignRole('user-1', 'manager', 'root-admin'); + svc.revokeRole('user-1', 'root-admin'); + expect(svc.getAssignment('user-1')).toBeNull(); + }); + + it('expires role assignments', () => { + svc.assignRole('user-1', 'viewer', 'root-admin', Date.now() - 1000); + expect(svc.getAssignment('user-1')).toBeNull(); + }); + }); + + describe('permission checking', () => { + it('grants admin access to all resources', () => { + svc.assignRole('admin-1', 'admin', 'root-admin'); + expect(svc.hasPermission('admin-1', 'subscriptions', 'manage')).toBe(true); + expect(svc.hasPermission('admin-1', 'billing', 'delete')).toBe(true); + expect(svc.hasPermission('admin-1', 'settings', 'update')).toBe(true); + }); + + it('grants manager create, read, update but not delete on subscriptions', () => { + svc.assignRole('mgr-1', 'manager', 'root-admin'); + expect(svc.hasPermission('mgr-1', 'subscriptions', 'create')).toBe(true); + expect(svc.hasPermission('mgr-1', 'subscriptions', 'read')).toBe(true); + expect(svc.hasPermission('mgr-1', 'subscriptions', 'update')).toBe(true); + expect(svc.hasPermission('mgr-1', 'subscriptions', 'delete')).toBe(false); + }); + + it('grants viewer read-only access', () => { + svc.assignRole('viewer-1', 'viewer', 'root-admin'); + expect(svc.hasPermission('viewer-1', 'subscriptions', 'read')).toBe(true); + expect(svc.hasPermission('viewer-1', 'subscriptions', 'create')).toBe(false); + expect(svc.hasPermission('viewer-1', 'billing', 'update')).toBe(false); + }); + + it('defaults unassigned users to viewer role', () => { + expect(svc.hasPermission('unknown', 'subscriptions', 'read')).toBe(true); + expect(svc.hasPermission('unknown', 'settings', 'update')).toBe(false); + }); + + it('throws AccessDeniedError on requirePermission failure', () => { + svc.assignRole('viewer-1', 'viewer', 'root-admin'); + expect(() => + svc.requirePermission('viewer-1', 'billing', 'delete') + ).toThrow('Access denied'); + }); + }); + + describe('temporary elevation', () => { + it('grants and respects temporary elevation', () => { + svc.assignRole('user-1', 'viewer', 'root-admin'); + + const elevation = svc.grantTemporaryElevation( + 'user-1', + 'manager', + 'root-admin', + 60_000, + 'Testing elevation' + ); + + expect(elevation.elevatedRole).toBe('manager'); + expect(elevation.originalRole).toBe('viewer'); + + expect(svc.getUserRole('user-1')).toBe('manager'); + expect(svc.hasPermission('user-1', 'subscriptions', 'update')).toBe(true); + }); + + it('expires elevations after duration', () => { + svc.assignRole('user-1', 'viewer', 'root-admin'); + + svc.grantTemporaryElevation('user-1', 'manager', 'root-admin', -1, 'Already expired'); + + expect(svc.getActiveElevation('user-1')).toBeNull(); + }); + + it('prevents lower roles from elevating others', () => { + svc.assignRole('viewer-1', 'viewer', 'root-admin'); + svc.assignRole('mgr-1', 'manager', 'root-admin'); + + expect(() => + svc.grantTemporaryElevation('viewer-1', 'admin', 'mgr-1', 60_000, 'Escalate') + ).toThrow('cannot elevate'); + }); + }); + + describe('API key scoping', () => { + it('registers and checks API key permissions', () => { + svc.registerApiKeyScope('key-1', [ + { resource: 'subscriptions', actions: ['read'] }, + ]); + + expect(svc.checkApiKeyPermission('key-1', 'subscriptions', 'read')).toBe(true); + expect(svc.checkApiKeyPermission('key-1', 'subscriptions', 'create')).toBe(false); + expect(svc.checkApiKeyPermission('key-1', 'billing', 'read')).toBe(false); + }); + + it('restricts API keys by allowed resources', () => { + svc.registerApiKeyScope( + 'key-2', + [{ resource: 'subscriptions', actions: ['read'] }], + { allowedResources: ['subscriptions'] } + ); + + expect(svc.checkApiKeyPermission('key-2', 'subscriptions', 'read')).toBe(true); + expect(svc.checkApiKeyPermission('key-2', 'analytics', 'read')).toBe(false); + }); + + it('returns null for unknown API keys', () => { + expect(svc.getApiKeyScope('nonexistent')).toBeNull(); + }); + }); + + describe('unauthorized access monitoring', () => { + it('records unauthorized access events', () => { + svc.assignRole('viewer-1', 'viewer', 'root-admin'); + expect(() => + svc.requirePermission('viewer-1', 'billing', 'delete') + ).toThrow(); + + const events = svc.getUnauthorizedEvents({ actorId: 'viewer-1' }); + expect(events.length).toBe(1); + expect(events[0].resource).toBe('billing'); + expect(events[0].action).toBe('delete'); + }); + + it('aggregates unauthorized access stats', () => { + svc.assignRole('viewer-1', 'viewer', 'root-admin'); + + for (let i = 0; i < 3; i++) { + try { + svc.requirePermission('viewer-1', 'billing', 'delete'); + } catch {} + } + + const stats = svc.getUnauthorizedAccessStats(); + expect(stats.total).toBe(3); + expect(stats.byActor['viewer-1']).toBe(3); + expect(stats.byResource['billing']).toBe(3); + }); + + it('resolves unauthorized events', () => { + svc.assignRole('viewer-1', 'viewer', 'root-admin'); + try { svc.requirePermission('viewer-1', 'billing', 'delete'); } catch {} + + const events = svc.getUnauthorizedEvents({ resolved: false }); + expect(events.length).toBe(1); + + svc.resolveUnauthorizedEvent(events[0].id); + expect(svc.getUnauthorizedEvents({ resolved: false }).length).toBe(0); + }); + }); + + describe('permission escalation prevention', () => { + it('prevents escalation via direct call', () => { + svc.assignRole('viewer-1', 'viewer', 'root-admin'); + expect(() => svc.preventEscalation('viewer-1', 'manager')).toThrow( + 'Permission escalation prevented' + ); + }); + + it('allows valid escalation check from higher role', () => { + expect(() => svc.preventEscalation('root-admin', 'viewer')).not.toThrow(); + }); + }); + + describe('audit integration', () => { + it('audits role assignments', () => { + svc.assignRole('user-1', 'manager', 'root-admin'); + const report = audit.generateReport(0, Date.now()); + expect(report.totalEvents).toBe(2); // bootstrap + assignment + const assignmentEvent = report.events.find( + e => e.actorId === 'root-admin' && e.resourceType === 'role_assignment' + ); + expect(assignmentEvent).toBeTruthy(); + expect(assignmentEvent!.metadata).toMatchObject({ role: 'manager' }); + }); + + it('audits role revocations', () => { + svc.assignRole('user-1', 'manager', 'root-admin'); + svc.revokeRole('user-1', 'root-admin'); + const report = audit.generateReport(0, Date.now()); + expect(report.totalEvents).toBe(3); // bootstrap + assign + revoke + }); + }); +}); diff --git a/backend/services/accessControl.ts b/backend/services/accessControl.ts new file mode 100644 index 00000000..1d5a4b1f --- /dev/null +++ b/backend/services/accessControl.ts @@ -0,0 +1,661 @@ +/** + * Access Control Service — fine-grained RBAC with role hierarchy, + * permission checking, API key scoping, temporary elevation, and + * unauthorized access monitoring. + */ + +import { randomUUID } from 'crypto'; +import { AuditService } from './auditService'; +import { AlertingService } from './alerting'; +import type { Alert } from './types'; + +// ─── Resource & Action Types ────────────────────────────────────────────────── + +export type Resource = + | 'subscriptions' + | 'plans' + | 'users' + | 'billing' + | 'analytics' + | 'settings' + | 'features' + | 'api_keys' + | 'audit_logs' + | 'webhooks' + | 'campaigns' + | 'support_tickets' + | 'fraud_rules'; + +export type Action = 'create' | 'read' | 'update' | 'delete' | 'manage'; + +export type Effect = 'allow' | 'deny'; + +export interface Permission { + resource: Resource; + actions: Action[]; + conditions?: Record; + effect?: Effect; +} + +// ─── Role Definitions ───────────────────────────────────────────────────────── + +export type Role = 'admin' | 'manager' | 'viewer'; + +/** + * Canonical role definitions. `manage` implies all lower actions. + * Admin inherits all, manager inherits viewer. + */ +export const ROLE_PERMISSIONS: Record = { + admin: [ + { resource: 'subscriptions', actions: ['create', 'read', 'update', 'delete', 'manage'] }, + { resource: 'plans', actions: ['create', 'read', 'update', 'delete', 'manage'] }, + { resource: 'users', actions: ['create', 'read', 'update', 'delete', 'manage'] }, + { resource: 'billing', actions: ['create', 'read', 'update', 'delete', 'manage'] }, + { resource: 'analytics', actions: ['create', 'read', 'update', 'delete', 'manage'] }, + { resource: 'settings', actions: ['create', 'read', 'update', 'delete', 'manage'] }, + { resource: 'features', actions: ['create', 'read', 'update', 'delete', 'manage'] }, + { resource: 'api_keys', actions: ['create', 'read', 'update', 'delete', 'manage'] }, + { resource: 'audit_logs', actions: ['read', 'manage'] }, + { resource: 'webhooks', actions: ['create', 'read', 'update', 'delete', 'manage'] }, + { resource: 'campaigns', actions: ['create', 'read', 'update', 'delete', 'manage'] }, + { resource: 'support_tickets', actions: ['create', 'read', 'update', 'delete', 'manage'] }, + { resource: 'fraud_rules', actions: ['create', 'read', 'update', 'delete', 'manage'] }, + ], + manager: [ + { resource: 'subscriptions', actions: ['create', 'read', 'update'] }, + { resource: 'plans', actions: ['read', 'update'] }, + { resource: 'users', actions: ['read'] }, + { resource: 'billing', actions: ['read', 'update'] }, + { resource: 'analytics', actions: ['read'] }, + { resource: 'features', actions: ['read'] }, + { resource: 'api_keys', actions: ['read', 'create'] }, + { resource: 'webhooks', actions: ['read', 'create', 'update'] }, + { resource: 'campaigns', actions: ['read', 'create', 'update'] }, + { resource: 'support_tickets', actions: ['read', 'update'] }, + ], + viewer: [ + { resource: 'subscriptions', actions: ['read'] }, + { resource: 'plans', actions: ['read'] }, + { resource: 'users', actions: ['read'] }, + { resource: 'billing', actions: ['read'] }, + { resource: 'analytics', actions: ['read'] }, + { resource: 'features', actions: ['read'] }, + { resource: 'audit_logs', actions: ['read'] }, + { resource: 'webhooks', actions: ['read'] }, + { resource: 'campaigns', actions: ['read'] }, + { resource: 'support_tickets', actions: ['read'] }, + ], +}; + +/** + * Role hierarchy: admin > manager > viewer + * Higher roles inherit all permissions from lower roles. + */ +export const ROLE_HIERARCHY: Record = { + admin: 100, + manager: 50, + viewer: 10, +}; + +// ─── Temporary Elevation ────────────────────────────────────────────────────── + +export interface TemporaryElevation { + id: string; + userId: string; + elevatedRole: Role; + originalRole: Role; + grantedBy: string; + reason: string; + expiresAt: number; + active: boolean; +} + +// ─── API Key Scope ──────────────────────────────────────────────────────────── + +export interface ApiKeyScope { + apiKeyId: string; + permissions: Permission[]; + allowedResources?: Resource[]; + maxRatePerMinute?: number; +} + +// ─── Unauthorized Access Event ──────────────────────────────────────────────── + +export interface UnauthorizedAccessEvent { + id: string; + actorId: string; + actorRole: Role | null; + resource: Resource; + action: Action; + deniedAt: number; + reason: string; + ip?: string; + userAgent?: string; + resolved: boolean; +} + +// ─── Role Assignment ────────────────────────────────────────────────────────── + +export interface RoleAssignment { + userId: string; + role: Role; + assignedBy: string; + assignedAt: number; + expiresAt?: number; +} + +// ─── AccessControlService ───────────────────────────────────────────────────── + +export type AccessCheckOptions = { + allowElevated?: boolean; + requireAllActions?: boolean; +}; + +export class AccessControlService { + private assignments = new Map(); + private elevations = new Map(); + private apiKeyScopes = new Map(); + private unauthorizedEvents: UnauthorizedAccessEvent[] = []; + private auditService: AuditService; + private alertingService: AlertingService; + private readonly maxUnauthorizedEvents = 10_000; + + private bootstrapped = false; + + constructor(auditService: AuditService, alertingService: AlertingService) { + this.auditService = auditService; + this.alertingService = alertingService; + } + + /** + * Bootstrap the system by assigning the first admin. + * Only callable once — subsequent calls require normal role hierarchy checks. + */ + bootstrap(userId: string): RoleAssignment { + if (this.bootstrapped) { + throw new Error('System already bootstrapped'); + } + const assignment: RoleAssignment = { + userId, + role: 'admin', + assignedBy: 'system', + assignedAt: Date.now(), + }; + this.assignments.set(userId, assignment); + this.bootstrapped = true; + + this.auditService.capture( + 'admin.action', + 'system', + userId, + 'role_assignment', + { role: 'admin', bootstrap: true } + ); + + return assignment; + } + + // ─── Role Assignment ────────────────────────────────────────────────────── + + assignRole( + userId: string, + role: Role, + assignedBy: string, + expiresAt?: number + ): RoleAssignment { + if (!this.canAssignRole(assignedBy, role)) { + throw new Error(`Actor ${assignedBy} cannot assign role ${role}`); + } + + const assignment: RoleAssignment = { + userId, + role, + assignedBy, + assignedAt: Date.now(), + expiresAt, + }; + + this.assignments.set(userId, assignment); + + this.auditService.capture( + 'admin.action', + assignedBy, + userId, + 'role_assignment', + { role, expiresAt } + ); + + return assignment; + } + + revokeRole(userId: string, revokedBy: string): void { + const existing = this.assignments.get(userId); + if (!existing) { + throw new Error(`No role assignment found for user ${userId}`); + } + + this.assignments.delete(userId); + + this.auditService.capture( + 'admin.action', + revokedBy, + userId, + 'role_revocation', + { previousRole: existing.role } + ); + } + + getAssignment(userId: string): RoleAssignment | null { + const assignment = this.assignments.get(userId); + if (!assignment) return null; + + if (assignment.expiresAt && Date.now() > assignment.expiresAt) { + this.assignments.delete(userId); + return null; + } + + return assignment; + } + + getUserRole(userId: string): Role { + const assignment = this.getAssignment(userId); + if (!assignment) return 'viewer'; + + const elevation = this.getActiveElevation(userId); + if (elevation) { + return elevation.elevatedRole; + } + + return assignment.role; + } + + private canAssignRole(actorId: string, targetRole: Role): boolean { + const actorRole = this.getUserRole(actorId); + const actorLevel = ROLE_HIERARCHY[actorRole] ?? 0; + const targetLevel = ROLE_HIERARCHY[targetRole] ?? 0; + + return actorLevel >= targetLevel; + } + + getAllAssignments(): RoleAssignment[] { + return Array.from(this.assignments.values()); + } + + // ─── Temporary Elevation ────────────────────────────────────────────────── + + grantTemporaryElevation( + userId: string, + elevatedRole: Role, + grantedBy: string, + durationMs: number, + reason: string + ): TemporaryElevation { + const actorRole = this.getUserRole(grantedBy); + const actorLevel = ROLE_HIERARCHY[actorRole] ?? 0; + const targetLevel = ROLE_HIERARCHY[elevatedRole] ?? 0; + + if (actorLevel <= targetLevel) { + throw new Error( + `Actor with role ${actorRole} cannot elevate to ${elevatedRole}` + ); + } + + const existing = this.getAssignment(userId); + if (!existing) { + throw new Error(`User ${userId} has no role assignment`); + } + + const elevation: TemporaryElevation = { + id: randomUUID(), + userId, + elevatedRole, + originalRole: existing.role, + grantedBy, + reason, + expiresAt: Date.now() + durationMs, + active: true, + }; + + this.elevations.set(elevation.id, elevation); + + this.auditService.capture( + 'admin.action', + grantedBy, + userId, + 'temporary_elevation', + { + elevationId: elevation.id, + originalRole: elevation.originalRole, + elevatedRole, + durationMs, + reason, + } + ); + + return elevation; + } + + revokeElevation(elevationId: string, revokedBy: string): void { + const elevation = this.elevations.get(elevationId); + if (!elevation) { + throw new Error(`Elevation ${elevationId} not found`); + } + + elevation.active = false; + + this.auditService.capture( + 'admin.action', + revokedBy, + elevation.userId, + 'elevation_revocation', + { + elevationId, + originalRole: elevation.originalRole, + elevatedRole: elevation.elevatedRole, + } + ); + } + + getActiveElevation(userId: string): TemporaryElevation | null { + const elevations = Array.from(this.elevations.values()); + for (const elevation of elevations) { + if ( + elevation.userId === userId && + elevation.active && + Date.now() < elevation.expiresAt + ) { + return elevation; + } + } + return null; + } + + getElevationsForUser(userId: string): TemporaryElevation[] { + return Array.from(this.elevations.values()) + .filter((e) => e.userId === userId); + } + + // ─── Permission Checking ────────────────────────────────────────────────── + + hasPermission( + userId: string, + resource: Resource, + action: Action, + options?: AccessCheckOptions + ): boolean { + const role = this.getUserRole(userId); + const permissions = ROLE_PERMISSIONS[role]; + + if (!permissions) return false; + + if (options?.allowElevated === false) { + const elevation = this.getActiveElevation(userId); + if (elevation) { + const originalPermissions = ROLE_PERMISSIONS[elevation.originalRole]; + return this.checkPermissions(originalPermissions, resource, action, options); + } + } + + return this.checkPermissions(permissions, resource, action, options); + } + + requirePermission( + userId: string, + resource: Resource, + action: Action, + options?: AccessCheckOptions + ): void { + const hasAccess = this.hasPermission(userId, resource, action, options); + if (!hasAccess) { + const role = this.getUserRole(userId); + this.recordUnauthorizedAccess(userId, role, resource, action, 'Insufficient permissions'); + throw new AccessDeniedError(userId, resource, action, role); + } + } + + private checkPermissions( + permissions: Permission[], + resource: Resource, + action: Action, + options?: AccessCheckOptions + ): boolean { + const resourcePerms = permissions.filter( + (p) => p.resource === resource && p.effect !== 'deny' + ); + + if (resourcePerms.length === 0) return false; + + const allowedActions = new Set(); + for (const perm of resourcePerms) { + if (perm.actions.includes('manage')) { + return true; + } + for (const a of perm.actions) { + allowedActions.add(a); + } + } + + if (options?.requireAllActions) { + return allowedActions.has(action); + } + + return allowedActions.has(action); + } + + // ─── API Key Scoping ────────────────────────────────────────────────────── + + registerApiKeyScope( + apiKeyId: string, + permissions: Permission[], + options?: { allowedResources?: Resource[]; maxRatePerMinute?: number } + ): ApiKeyScope { + const scope: ApiKeyScope = { + apiKeyId, + permissions, + allowedResources: options?.allowedResources, + maxRatePerMinute: options?.maxRatePerMinute, + }; + + this.apiKeyScopes.set(apiKeyId, scope); + return scope; + } + + updateApiKeyScope( + apiKeyId: string, + updates: Partial> + ): ApiKeyScope | null { + const existing = this.apiKeyScopes.get(apiKeyId); + if (!existing) return null; + + const updated: ApiKeyScope = { ...existing, ...updates }; + this.apiKeyScopes.set(apiKeyId, updated); + return updated; + } + + getApiKeyScope(apiKeyId: string): ApiKeyScope | null { + return this.apiKeyScopes.get(apiKeyId) ?? null; + } + + removeApiKeyScope(apiKeyId: string): void { + this.apiKeyScopes.delete(apiKeyId); + } + + checkApiKeyPermission( + apiKeyId: string, + resource: Resource, + action: Action + ): boolean { + const scope = this.apiKeyScopes.get(apiKeyId); + if (!scope) return false; + + if (scope.allowedResources && !scope.allowedResources.includes(resource)) { + return false; + } + + return this.checkPermissions(scope.permissions, resource, action); + } + + getAllApiKeyScopes(): ApiKeyScope[] { + return Array.from(this.apiKeyScopes.values()); + } + + // ─── Unauthorized Access Monitoring ─────────────────────────────────────── + + private recordUnauthorizedAccess( + actorId: string, + actorRole: Role | null, + resource: Resource, + action: Action, + reason: string, + meta?: { ip?: string; userAgent?: string } + ): void { + const event: UnauthorizedAccessEvent = { + id: randomUUID(), + actorId, + actorRole, + resource, + action, + deniedAt: Date.now(), + reason, + ip: meta?.ip, + userAgent: meta?.userAgent, + resolved: false, + }; + + this.unauthorizedEvents.push(event); + + if (this.unauthorizedEvents.length > this.maxUnauthorizedEvents) { + this.unauthorizedEvents = this.unauthorizedEvents.slice(-5000); + } + + this.auditService.capture( + 'admin.action', + actorId, + resource, + 'access_denied', + { action, reason, ip: meta?.ip } + ); + + const recentFromActor = this.unauthorizedEvents.filter( + (e) => e.actorId === actorId && !e.resolved + ).length; + + if (recentFromActor >= 5) { + const alert: Alert = { + id: `unauthorized-burst-${actorId}-${Date.now()}`, + severity: 'warning', + title: 'Repeated unauthorized access attempts', + message: `User ${actorId} has ${recentFromActor} denied access events. Possible permission escalation attempt.`, + timestamp: Date.now(), + resolved: false, + ruleId: 'unauthorized-access-burst', + }; + this.alertingService.dispatch(alert); + } + } + + getUnauthorizedEvents(filter?: { + actorId?: string; + resolved?: boolean; + since?: number; + }): UnauthorizedAccessEvent[] { + return this.unauthorizedEvents.filter((e) => { + if (filter?.actorId && e.actorId !== filter.actorId) return false; + if (filter?.resolved !== undefined && e.resolved !== filter.resolved) return false; + if (filter?.since && e.deniedAt < filter.since) return false; + return true; + }); + } + + resolveUnauthorizedEvent(eventId: string): void { + const event = this.unauthorizedEvents.find((e) => e.id === eventId); + if (event) { + event.resolved = true; + } + } + + getUnauthorizedAccessStats(): { + total: number; + unresolved: number; + byActor: Record; + byResource: Record; + } { + const stats = { + total: this.unauthorizedEvents.length, + unresolved: this.unauthorizedEvents.filter((e) => !e.resolved).length, + byActor: {} as Record, + byResource: {} as Record, + }; + + for (const e of this.unauthorizedEvents) { + stats.byActor[e.actorId] = (stats.byActor[e.actorId] ?? 0) + 1; + stats.byResource[e.resource] = (stats.byResource[e.resource] ?? 0) + 1; + } + + return stats; + } + + // ─── Role Hierarchy Complexity Guard ────────────────────────────────────── + + validateRoleHierarchy(): boolean { + const roles = Object.keys(ROLE_HIERARCHY) as Role[]; + for (let i = 0; i < roles.length; i++) { + for (let j = i + 1; j < roles.length; j++) { + if (ROLE_HIERARCHY[roles[i]] === ROLE_HIERARCHY[roles[j]]) { + return false; + } + } + } + return true; + } + + /** + * Prevent permission escalation: ensure no user can assign a role + * equal to or higher than their own. + */ + preventEscalation(actorId: string, targetRole: Role): void { + const actorRole = this.getUserRole(actorId); + const actorLevel = ROLE_HIERARCHY[actorRole] ?? 0; + const targetLevel = ROLE_HIERARCHY[targetRole] ?? 0; + + if (actorLevel <= targetLevel) { + this.recordUnauthorizedAccess( + actorId, + actorRole, + 'users', + 'manage', + `Permission escalation prevention: ${actorRole} cannot assign ${targetRole}` + ); + throw new PermissionEscalationError(actorId, actorRole, targetRole); + } + } +} + +// ─── Custom Errors ──────────────────────────────────────────────────────────── + +export class AccessDeniedError extends Error { + constructor( + public readonly userId: string, + public readonly resource: Resource, + public readonly action: Action, + public readonly role: Role | null + ) { + super( + `Access denied: user ${userId} (role: ${role ?? 'none'}) cannot ${action} ${resource}` + ); + this.name = 'AccessDeniedError'; + } +} + +export class PermissionEscalationError extends Error { + constructor( + public readonly actorId: string, + public readonly actorRole: Role, + public readonly targetRole: Role + ) { + super( + `Permission escalation prevented: ${actorRole} cannot assign ${targetRole}` + ); + this.name = 'PermissionEscalationError'; + } +} diff --git a/backend/services/index.ts b/backend/services/index.ts index 608ce85c..eaa9ffbb 100644 --- a/backend/services/index.ts +++ b/backend/services/index.ts @@ -88,3 +88,18 @@ export type { ABTestAssignment, FeatureId, } from '../../src/types/feature'; + +// ── Access Control / RBAC (Issue #420) ──────────────────────────────────────── +export { AccessControlService, AccessDeniedError, PermissionEscalationError, ROLE_PERMISSIONS, ROLE_HIERARCHY } from './accessControl'; +export type { + Role, + Resource, + Action, + Permission, + Effect, + RoleAssignment, + TemporaryElevation, + ApiKeyScope, + UnauthorizedAccessEvent, + AccessCheckOptions, +} from './accessControl'; diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 00000000..f8ecce2e --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,188 @@ +# Role-Based Access Control (RBAC) — Permissions Reference + +## Overview + +SubTrackr uses a fine-grained RBAC system with three roles: **admin**, **manager**, and **viewer**. Each role carries a defined set of resource-level permissions that control which actions a user can perform. + +## Role Hierarchy + +``` +admin (100) → manager (50) → viewer (10) +``` + +Higher-ranked roles **inherit** all permissions from lower roles. An admin can do everything a manager or viewer can. + +| Role | Level | Description | +| ------- | ----- | ------------------------------------------------ | +| Admin | 100 | Full access to all resources and operations | +| Manager | 50 | Read/write on most resources, limited delete | +| Viewer | 10 | Read-only access to permitted resources | + +## Resources + +| Resource | Description | +| ----------------- | ----------------------------------------- | +| `subscriptions` | Subscription records and lifecycle | +| `plans` | Subscription plan definitions | +| `users` | User accounts and profiles | +| `billing` | Billing operations and invoices | +| `analytics` | Analytics data and reports | +| `settings` | Application and system settings | +| `features` | Feature flag management | +| `api_keys` | API key management | +| `audit_logs` | Audit log access | +| `webhooks` | Webhook configuration | +| `campaigns` | Marketing campaigns | +| `support_tickets` | Support ticket management | +| `fraud_rules` | Fraud detection rule configuration | + +## Actions + +| Action | Description | +| -------- | ------------------------------------------ | +| `create` | Create new resources | +| `read` | View existing resources | +| `update` | Modify existing resources | +| `delete` | Remove resources | +| `manage` | All actions including admin-level controls | + +## Permission Matrix + +| Resource | Admin | Manager | Viewer | +| ----------------- | -------------------- | ------------------------ | ------------ | +| subscriptions | create, read, update, delete, manage | create, read, update | read | +| plans | create, read, update, delete, manage | read, update | read | +| users | create, read, update, delete, manage | read | read | +| billing | create, read, update, delete, manage | read, update | read | +| analytics | create, read, update, delete, manage | read | read | +| settings | create, read, update, delete, manage | ✗ | ✗ | +| features | create, read, update, delete, manage | read | read | +| api_keys | create, read, update, delete, manage | read, create | ✗ | +| audit_logs | read, manage | ✗ | read | +| webhooks | create, read, update, delete, manage | read, create, update | read | +| campaigns | create, read, update, delete, manage | read, create, update | read | +| support_tickets | create, read, update, delete, manage | read, update | read | +| fraud_rules | create, read, update, delete, manage | ✗ | ✗ | + +## API + +### `AccessControlService` + +The service is available at `backend/services/accessControl.ts`. + +#### Constructor + +```ts +new AccessControlService(auditService: AuditService, alertingService: AlertingService) +``` + +Requires an `AuditService` instance for role change auditing and an `AlertingService` for unauthorized access alerts. + +#### Role Assignment + +```ts +assignRole(userId: string, role: Role, assignedBy: string, expiresAt?: number): RoleAssignment +revokeRole(userId: string, revokedBy: string): void +getAssignment(userId: string): RoleAssignment | null +getUserRole(userId: string): Role +getAllAssignments(): RoleAssignment[] +``` + +#### Permission Checking + +```ts +hasPermission(userId: string, resource: Resource, action: Action, options?: AccessCheckOptions): boolean +requirePermission(userId: string, resource: Resource, action: Action, options?: AccessCheckOptions): void +``` + +`requirePermission` throws `AccessDeniedError` if the user lacks the required permission. + +#### Temporary Elevation + +```ts +grantTemporaryElevation(userId: string, elevatedRole: Role, grantedBy: string, durationMs: number, reason: string): TemporaryElevation +revokeElevation(elevationId: string, revokedBy: string): void +getActiveElevation(userId: string): TemporaryElevation | null +``` + +Elevations are automatically expired after `durationMs`. Only users with a role higher than the target role can grant elevations. + +#### API Key Scoping + +```ts +registerApiKeyScope(apiKeyId: string, permissions: Permission[], options?: { allowedResources?: Resource[]; maxRatePerMinute?: number }): ApiKeyScope +checkApiKeyPermission(apiKeyId: string, resource: Resource, action: Action): boolean +updateApiKeyScope(apiKeyId: string, updates: Partial): ApiKeyScope | null +getApiKeyScope(apiKeyId: string): ApiKeyScope | null +removeApiKeyScope(apiKeyId: string): void +``` + +#### Unauthorized Access Monitoring + +```ts +getUnauthorizedEvents(filter?: { actorId?: string; resolved?: boolean; since?: number }): UnauthorizedAccessEvent[] +resolveUnauthorizedEvent(eventId: string): void +getUnauthorizedAccessStats(): { total: number; unresolved: number; byActor: Record; byResource: Record } +``` + +When 5+ unauthorized events are logged for the same actor, an alert is dispatched through the `AlertingService`. + +#### Escalation Prevention + +```ts +preventEscalation(actorId: string, targetRole: Role): void +``` + +Throws `PermissionEscalationError` if the actor tries to assign a role equal to or higher than their own. + +### Errors + +| Error | Description | +| ------------------------ | --------------------------------------------------- | +| `AccessDeniedError` | Thrown when `requirePermission` fails | +| `PermissionEscalationError` | Thrown when escalation prevention is triggered | + +## TypeScript Types + +All types are exported from `backend/services/accessControl.ts`: + +```ts +type Role = 'admin' | 'manager' | 'viewer' +type Resource = 'subscriptions' | 'plans' | 'users' | 'billing' | 'analytics' | 'settings' | 'features' | 'api_keys' | 'audit_logs' | 'webhooks' | 'campaigns' | 'support_tickets' | 'fraud_rules' +type Action = 'create' | 'read' | 'update' | 'delete' | 'manage' +``` + +## Usage Examples + +```ts +import { AccessControlService } from './backend/services/accessControl'; +import { AuditService } from './backend/services/auditService'; +import { AlertingService } from './backend/services/alerting'; + +const audit = new AuditService('hmac-secret'); +const alerting = new AlertingService(); +const acl = new AccessControlService(audit, alerting); + +// Assign roles +acl.assignRole('user-abc', 'manager', 'admin-xyz'); + +// Check permissions +if (acl.hasPermission('user-abc', 'subscriptions', 'create')) { + // allow operation +} + +// Enforce permissions (throws on denial) +acl.requirePermission('user-abc', 'billing', 'delete'); + +// Temporary elevation +acl.grantTemporaryElevation('user-abc', 'admin', 'admin-xyz', 3600000, 'Incident response'); + +// API key scoping +acl.registerApiKeyScope('key-123', [ + { resource: 'subscriptions', actions: ['read'] }, + { resource: 'analytics', actions: ['read'] }, +]); + +// Check unauthorized access +const events = acl.getUnauthorizedEvents({ actorId: 'user-abc' }); +``` diff --git a/package-lock.json b/package-lock.json index 2f55c0a4..06ee7cb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@react-navigation/native": "^6.1.9", "@react-navigation/native-stack": "^6.9.17", "@reown/appkit-ethers-react-native": "^1.3.0", + "@sentry/react-native": "^5.4.0", "@shopify/flash-list": "latest", "@stellar/stellar-sdk": "^12.0.0", "@superfluid-finance/sdk-core": "^0.9.0", @@ -26,6 +27,7 @@ "expo-application": "~6.1.5", "expo-clipboard": "~7.1.5", "expo-dev-client": "~5.2.4", + "expo-image": "~2.3.0", "expo-linking": "~7.1.7", "expo-notifications": "^0.31.5", "expo-status-bar": "~2.2.3", @@ -60,6 +62,7 @@ "@semantic-release/npm": "^12.0.2", "@semantic-release/release-notes-generator": "^14.1.0", "@size-limit/file": "^11.1.4", + "@testing-library/react-native": "13.3.3", "@typechain/ethers-v5": "^11.1.2", "@types/detox": "^17.14.3", "@types/jest": "^29.5.14", @@ -83,9 +86,7 @@ "size-limit": "^11.1.4", "ts-jest": "^29.4.11", "typechain": "^8.3.2", - "typescript": "~5.8.3", - "@testing-library/react-native": "13.3.3", - "react-test-renderer": "19.2.5" + "typescript": "~5.8.3" } }, "node_modules/@0no-co/graphql.web": { @@ -5845,21 +5846,6 @@ "@babel/core": "*" } }, - "node_modules/@react-native/codegen/node_modules/hermes-estree": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz", - "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==", - "license": "MIT" - }, - "node_modules/@react-native/codegen/node_modules/hermes-parser": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz", - "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==", - "license": "MIT", - "dependencies": { - "hermes-estree": "0.33.3" - } - }, "node_modules/@react-native/community-cli-plugin": { "version": "0.85.2", "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.85.2.tgz", @@ -8037,6 +8023,378 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@sentry-internal/feedback": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.119.1.tgz", + "integrity": "sha512-EPyW6EKZmhKpw/OQUPRkTynXecZdYl4uhZwdZuGqnGMAzswPOgQvFrkwsOuPYvoMfXqCH7YuRqyJrox3uBOrTA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@sentry-internal/feedback/node_modules/@sentry/core": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.119.1.tgz", + "integrity": "sha512-YUNnH7O7paVd+UmpArWCPH4Phlb5LwrkWVqzFWqL3xPyCcTSof2RL8UmvpkTjgYJjJ+NDfq5mPFkqv3aOEn5Sw==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry-internal/feedback/node_modules/@sentry/types": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.119.1.tgz", + "integrity": "sha512-4G2mcZNnYzK3pa2PuTq+M2GcwBRY/yy1rF+HfZU+LAPZr98nzq2X3+mJHNJoobeHRkvVh7YZMPi4ogXiIS5VNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry-internal/feedback/node_modules/@sentry/utils": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.119.1.tgz", + "integrity": "sha512-ju/Cvyeu/vkfC5/XBV30UNet5kLEicZmXSyuLwZu95hEbL+foPdxN+re7pCI/eNqfe3B2vz7lvz5afLVOlQ2Hg==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-7.119.1.tgz", + "integrity": "sha512-O/lrzENbMhP/UDr7LwmfOWTjD9PLNmdaCF408Wx8SDuj7Iwc+VasGfHg7fPH4Pdr4nJON6oh+UqoV4IoG05u+A==", + "license": "MIT", + "dependencies": { + "@sentry/core": "7.119.1", + "@sentry/replay": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@sentry-internal/replay-canvas/node_modules/@sentry/core": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.119.1.tgz", + "integrity": "sha512-YUNnH7O7paVd+UmpArWCPH4Phlb5LwrkWVqzFWqL3xPyCcTSof2RL8UmvpkTjgYJjJ+NDfq5mPFkqv3aOEn5Sw==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry-internal/replay-canvas/node_modules/@sentry/types": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.119.1.tgz", + "integrity": "sha512-4G2mcZNnYzK3pa2PuTq+M2GcwBRY/yy1rF+HfZU+LAPZr98nzq2X3+mJHNJoobeHRkvVh7YZMPi4ogXiIS5VNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry-internal/replay-canvas/node_modules/@sentry/utils": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.119.1.tgz", + "integrity": "sha512-ju/Cvyeu/vkfC5/XBV30UNet5kLEicZmXSyuLwZu95hEbL+foPdxN+re7pCI/eNqfe3B2vz7lvz5afLVOlQ2Hg==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry-internal/tracing": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.119.1.tgz", + "integrity": "sha512-cI0YraPd6qBwvUA3wQdPGTy8PzAoK0NZiaTN1LM3IczdPegehWOaEG5GVTnpGnTsmBAzn1xnBXNBhgiU4dgcrQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry-internal/tracing/node_modules/@sentry/core": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.119.1.tgz", + "integrity": "sha512-YUNnH7O7paVd+UmpArWCPH4Phlb5LwrkWVqzFWqL3xPyCcTSof2RL8UmvpkTjgYJjJ+NDfq5mPFkqv3aOEn5Sw==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry-internal/tracing/node_modules/@sentry/types": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.119.1.tgz", + "integrity": "sha512-4G2mcZNnYzK3pa2PuTq+M2GcwBRY/yy1rF+HfZU+LAPZr98nzq2X3+mJHNJoobeHRkvVh7YZMPi4ogXiIS5VNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry-internal/tracing/node_modules/@sentry/utils": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.119.1.tgz", + "integrity": "sha512-ju/Cvyeu/vkfC5/XBV30UNet5kLEicZmXSyuLwZu95hEbL+foPdxN+re7pCI/eNqfe3B2vz7lvz5afLVOlQ2Hg==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/babel-plugin-component-annotate": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-2.20.1.tgz", + "integrity": "sha512-4mhEwYTK00bIb5Y9UWIELVUfru587Vaeg0DQGswv4aIRHIiMKLyNqCEejaaybQ/fNChIZOKmvyqXk430YVd7Qg==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@sentry/browser": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.119.1.tgz", + "integrity": "sha512-aMwAnFU4iAPeLyZvqmOQaEDHt/Dkf8rpgYeJ0OEi50dmP6AjG+KIAMCXU7CYCCQDn70ITJo8QD5+KzCoZPYz0A==", + "license": "MIT", + "dependencies": { + "@sentry-internal/feedback": "7.119.1", + "@sentry-internal/replay-canvas": "7.119.1", + "@sentry-internal/tracing": "7.119.1", + "@sentry/core": "7.119.1", + "@sentry/integrations": "7.119.1", + "@sentry/replay": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/browser/node_modules/@sentry/core": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.119.1.tgz", + "integrity": "sha512-YUNnH7O7paVd+UmpArWCPH4Phlb5LwrkWVqzFWqL3xPyCcTSof2RL8UmvpkTjgYJjJ+NDfq5mPFkqv3aOEn5Sw==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/browser/node_modules/@sentry/integrations": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.119.1.tgz", + "integrity": "sha512-CGmLEPnaBqbUleVqrmGYjRjf5/OwjUXo57I9t0KKWViq81mWnYhaUhRZWFNoCNQHns+3+GPCOMvl0zlawt+evw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1", + "localforage": "^1.8.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/browser/node_modules/@sentry/types": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.119.1.tgz", + "integrity": "sha512-4G2mcZNnYzK3pa2PuTq+M2GcwBRY/yy1rF+HfZU+LAPZr98nzq2X3+mJHNJoobeHRkvVh7YZMPi4ogXiIS5VNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/browser/node_modules/@sentry/utils": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.119.1.tgz", + "integrity": "sha512-ju/Cvyeu/vkfC5/XBV30UNet5kLEicZmXSyuLwZu95hEbL+foPdxN+re7pCI/eNqfe3B2vz7lvz5afLVOlQ2Hg==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/cli": { + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.37.0.tgz", + "integrity": "sha512-fM3V4gZRJR/s8lafc3O07hhOYRnvkySdPkvL/0e0XW0r+xRwqIAgQ5ECbsZO16A5weUiXVSf03ztDL1FcmbJCQ==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.37.0", + "@sentry/cli-linux-arm": "2.37.0", + "@sentry/cli-linux-arm64": "2.37.0", + "@sentry/cli-linux-i686": "2.37.0", + "@sentry/cli-linux-x64": "2.37.0", + "@sentry/cli-win32-i686": "2.37.0", + "@sentry/cli-win32-x64": "2.37.0" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.37.0.tgz", + "integrity": "sha512-CsusyMvO0eCPSN7H+sKHXS1pf637PWbS4rZak/7giz/z31/6qiXmeMlcL3f9lLZKtFPJmXVFO9uprn1wbBVF8A==", + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.37.0.tgz", + "integrity": "sha512-Dz0qH4Yt+gGUgoVsqVt72oDj4VQynRF1QB1/Sr8g76Vbi+WxWZmUh0iFwivYVwWxdQGu/OQrE0tx946HToCRyA==", + "cpu": [ + "arm" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.37.0.tgz", + "integrity": "sha512-2vzUWHLZ3Ct5gpcIlfd/2Qsha+y9M8LXvbZE26VxzYrIkRoLAWcnClBv8m4XsHLMURYvz3J9QSZHMZHSO7kAzw==", + "cpu": [ + "arm64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.37.0.tgz", + "integrity": "sha512-MHRLGs4t/CQE1pG+mZBQixyWL6xDZfNalCjO8GMcTTbZFm44S3XRHfYJZNVCgdtnUP7b6OHGcu1v3SWE10LcwQ==", + "cpu": [ + "x86", + "ia32" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.37.0.tgz", + "integrity": "sha512-k76ClefKZaDNJZU/H3mGeR8uAzAGPzDRG/A7grzKfBeyhP3JW09L7Nz9IQcSjCK+xr399qLhM2HFCaPWQ6dlMw==", + "cpu": [ + "x64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.37.0.tgz", + "integrity": "sha512-FFyi5RNYQQkEg4GkP2f3BJcgQn0F4fjFDMiWkjCkftNPXQG+HFUEtrGsWr6mnHPdFouwbYg3tEPUWNxAoypvTw==", + "cpu": [ + "x86", + "ia32" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.37.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.37.0.tgz", + "integrity": "sha512-nSMj4OcfQmyL+Tu/jWCJwhKCXFsCZW1MUk6wjjQlRt9SDLfgeapaMlK1ZvT1eZv5ZH6bj3qJfefwj4U8160uOA==", + "cpu": [ + "x64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli/node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/@sentry/core": { "version": "5.30.0", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-5.30.0.tgz", @@ -8067,6 +8425,55 @@ "node": ">=6" } }, + "node_modules/@sentry/integrations": { + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.119.0.tgz", + "integrity": "sha512-OHShvtsRW0A+ZL/ZbMnMqDEtJddPasndjq+1aQXw40mN+zeP7At/V1yPZyFaURy86iX7Ucxw5BtmzuNy7hLyTA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "7.119.0", + "@sentry/types": "7.119.0", + "@sentry/utils": "7.119.0", + "localforage": "^1.8.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/integrations/node_modules/@sentry/core": { + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.119.0.tgz", + "integrity": "sha512-CS2kUv9rAJJEjiRat6wle3JATHypB0SyD7pt4cpX5y0dN5dZ1JrF57oLHRMnga9fxRivydHz7tMTuBhSSwhzjw==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.0", + "@sentry/utils": "7.119.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/integrations/node_modules/@sentry/types": { + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.119.0.tgz", + "integrity": "sha512-27qQbutDBPKGbuJHROxhIWc1i0HJaGLA90tjMu11wt0E4UNxXRX+UQl4Twu68v4EV3CPvQcEpQfgsViYcXmq+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/integrations/node_modules/@sentry/utils": { + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.119.0.tgz", + "integrity": "sha512-ZwyXexWn2ZIe2bBoYnXJVPc2esCSbKpdc6+0WJa8eutXfHq3FRKg4ohkfCBpfxljQGEfP1+kfin945lA21Ka+A==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@sentry/minimal": { "version": "5.30.0", "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-5.30.0.tgz", @@ -8101,6 +8508,220 @@ "node": ">=6" } }, + "node_modules/@sentry/react": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-7.119.1.tgz", + "integrity": "sha512-Bri314LnSVm16K3JATgn3Zsq6Uj3M/nIjdUb3nggBw0BMlFWMsyFjUCfmCio5d80KJK/lUjOIxRjzu79M6jOzQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "7.119.1", + "@sentry/core": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1", + "hoist-non-react-statics": "^3.3.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": "15.x || 16.x || 17.x || 18.x" + } + }, + "node_modules/@sentry/react-native": { + "version": "5.36.0", + "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-5.36.0.tgz", + "integrity": "sha512-MPTN5Wb6wEplIVydh2oXOdLJYqCAWKvncN5TBPN5OG8XdCsDqF7LyH2Sz+SK2T3hMPKESl3StAMhrrNSmHDbNg==", + "license": "MIT", + "dependencies": { + "@sentry/babel-plugin-component-annotate": "2.20.1", + "@sentry/browser": "7.119.1", + "@sentry/cli": "2.37.0", + "@sentry/core": "7.119.1", + "@sentry/hub": "7.119.0", + "@sentry/integrations": "7.119.0", + "@sentry/react": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" + }, + "bin": { + "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" + }, + "peerDependencies": { + "expo": ">=49.0.0", + "react": ">=17.0.0", + "react-native": ">=0.65.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/core": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.119.1.tgz", + "integrity": "sha512-YUNnH7O7paVd+UmpArWCPH4Phlb5LwrkWVqzFWqL3xPyCcTSof2RL8UmvpkTjgYJjJ+NDfq5mPFkqv3aOEn5Sw==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/hub": { + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-7.119.0.tgz", + "integrity": "sha512-183h5B/rZosLxpB+ZYOvFdHk0rwZbKskxqKFtcyPbDAfpCUgCass41UTqyxF6aH1qLgCRxX8GcLRF7frIa/SOg==", + "license": "MIT", + "dependencies": { + "@sentry/core": "7.119.0", + "@sentry/types": "7.119.0", + "@sentry/utils": "7.119.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/hub/node_modules/@sentry/core": { + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.119.0.tgz", + "integrity": "sha512-CS2kUv9rAJJEjiRat6wle3JATHypB0SyD7pt4cpX5y0dN5dZ1JrF57oLHRMnga9fxRivydHz7tMTuBhSSwhzjw==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.0", + "@sentry/utils": "7.119.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/hub/node_modules/@sentry/types": { + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.119.0.tgz", + "integrity": "sha512-27qQbutDBPKGbuJHROxhIWc1i0HJaGLA90tjMu11wt0E4UNxXRX+UQl4Twu68v4EV3CPvQcEpQfgsViYcXmq+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/hub/node_modules/@sentry/utils": { + "version": "7.119.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.119.0.tgz", + "integrity": "sha512-ZwyXexWn2ZIe2bBoYnXJVPc2esCSbKpdc6+0WJa8eutXfHq3FRKg4ohkfCBpfxljQGEfP1+kfin945lA21Ka+A==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/types": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.119.1.tgz", + "integrity": "sha512-4G2mcZNnYzK3pa2PuTq+M2GcwBRY/yy1rF+HfZU+LAPZr98nzq2X3+mJHNJoobeHRkvVh7YZMPi4ogXiIS5VNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/react-native/node_modules/@sentry/utils": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.119.1.tgz", + "integrity": "sha512-ju/Cvyeu/vkfC5/XBV30UNet5kLEicZmXSyuLwZu95hEbL+foPdxN+re7pCI/eNqfe3B2vz7lvz5afLVOlQ2Hg==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/react/node_modules/@sentry/core": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.119.1.tgz", + "integrity": "sha512-YUNnH7O7paVd+UmpArWCPH4Phlb5LwrkWVqzFWqL3xPyCcTSof2RL8UmvpkTjgYJjJ+NDfq5mPFkqv3aOEn5Sw==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/react/node_modules/@sentry/types": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.119.1.tgz", + "integrity": "sha512-4G2mcZNnYzK3pa2PuTq+M2GcwBRY/yy1rF+HfZU+LAPZr98nzq2X3+mJHNJoobeHRkvVh7YZMPi4ogXiIS5VNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/react/node_modules/@sentry/utils": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.119.1.tgz", + "integrity": "sha512-ju/Cvyeu/vkfC5/XBV30UNet5kLEicZmXSyuLwZu95hEbL+foPdxN+re7pCI/eNqfe3B2vz7lvz5afLVOlQ2Hg==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/replay": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.119.1.tgz", + "integrity": "sha512-4da+ruMEipuAZf35Ybt2StBdV1S+oJbSVccGpnl9w6RoeQoloT4ztR6ML3UcFDTXeTPT1FnHWDCyOfST0O7XMw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/tracing": "7.119.1", + "@sentry/core": "7.119.1", + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@sentry/replay/node_modules/@sentry/core": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.119.1.tgz", + "integrity": "sha512-YUNnH7O7paVd+UmpArWCPH4Phlb5LwrkWVqzFWqL3xPyCcTSof2RL8UmvpkTjgYJjJ+NDfq5mPFkqv3aOEn5Sw==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.1", + "@sentry/utils": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/replay/node_modules/@sentry/types": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.119.1.tgz", + "integrity": "sha512-4G2mcZNnYzK3pa2PuTq+M2GcwBRY/yy1rF+HfZU+LAPZr98nzq2X3+mJHNJoobeHRkvVh7YZMPi4ogXiIS5VNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/replay/node_modules/@sentry/utils": { + "version": "7.119.1", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.119.1.tgz", + "integrity": "sha512-ju/Cvyeu/vkfC5/XBV30UNet5kLEicZmXSyuLwZu95hEbL+foPdxN+re7pCI/eNqfe3B2vz7lvz5afLVOlQ2Hg==", + "license": "MIT", + "dependencies": { + "@sentry/types": "7.119.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@sentry/tracing": { "version": "5.30.0", "resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-5.30.0.tgz", @@ -8443,6 +9064,124 @@ } } }, + "node_modules/@testing-library/react-native": { + "version": "13.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz", + "integrity": "sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-matcher-utils": "^30.0.5", + "picocolors": "^1.1.1", + "pretty-format": "^30.0.5", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "jest": ">=29.0.0", + "react": ">=18.2.0", + "react-native": ">=0.71", + "react-test-renderer": ">=18.2.0" + }, + "peerDependenciesMeta": { + "jest": { + "optional": true + } + } + }, + "node_modules/@testing-library/react-native/node_modules/@jest/diff-sequences": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@testing-library/react-native/node_modules/@jest/schemas": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@testing-library/react-native/node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react-native/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/react-native/node_modules/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@testing-library/react-native/node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@testing-library/react-native/node_modules/pretty-format": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.4.1", + "ansi-styles": "^5.2.0", + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -17069,6 +17808,23 @@ "react": "*" } }, + "node_modules/expo-image": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/expo-image/-/expo-image-2.3.2.tgz", + "integrity": "sha512-TOp7UR1mzeCxzs3c/6MV2Wy7jBfJpKq8aVC06gkLfxHsCVMeGqCXc+6GMrGIVrjG938LEub4dwnrE0OuSE2Qwg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-json-utils": { "version": "0.15.0", "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-0.15.0.tgz", @@ -19434,6 +20190,12 @@ "node": ">=16.x" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immutable": { "version": "4.3.8", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", @@ -22810,6 +23572,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lighthouse-logger": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", @@ -23298,6 +24069,15 @@ "node": ">=0.10.0" } }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -24676,6 +25456,16 @@ "dom-walk": "^0.1.0" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -30106,6 +30896,22 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-is-18": { + "name": "react-is", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "dev": true, + "license": "MIT" + }, "node_modules/react-native": { "version": "0.85.2", "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.85.2.tgz", @@ -30485,15 +31291,6 @@ "react-native": "*" } }, - "node_modules/react-native/node_modules/babel-plugin-syntax-hermes-parser": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz", - "integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==", - "license": "MIT", - "dependencies": { - "hermes-parser": "0.33.3" - } - }, "node_modules/react-native/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -30503,21 +31300,6 @@ "node": ">=18" } }, - "node_modules/react-native/node_modules/hermes-estree": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz", - "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==", - "license": "MIT" - }, - "node_modules/react-native/node_modules/hermes-parser": { - "version": "0.33.3", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz", - "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==", - "license": "MIT", - "dependencies": { - "hermes-estree": "0.33.3" - } - }, "node_modules/react-native/node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -30802,6 +31584,33 @@ "node": ">= 12.13.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redent/node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reduce-flatten": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/reduce-flatten/-/reduce-flatten-2.0.0.tgz", @@ -37185,206 +37994,6 @@ "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } - }, - "node_modules/@testing-library/react-native": { - "version": "13.3.3", - "resolved": "https://registry.npmjs.org/@testing-library/react-native/-/react-native-13.3.3.tgz", - "integrity": "sha512-k6Mjsd9dbZgvY4Bl7P1NIpePQNi+dfYtlJ5voi9KQlynxSyQkfOgJmYGCYmw/aSgH/rUcFvG8u5gd4npzgRDyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-matcher-utils": "^30.0.5", - "picocolors": "^1.1.1", - "pretty-format": "^30.0.5", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "jest": ">=29.0.0", - "react": ">=18.2.0", - "react-native": ">=0.71", - "react-test-renderer": ">=18.2.0" - }, - "peerDependenciesMeta": { - "jest": { - "optional": true - } - } - }, - "node_modules/@testing-library/react-native/node_modules/@jest/diff-sequences": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", - "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@testing-library/react-native/node_modules/@jest/schemas": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", - "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@testing-library/react-native/node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react-native/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@testing-library/react-native/node_modules/jest-diff": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", - "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/diff-sequences": "30.4.0", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@testing-library/react-native/node_modules/jest-matcher-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", - "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "jest-diff": "30.4.1", - "pretty-format": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@testing-library/react-native/node_modules/pretty-format": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", - "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.4.1", - "ansi-styles": "^5.2.0", - "react-is-18": "npm:react-is@^18.3.1", - "react-is-19": "npm:react-is@^19.2.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/react-is-18": { - "name": "react-is", - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/react-is-19": { - "name": "react-is", - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", - "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", - "dev": true, - "license": "MIT" - }, - "node_modules/react-test-renderer": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.2.5.tgz", - "integrity": "sha512-kwViRpdISMTpcpy5B6TSewfJzRjnajihRaj57ZmOWKD+SPN6k9LUM13O0pfOuW8ir6B6OOiAXwCRqOoVxRNykA==", - "dev": true, - "license": "MIT", - "dependencies": { - "react-is": "^19.2.5", - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.5" - } - }, - "node_modules/react-test-renderer/node_modules/react-is": { - "name": "react-is", - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", - "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", - "dev": true, - "license": "MIT" - }, - "node_modules/react-test-renderer/node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/redent/node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } } } } diff --git a/src/components/admin/FeatureManagement.tsx b/src/components/admin/FeatureManagement.tsx index 523d3733..80e60e10 100644 --- a/src/components/admin/FeatureManagement.tsx +++ b/src/components/admin/FeatureManagement.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { View, Text, @@ -16,28 +16,98 @@ import { colors, spacing, typography, borderRadius, shadows } from '../../utils/ interface FeatureManagementProps { onFeatureUpdate?: (featureId: FeatureId, updates: Partial) => void; + userRole?: 'admin' | 'manager' | 'viewer'; + currentUserId?: string; } /** - * Administrative component for managing feature flags + * Administrative component for managing feature flags with comprehensive RBAC checks */ -export const FeatureManagement: React.FC = ({ onFeatureUpdate }) => { +export const FeatureManagement: React.FC = ({ + onFeatureUpdate, + userRole = 'viewer', + currentUserId, +}) => { const [editingFeature, setEditingFeature] = useState(null); const [rolloutPercentage, setRolloutPercentage] = useState(''); + // ── Role & Temporary Elevation State ─────────────────────────────────────── + const [baseRole, setBaseRole] = useState<'admin' | 'manager' | 'viewer'>(userRole); + const [elevationRole, setElevationRole] = useState<'admin' | 'manager' | null>(null); + const [elevationTime, setElevationTime] = useState(0); + const [recentAuditLog, setRecentAuditLog] = useState(null); + + const actor = currentUserId || 'system_actor'; + + // Sync prop changes + useEffect(() => { + setBaseRole(userRole); + }, [userRole]); + + // Compute effective role + const effectiveRole = elevationRole || baseRole; + const isEditable = effectiveRole === 'admin' || effectiveRole === 'manager'; + + // Elevation countdown timer + useEffect(() => { + if (elevationTime <= 0) { + if (elevationRole) { + setElevationRole(null); + setRecentAuditLog( + `role.revoked: Temporary elevation lease expired. Reverted to Viewer for ${actor}.` + ); + Alert.alert( + 'Elevation Expired', + 'Your temporary privileges have expired and your session has reverted to Viewer.' + ); + } + return; + } + + const timer = setInterval(() => { + setElevationTime((prev) => prev - 1); + }, 1000); + + return () => clearInterval(timer); + }, [elevationTime, elevationRole, actor]); + const features = useMemo(() => { return featureFlagsService.getAllFeatures(); }, []); + // ── Interactive UI Action Triggers with RBAC Assertion ──────────────────── + const handleFeatureToggle = (featureId: FeatureId, enabled: boolean) => { + if (!isEditable) { + Alert.alert( + 'Access Denied', + 'Unauthorized attempt. Viewers do not have permission to modify feature flags.\n\nPlease use the Temporary Elevation panel above to request access.', + [{ text: 'OK', style: 'cancel' }] + ); + return; + } + const feature = features[featureId]; if (feature) { const updatedFeature = { ...feature, enabled }; onFeatureUpdate?.(featureId, updatedFeature); + // Log audit locally for demonstration + setRecentAuditLog( + `feature.updated: Toggled "${feature.name}" to ${enabled ? 'ON' : 'OFF'} by [${effectiveRole.toUpperCase()}] (${actor})` + ); } }; const handleRolloutUpdate = (featureId: FeatureId) => { + if (!isEditable) { + Alert.alert( + 'Access Denied', + 'Unauthorized attempt. Viewers do not have permission to modify rollout stages.', + [{ text: 'OK', style: 'cancel' }] + ); + return; + } + const percentage = parseInt(rolloutPercentage); if (isNaN(percentage) || percentage < 0 || percentage > 100) { Alert.alert('Invalid Input', 'Rollout percentage must be between 0 and 100'); @@ -50,9 +120,24 @@ export const FeatureManagement: React.FC = ({ onFeatureU onFeatureUpdate?.(featureId, updatedFeature); setEditingFeature(null); setRolloutPercentage(''); + setRecentAuditLog( + `feature.updated: Set rollout of "${feature.name}" to ${percentage}% by [${effectiveRole.toUpperCase()}] (${actor})` + ); } }; + const triggerElevation = (role: 'admin' | 'manager') => { + setElevationRole(role); + setElevationTime(60); // 60 seconds elevation + setRecentAuditLog( + `role.elevated: Simulated user ${actor} elevated to ${role.toUpperCase()} (1m lease). Audit chain entry appended.` + ); + Alert.alert( + 'Elevation Approved', + `You have been granted temporary ${role.toUpperCase()} privileges for 60 seconds.\n\nAll edit actions are now unlocked.` + ); + }; + const getTierColor = (tier: SubscriptionTier) => { switch (tier) { case SubscriptionTier.FREE: @@ -72,7 +157,7 @@ export const FeatureManagement: React.FC = ({ onFeatureU const isEditing = editingFeature === featureId; return ( - + {feature.name} @@ -81,8 +166,13 @@ export const FeatureManagement: React.FC = ({ onFeatureU handleFeatureToggle(featureId, enabled)} - trackColor={{ false: colors.surface, true: colors.primary }} - thumbColor={feature.enabled ? colors.surface : colors.textSecondary} + trackColor={{ + false: colors.surface, + true: isEditable ? colors.primary : colors.textSecondary, + }} + thumbColor={ + feature.enabled ? (isEditable ? colors.surface : colors.border) : colors.textSecondary + } /> @@ -102,7 +192,7 @@ export const FeatureManagement: React.FC = ({ onFeatureU Rollout: - {isEditing ? ( + {isEditing && isEditable ? ( = ({ onFeatureU { + if (!isEditable) { + Alert.alert( + 'Access Denied', + 'Unauthorized attempt. Viewers cannot modify rollout stages.' + ); + return; + } setEditingFeature(featureId); setRolloutPercentage(`${feature.rolloutPercentage || 100}`); }}> - {feature.rolloutPercentage || 100}% - Tap to edit + + {feature.rolloutPercentage || 100}% + + {isEditable && Tap to edit} )} @@ -159,6 +258,55 @@ export const FeatureManagement: React.FC = ({ onFeatureU return ( + {/* ── Premium Glassmorphism Elevation & Security Banner ──────────────── */} + + + + 🛡️ Security Role:{' '} + {effectiveRole.toUpperCase()} + + {elevationRole ? ( + + + ⚠️ Temporary Elevation Active! Unlocked editing access. + + + ⏳ {elevationTime}s remaining + + + ) : ( + + {baseRole === 'viewer' + ? `You have view-only access as ${actor}. Modify actions are gated by RBAC.` + : `Full manager and edit operations enabled for ${actor}.`} + + )} + + {!elevationRole && baseRole === 'viewer' && ( + + triggerElevation('manager')}> + Elevate to Manager (1m) + + triggerElevation('admin')}> + Elevate to Admin (1m) + + + )} + + + + {/* ── Simulated Audit Logs Visual Console ────────────────────────────── */} + {recentAuditLog && ( + + Tamper-Evident Audit Feed (Live): + ⚡ {recentAuditLog} + + )} + Feature Management @@ -204,6 +352,10 @@ const styles = StyleSheet.create({ marginBottom: spacing.md, ...shadows.sm, }, + featureCardDisabled: { + opacity: 0.85, + backgroundColor: '#fafafa', + }, featureHeader: { flexDirection: 'row', justifyContent: 'space-between', @@ -318,4 +470,101 @@ const styles = StyleSheet.create({ ...typography.body, color: colors.primary, }, + // ── Premium Elevation Panel Styles ────────────────────────────────────────── + glassContainer: { + margin: spacing.lg, + marginBottom: 0, + borderRadius: borderRadius.lg, + overflow: 'hidden', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.4)', + backgroundColor: 'rgba(240, 244, 255, 0.75)', + ...shadows.md, + }, + glassGradient: { + padding: spacing.lg, + }, + glassTitle: { + ...typography.h3, + color: '#0f172a', + marginBottom: spacing.xs, + }, + roleHighlight: { + fontWeight: '800', + color: '#4f46e5', + }, + glassSubtitle: { + ...typography.body, + color: '#475569', + fontSize: 13, + marginBottom: spacing.md, + }, + countdownContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + }, + timerBadge: { + backgroundColor: '#fee2e2', + borderColor: '#ef4444', + borderWidth: 1, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: borderRadius.sm, + }, + timerText: { + color: '#b91c1c', + fontWeight: '700', + fontSize: 12, + }, + elevationButtons: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + elevateButtonManager: { + flex: 1, + backgroundColor: '#3b82f6', + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + marginRight: spacing.xs, + alignItems: 'center', + justifyContent: 'center', + ...shadows.sm, + }, + elevateButtonAdmin: { + flex: 1, + backgroundColor: '#8b5cf6', + paddingVertical: spacing.sm, + borderRadius: borderRadius.md, + marginLeft: spacing.xs, + alignItems: 'center', + justifyContent: 'center', + ...shadows.sm, + }, + elevationButtonText: { + color: '#ffffff', + fontSize: 12, + fontWeight: '700', + }, + auditConsole: { + marginHorizontal: spacing.lg, + marginTop: spacing.md, + padding: spacing.md, + backgroundColor: '#0f172a', + borderRadius: borderRadius.md, + borderLeftWidth: 4, + borderLeftColor: '#10b981', + }, + auditConsoleTitle: { + color: '#94a3b8', + fontSize: 11, + fontWeight: '600', + marginBottom: 4, + }, + auditConsoleLogs: { + color: '#34d399', + fontSize: 11, + fontFamily: 'monospace', + }, });