diff --git a/lib/src/screens/pipeline_detail/controller_pipeline_detail.dart b/lib/src/screens/pipeline_detail/controller_pipeline_detail.dart index 03f8ee31..80fa0eb6 100644 --- a/lib/src/screens/pipeline_detail/controller_pipeline_detail.dart +++ b/lib/src/screens/pipeline_detail/controller_pipeline_detail.dart @@ -11,6 +11,9 @@ class _PipelineDetailController with ShareMixin, AdsMixin, ApiErrorHelper { final pipeStages = ValueNotifier?>(null); + /// Graph descriptors identifying the current user as an approver (own descriptor + groups). + Set _approverDescriptors = {}; + Timer? _timer; Pipeline get pipeline => buildDetail.value!.data!.pipeline; @@ -60,6 +63,11 @@ class _PipelineDetailController with ShareMixin, AdsMixin, ApiErrorHelper { final approvals = await api.getPipelineApprovals(pipeline: res.data!.pipeline); res.data!.pipeline.approvals = approvals.data ?? []; + if (approvals.data?.isNotEmpty ?? false) { + final descriptors = await api.getCurrentUserApproverDescriptors(); + _approverDescriptors = descriptors.data ?? {}; + } + buildDetail.value = res; final realLogs = res.data!.timeline.where((r) => r.order < 1000); @@ -262,21 +270,21 @@ class _PipelineDetailController with ShareMixin, AdsMixin, ApiErrorHelper { } bool _canApprove(Approval approval) { - final pendingStep = approval.steps.firstWhereOrNull((s) => s.isPending); - if (pendingStep == null) return false; - - final userEmail = api.user!.emailAddress; + if (_isBlockedApprover(approval)) return false; - return !_isBlockedApprover(approval) && (pendingStep.assignedApprover.uniqueName == userEmail); + return approval.steps.any((s) => s.isPending && _isCurrentUser(s.assignedApprover)); } - bool _isBlockedApprover(Approval approval) { - final pendingStep = approval.steps.firstWhereOrNull((s) => s.isPending); - if (pendingStep == null) return false; + bool _isBlockedApprover(Approval approval) => approval.blockedApprovers.any(_isCurrentUser); - final userEmail = api.user!.emailAddress; + /// Whether [approver] represents the current user, either directly or via a group membership. + bool _isCurrentUser(AssignedApprover approver) { + final user = api.user!; + final email = user.emailAddress?.toLowerCase(); - return approval.blockedApprovers.map((a) => a.uniqueName).contains(userEmail); + return (email != null && approver.uniqueName.toLowerCase() == email) || + (approver.id.isNotEmpty && approver.id == user.id) || + (approver.descriptor.isNotEmpty && _approverDescriptors.contains(approver.descriptor)); } Future _approveApproval(Approval approval) async { diff --git a/lib/src/services/azure_api_service.dart b/lib/src/services/azure_api_service.dart index d6105b46..3f6886f1 100644 --- a/lib/src/services/azure_api_service.dart +++ b/lib/src/services/azure_api_service.dart @@ -260,6 +260,10 @@ abstract class AzureApiService { Future> rejectPipelineApproval({required Approval approval, required String projectId}); + /// Returns the graph descriptors that identify the current user as an approver: + /// the user's own descriptor plus every group the user belongs to (transitively). + Future>> getCurrentUserApproverDescriptors(); + Future> getPipeline({required String projectName, required int id}); Future> getPipelineTaskLogs({ @@ -418,6 +422,10 @@ class AzureApiServiceImpl with AppLogger implements AzureApiService { List get allUsers => _allUsers; List _allUsers = []; + /// Cached set of graph descriptors identifying the current user as an approver + /// (own descriptor + all groups they belong to). Computed at most once per session. + Set? _userApproverDescriptors; + @override bool get isImageUnauthorized => _isImageUnauthorized; bool _isImageUnauthorized = false; @@ -2471,6 +2479,37 @@ class AzureApiServiceImpl with AppLogger implements AzureApiService { return ApiResponse.ok(user); } + @override + Future>> getCurrentUserApproverDescriptors() async { + if (_userApproverDescriptors != null) return ApiResponse.ok(_userApproverDescriptors!); + + if (_allUsers.isEmpty) await _getUsers(); + + final email = user?.emailAddress; + final descriptor = _allUsers.firstWhereOrNull((u) => u.mailAddress == email)?.descriptor; + if (descriptor == null) return ApiResponse.ok({}); + + final descriptors = {descriptor}; + // Walk the membership graph upwards to collect every (transitive) group the user belongs to. + final toVisit = [descriptor]; + while (toVisit.isNotEmpty) { + final current = toVisit.removeLast(); + final res = await _get( + '$_usersBasePath/$_organization/_apis/graph/memberships/$current?direction=up&$_apiVersion-preview', + ); + if (res.isError) continue; + + final value = (jsonDecode(res.body) as Map)['value'] as List? ?? []; + for (final membership in value) { + final container = (membership as Map)['containerDescriptor'] as String?; + if (container != null && descriptors.add(container)) toVisit.add(container); + } + } + + _userApproverDescriptors = descriptors; + return ApiResponse.ok(descriptors); + } + @override Future> getUserFromDisplayName({required String name}) async { if (_allUsers.isEmpty) await _getUsers(); @@ -2509,6 +2548,7 @@ class AzureApiServiceImpl with AppLogger implements AzureApiService { _organization = ''; _chosenProjects = null; _allUsers.clear(); + _userApproverDescriptors = null; _user = null; dispose(); } diff --git a/test/api_service_mock.dart b/test/api_service_mock.dart index 42767a71..d4071b48 100644 --- a/test/api_service_mock.dart +++ b/test/api_service_mock.dart @@ -177,6 +177,11 @@ class AzureApiServiceMock implements AzureApiService { return ApiResponse.ok(true); } + @override + Future>> getCurrentUserApproverDescriptors() async { + return ApiResponse.ok({}); + } + @override Future>> getRecentCommits({ Set? projects,