From 413019491651ccfc9a9a12ed5caeaafc0ef7c477 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Tue, 28 Apr 2026 21:37:37 -0700 Subject: [PATCH 01/18] Add DAR tasks --- .../it/tests/DataAccessRequestIT.java | 236 +++++++++ .../org/openmetadata/sdk/fluent/Tasks.java | 144 ++++++ .../service/jdbi3/TaskRepository.java | 1 + .../service/tasks/TaskWorkflowHandler.java | 2 + .../tasks/TaskWorkflowLifecycleResolver.java | 1 + .../DataAccessRequestTaskWorkflow.json | 53 +- .../taskFormSchemas/DataAccessRequest.json | 131 +++++ .../tasks/DataAccessRequestWorkflowTest.java | 111 ++++ .../json/schema/entity/tasks/task.json | 12 +- .../schema/type/dataAccessRequestPayload.json | 44 +- .../src/main/resources/ui/package.json | 1 + .../Features/Tasks/DataAccessRequest.spec.ts | 181 +++++++ .../AppRouter/AuthenticatedAppRouter.tsx | 17 + .../DataAccessRequest.interface.ts | 57 +++ ...ataAccessRequestDetailDrawer.component.tsx | 439 ++++++++++++++++ .../DataAccessDatasetPicker.component.tsx | 124 +++++ .../DataAccessRequestDrawer.component.tsx | 281 +++++++++++ .../DataAccessRequestList.component.tsx | 473 ++++++++++++++++++ .../DataAccessRequestWidget.component.tsx | 245 +++++++++ ...laceDataAccessRequestsWidget.component.tsx | 307 ++++++++++++ .../EntityRightPanel/EntityRightPanel.tsx | 38 +- .../ui/src/constants/LeftSidebar.constants.ts | 8 + .../resources/ui/src/constants/constants.ts | 2 + .../ui/src/enums/CustomizeDetailPage.enum.ts | 1 + .../resources/ui/src/enums/sidebar.enum.ts | 1 + .../ui/src/generated/entity/tasks/task.ts | 2 + .../type/dataAccessRequestPayload.ts | 27 +- .../ui/src/locale/languages/ar-sa.json | 59 +++ .../ui/src/locale/languages/de-de.json | 59 +++ .../ui/src/locale/languages/en-us.json | 59 +++ .../ui/src/locale/languages/es-es.json | 59 +++ .../ui/src/locale/languages/fr-fr.json | 59 +++ .../ui/src/locale/languages/gl-es.json | 59 +++ .../ui/src/locale/languages/he-he.json | 59 +++ .../ui/src/locale/languages/ja-jp.json | 59 +++ .../ui/src/locale/languages/ko-kr.json | 59 +++ .../ui/src/locale/languages/mr-in.json | 59 +++ .../ui/src/locale/languages/nl-nl.json | 59 +++ .../ui/src/locale/languages/pr-pr.json | 59 +++ .../ui/src/locale/languages/pt-br.json | 59 +++ .../ui/src/locale/languages/pt-pt.json | 59 +++ .../ui/src/locale/languages/ru-ru.json | 59 +++ .../ui/src/locale/languages/th-th.json | 59 +++ .../ui/src/locale/languages/tr-tr.json | 59 +++ .../ui/src/locale/languages/zh-cn.json | 59 +++ .../ui/src/locale/languages/zh-tw.json | 59 +++ .../DataAccessRequestPage.component.tsx | 128 +++++ .../main/resources/ui/src/rest/tasksAPI.ts | 28 ++ .../DataAccessRequestUtils.test.ts | 230 +++++++++ .../DataAccessRequestUtils.ts | 224 +++++++++ .../DataMarketplaceClassBase.ts | 26 +- .../DataMarketplace/DataMarketplaceUtils.tsx | 14 + .../resources/ui/src/utils/TableClassBase.ts | 28 +- .../src/main/resources/ui/yarn.lock | 5 + 54 files changed, 4714 insertions(+), 29 deletions(-) create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataAccessRequestIT.java create mode 100644 openmetadata-service/src/main/resources/json/data/taskFormSchemas/DataAccessRequest.json create mode 100644 openmetadata-service/src/test/java/org/openmetadata/service/tasks/DataAccessRequestWorkflowTest.java create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/DataAccessRequest.spec.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequest.interface.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestDetailDrawer/DataAccessRequestDetailDrawer.component.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestDrawer/DataAccessDatasetPicker.component.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestDrawer/DataAccessRequestDrawer.component.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestList/DataAccessRequestList.component.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestWidget/DataAccessRequestWidget.component.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/DataMarketplace/MarketplaceDataAccessRequestsWidget/MarketplaceDataAccessRequestsWidget.component.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/DataAccessRequestPage/DataAccessRequestPage.component.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/DataAccessRequest/DataAccessRequestUtils.test.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/DataAccessRequest/DataAccessRequestUtils.ts diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataAccessRequestIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataAccessRequestIT.java new file mode 100644 index 000000000000..c3cefc754f7f --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataAccessRequestIT.java @@ -0,0 +1,236 @@ +/* + * Copyright 2026 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.factories.DatabaseSchemaTestFactory; +import org.openmetadata.it.factories.DatabaseServiceTestFactory; +import org.openmetadata.it.factories.TableTestFactory; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.schema.api.tasks.CreateTask; +import org.openmetadata.schema.api.tasks.ResolveTask; +import org.openmetadata.schema.entity.data.DatabaseSchema; +import org.openmetadata.schema.entity.data.Table; +import org.openmetadata.schema.entity.feed.TaskFormSchema; +import org.openmetadata.schema.entity.services.DatabaseService; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.governance.workflows.WorkflowDefinition; +import org.openmetadata.schema.type.TaskAvailableTransition; +import org.openmetadata.schema.type.TaskCategory; +import org.openmetadata.schema.type.TaskEntityStatus; +import org.openmetadata.schema.type.TaskEntityType; +import org.openmetadata.schema.type.TaskPriority; +import org.openmetadata.schema.type.TaskResolutionType; +import org.openmetadata.sdk.exceptions.InvalidRequestException; + +/** + * Integration tests for the Data Access Request task type. + * + *

Exercises the full lifecycle through the REST API: + * + *

+ */ +@Execution(ExecutionMode.CONCURRENT) +public class DataAccessRequestIT { + + private static final String DAR_FORM_SCHEMA_NAME = "DataAccessRequest"; + private static final String DAR_WORKFLOW_NAME = "DataAccessRequestTaskWorkflow"; + + private static String createTargetTable(TestNamespace ns) { + DatabaseService service = DatabaseServiceTestFactory.createPostgres(ns); + DatabaseSchema dbSchema = DatabaseSchemaTestFactory.createSimple(ns, service); + Table table = TableTestFactory.createSimple(ns, dbSchema.getFullyQualifiedName()); + + return table.getFullyQualifiedName(); + } + + private static CreateTask buildDarRequest(TestNamespace ns, String tableFqn, String accessType) { + return new CreateTask() + .withName(ns.prefix("dar-task")) + .withDisplayName("Test DAR") + .withCategory(TaskCategory.DataAccess) + .withType(TaskEntityType.DataAccessRequest) + .withPriority(TaskPriority.Medium) + .withAbout(tableFqn) + .withAboutType("table") + .withPayload( + Map.of( + "accessType", accessType, + "requestedAccess", "Read", + "reason", "Need access for IT test", + "duration", "P14D")); + } + + @Test + void darFormSchemaIsSeeded() { + TaskFormSchema schema = + SdkClients.adminClient().taskFormSchemas().getByName(DAR_FORM_SCHEMA_NAME); + + assertNotNull(schema, "DataAccessRequest form schema must be seeded on boot"); + assertEquals(TaskEntityType.DataAccessRequest.value(), schema.getTaskType()); + assertEquals(TaskCategory.DataAccess.value(), schema.getTaskCategory()); + assertNotNull(schema.getTransitionForms()); + assertTrue( + schema.getTransitionForms().getAdditionalProperties().containsKey("approve"), + "approve transition form must exist"); + assertTrue( + schema.getTransitionForms().getAdditionalProperties().containsKey("reject"), + "reject transition form must exist"); + assertTrue( + schema.getTransitionForms().getAdditionalProperties().containsKey("revoke"), + "revoke transition form must exist"); + } + + @Test + void darWorkflowDefinitionIsSeeded() { + WorkflowDefinition workflow = + SdkClients.adminClient().workflowDefinitions().getByName(DAR_WORKFLOW_NAME); + + assertNotNull(workflow, "DataAccessRequestTaskWorkflow must be seeded on boot"); + List nodeNames = workflow.getNodes().stream().map(n -> n.getName()).toList(); + assertTrue(nodeNames.contains("TaskReview")); + assertTrue(nodeNames.contains("ApprovedAccess")); + assertTrue(nodeNames.contains("RejectedEnd")); + assertTrue(nodeNames.contains("RevokedEnd")); + } + + @Test + void createApproveAndRevokeLifecycle(TestNamespace ns) { + String tableFqn = createTargetTable(ns); + + Task created = + SdkClients.adminClient().tasks().create(buildDarRequest(ns, tableFqn, "FullAccess")); + + assertEquals(TaskEntityStatus.Open, created.getStatus()); + assertEquals("review", created.getWorkflowStageId()); + List openTransitions = + created.getAvailableTransitions().stream().map(TaskAvailableTransition::getId).toList(); + assertTrue(openTransitions.contains("approve")); + assertTrue(openTransitions.contains("reject")); + + // Approve → moves to ApprovedAccess userTask. Status stays non-terminal so the workflow + // can continue to a Revoke transition (matches IncidentResolution pattern). + Task approved = + SdkClients.adminClient() + .tasks() + .resolve( + created.getId().toString(), + new ResolveTask().withTransitionId("approve").withComment("approved")); + + assertEquals(TaskEntityStatus.InProgress, approved.getStatus()); + assertEquals("approved", approved.getWorkflowStageId()); + List approvedTransitions = + approved.getAvailableTransitions().stream().map(TaskAvailableTransition::getId).toList(); + assertEquals(List.of("revoke"), approvedTransitions); + + // Revoke → terminal Revoked status with resolution. + Task revoked = + SdkClients.adminClient() + .tasks() + .resolve( + approved.getId().toString(), + new ResolveTask().withTransitionId("revoke").withComment("revoking")); + + assertEquals(TaskEntityStatus.Revoked, revoked.getStatus()); + assertNotNull(revoked.getResolution()); + assertEquals(TaskResolutionType.Revoked, revoked.getResolution().getType()); + assertTrue(revoked.getAvailableTransitions().isEmpty()); + } + + @Test + void rejectLandsAtTerminalRejectedStatus(TestNamespace ns) { + String tableFqn = createTargetTable(ns); + Task created = + SdkClients.adminClient().tasks().create(buildDarRequest(ns, tableFqn, "ColumnLevel")); + + Task rejected = + SdkClients.adminClient() + .tasks() + .resolve( + created.getId().toString(), + new ResolveTask().withTransitionId("reject").withComment("not justified")); + + assertEquals(TaskEntityStatus.Rejected, rejected.getStatus()); + assertEquals(TaskResolutionType.Rejected, rejected.getResolution().getType()); + assertFalse(rejected.getAvailableTransitions().stream().anyMatch(t -> "revoke".equals(t.getId()))); + } + + @Test + void columnLevelPayloadStoresColumns(TestNamespace ns) { + String tableFqn = createTargetTable(ns); + + CreateTask req = + new CreateTask() + .withName(ns.prefix("dar-cols")) + .withCategory(TaskCategory.DataAccess) + .withType(TaskEntityType.DataAccessRequest) + .withAbout(tableFqn) + .withAboutType("table") + .withPayload( + Map.of( + "accessType", "ColumnLevel", + "columns", List.of(tableFqn + ".id", tableFqn + ".name"), + "reason", "Need a couple of columns", + "duration", "P7D")); + + Task created = SdkClients.adminClient().tasks().create(req); + + Map payload = (Map) created.getPayload(); + assertEquals("ColumnLevel", payload.get("accessType")); + assertEquals(2, ((List) payload.get("columns")).size()); + } + + @Test + void missingAccessTypeIsRejectedByFormSchema(TestNamespace ns) { + String tableFqn = createTargetTable(ns); + + CreateTask invalid = + new CreateTask() + .withName(ns.prefix("dar-invalid")) + .withCategory(TaskCategory.DataAccess) + .withType(TaskEntityType.DataAccessRequest) + .withAbout(tableFqn) + .withAboutType("table") + // accessType missing — required by both the JSON Schema payload + // (dataAccessRequestPayload.json) and the seeded form schema. + .withPayload(Map.of("reason", "I need it")); + + assertThrows( + InvalidRequestException.class, + () -> SdkClients.adminClient().tasks().create(invalid)); + } +} diff --git a/openmetadata-sdk/src/main/java/org/openmetadata/sdk/fluent/Tasks.java b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/fluent/Tasks.java index abe3657d1ad3..3dd978af4941 100644 --- a/openmetadata-sdk/src/main/java/org/openmetadata/sdk/fluent/Tasks.java +++ b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/fluent/Tasks.java @@ -13,14 +13,24 @@ package org.openmetadata.sdk.fluent; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.UUID; import org.openmetadata.schema.api.tasks.CreateTask; import org.openmetadata.schema.api.tasks.CreateTaskComment; import org.openmetadata.schema.api.tasks.ResolveTask; import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.type.DataAccessPermission; +import org.openmetadata.schema.type.DataAccessRequestPayload; +import org.openmetadata.schema.type.DataAccessType; import org.openmetadata.schema.type.EntityHistory; +import org.openmetadata.schema.type.TaskCategory; import org.openmetadata.schema.type.TaskEntityStatus; +import org.openmetadata.schema.type.TaskEntityType; +import org.openmetadata.schema.type.TaskPriority; +import org.openmetadata.schema.type.TaskResolutionType; import org.openmetadata.sdk.client.OpenMetadataClient; import org.openmetadata.sdk.models.ListParams; import org.openmetadata.sdk.models.ListResponse; @@ -148,4 +158,138 @@ public static Task editComment(String taskId, UUID commentId, String message) { public static Task deleteComment(String taskId, UUID commentId) { return getClient().tasks().deleteComment(taskId, commentId); } + + // ==================== Data Access Request Helpers ==================== + + /** + * Create a Data Access Request task against any entity (table, dataProduct, etc.). + * + * @param entityFqn fully qualified name of the entity access is being requested for + * @param entityType entity type (e.g., "table", "dataProduct") + * @param accessType scope of access being requested + * @param reason business justification (required, must be non-empty) + * @param duration optional ISO 8601 duration (e.g., {@code Duration.ofDays(14)}) + * @return the created Task + */ + public static Task requestDataAccess( + String entityFqn, + String entityType, + DataAccessType accessType, + String reason, + Duration duration) { + return requestDataAccess( + entityFqn, entityType, accessType, DataAccessPermission.Read, reason, duration, List.of()); + } + + /** + * Create a column-level Data Access Request against a Table. + */ + public static Task requestColumnLevelAccess( + String tableFqn, List columnFqns, String reason, Duration duration) { + return requestDataAccess( + tableFqn, + "table", + DataAccessType.ColumnLevel, + DataAccessPermission.Read, + reason, + duration, + columnFqns); + } + + /** + * Create a Data Access Request task with full control over payload fields. + */ + public static Task requestDataAccess( + String entityFqn, + String entityType, + DataAccessType accessType, + DataAccessPermission permission, + String reason, + Duration duration, + List columns) { + Map payload = new HashMap<>(); + payload.put("accessType", accessType.value()); + payload.put("requestedAccess", permission.value()); + payload.put("reason", reason); + if (duration != null) { + payload.put("duration", duration.toString()); + } + if (columns != null && !columns.isEmpty()) { + payload.put("columns", columns); + } + + CreateTask request = + new CreateTask() + .withCategory(TaskCategory.DataAccess) + .withType(TaskEntityType.DataAccessRequest) + .withPriority(TaskPriority.Medium) + .withAbout(entityFqn) + .withAboutType(entityType) + .withPayload(payload); + + return create(request); + } + + /** + * Approve a Data Access Request task. Equivalent to resolving with the + * {@code approve} transition and {@link TaskResolutionType#APPROVED}. + */ + public static Task approveDataAccessRequest(String taskId, String comment) { + ResolveTask request = + new ResolveTask() + .withTransitionId("approve") + .withResolutionType(TaskResolutionType.Approved) + .withComment(comment); + return resolve(taskId, request); + } + + /** + * Reject a Data Access Request task. The reject transition requires a comment. + */ + public static Task rejectDataAccessRequest(String taskId, String comment) { + ResolveTask request = + new ResolveTask() + .withTransitionId("reject") + .withResolutionType(TaskResolutionType.Rejected) + .withComment(comment); + return resolve(taskId, request); + } + + /** + * Revoke previously-granted access. Only valid against a task in the + * {@link TaskEntityStatus#Approved} state. Comment is required. + */ + public static Task revokeDataAccess(String taskId, String comment) { + ResolveTask request = + new ResolveTask() + .withTransitionId("revoke") + .withResolutionType(TaskResolutionType.Revoked) + .withComment(comment); + return resolve(taskId, request); + } + + /** + * Convenience: build a typed {@link DataAccessRequestPayload} for inspection. + * The on-wire payload sent by {@link #requestDataAccess} is a plain Map; use + * this helper when you need a typed view (e.g., for tests). + */ + public static DataAccessRequestPayload buildDataAccessPayload( + DataAccessType accessType, + DataAccessPermission permission, + String reason, + Duration duration, + List columns) { + DataAccessRequestPayload payload = + new DataAccessRequestPayload() + .withAccessType(accessType) + .withRequestedAccess(permission) + .withReason(reason); + if (duration != null) { + payload.setDuration(duration.toString()); + } + if (columns != null && !columns.isEmpty()) { + payload.setColumns(columns); + } + return payload; + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TaskRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TaskRepository.java index f6293a65d80b..fcf9a6ccad19 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TaskRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TaskRepository.java @@ -1046,6 +1046,7 @@ private TaskEntityStatus mapResolutionToStatus(TaskResolutionType resolutionType case Rejected, AutoRejected -> TaskEntityStatus.Rejected; case Completed -> TaskEntityStatus.Completed; case Cancelled -> TaskEntityStatus.Cancelled; + case Revoked -> TaskEntityStatus.Revoked; case TimedOut -> TaskEntityStatus.Failed; }; } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowHandler.java index d4bbca203aad..50a9a7db6592 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowHandler.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowHandler.java @@ -1140,6 +1140,7 @@ private String defaultWorkflowResult(TaskResolutionType resolutionType) { case Rejected, AutoRejected -> "reject"; case Completed -> "complete"; case Cancelled -> "cancel"; + case Revoked -> "revoke"; case TimedOut -> "timeout"; }; } @@ -1186,6 +1187,7 @@ private TaskResolutionType resolveResolutionType( case Rejected -> TaskResolutionType.Rejected; case Completed -> TaskResolutionType.Completed; case Cancelled -> TaskResolutionType.Cancelled; + case Revoked -> TaskResolutionType.Revoked; case Failed -> TaskResolutionType.TimedOut; case Open, InProgress, Pending -> null; }; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowLifecycleResolver.java b/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowLifecycleResolver.java index e2dadadb1647..68d4bc31ec2b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowLifecycleResolver.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/tasks/TaskWorkflowLifecycleResolver.java @@ -412,6 +412,7 @@ public static String defaultTransitionId(Task task, TaskResolutionType resolutio case Rejected, AutoRejected -> "reject"; case Completed -> "complete"; case Cancelled -> "cancel"; + case Revoked -> "revoke"; case TimedOut -> "timeout"; }; } diff --git a/openmetadata-service/src/main/resources/json/data/governance/workflows/DataAccessRequestTaskWorkflow.json b/openmetadata-service/src/main/resources/json/data/governance/workflows/DataAccessRequestTaskWorkflow.json index d884abb2796f..185039044964 100644 --- a/openmetadata-service/src/main/resources/json/data/governance/workflows/DataAccessRequestTaskWorkflow.json +++ b/openmetadata-service/src/main/resources/json/data/governance/workflows/DataAccessRequestTaskWorkflow.json @@ -2,7 +2,7 @@ "name": "DataAccessRequestTaskWorkflow", "fullyQualifiedName": "DataAccessRequestTaskWorkflow", "displayName": "Data Access Request Task Workflow", - "description": "Default workflow-driven lifecycle for data access request tasks.", + "description": "Default workflow-driven lifecycle for data access request tasks. After approval, access can be revoked by the approver, owner, or domain owner.", "config": { "storeStageStatus": true }, @@ -40,8 +40,7 @@ "id": "approve", "label": "Approve", "targetStageId": "approved", - "targetTaskStatus": "Approved", - "resolutionType": "Approved", + "targetTaskStatus": "InProgress", "formRef": "approve", "requiresComment": false }, @@ -61,16 +60,49 @@ } }, { - "type": "endEvent", - "subType": "endEvent", - "name": "ApprovedEnd", - "displayName": "Approved" + "type": "userTask", + "subType": "userApprovalTask", + "name": "ApprovedAccess", + "displayName": "Active Access", + "config": { + "assignees": { + "addReviewers": true, + "addOwners": true, + "candidates": [] + }, + "approvalThreshold": 1, + "rejectionThreshold": 1, + "stageId": "approved", + "stageDisplayName": "Approved", + "taskStatus": "InProgress", + "assigneeStrategy": "owners-and-reviewers", + "transitionMetadata": [ + { + "id": "revoke", + "label": "Revoke Access", + "targetStageId": "revoked", + "targetTaskStatus": "Revoked", + "resolutionType": "Revoked", + "formRef": "revoke", + "requiresComment": true + } + ] + }, + "inputNamespaceMap": { + "relatedEntity": "global" + } }, { "type": "endEvent", "subType": "endEvent", "name": "RejectedEnd", "displayName": "Rejected" + }, + { + "type": "endEvent", + "subType": "endEvent", + "name": "RevokedEnd", + "displayName": "Revoked" } ], "edges": [ @@ -80,13 +112,18 @@ }, { "from": "TaskReview", - "to": "ApprovedEnd", + "to": "ApprovedAccess", "condition": "approve" }, { "from": "TaskReview", "to": "RejectedEnd", "condition": "reject" + }, + { + "from": "ApprovedAccess", + "to": "RevokedEnd", + "condition": "revoke" } ] } diff --git a/openmetadata-service/src/main/resources/json/data/taskFormSchemas/DataAccessRequest.json b/openmetadata-service/src/main/resources/json/data/taskFormSchemas/DataAccessRequest.json new file mode 100644 index 000000000000..96700c66c886 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/taskFormSchemas/DataAccessRequest.json @@ -0,0 +1,131 @@ +{ + "name": "DataAccessRequest", + "displayName": "Data Access Request", + "description": "Form schema for requesting access to a Table, Data Product, or other data asset.", + "taskType": "DataAccessRequest", + "taskCategory": "DataAccess", + "workflowDefinitionRef": "DataAccessRequestTaskWorkflow", + "formSchema": { + "type": "object", + "required": ["accessType", "reason"], + "properties": { + "accessType": { + "type": "string", + "title": "Access Type", + "enum": ["FullAccess", "ColumnLevel", "Masked"], + "default": "FullAccess" + }, + "columns": { + "type": "array", + "title": "Select Columns", + "items": { + "type": "string" + }, + "default": [] + }, + "requestedAccess": { + "type": "string", + "title": "Permission Level", + "enum": ["Read", "Write", "Admin"], + "default": "Read" + }, + "duration": { + "type": "string", + "title": "Duration" + }, + "reason": { + "type": "string", + "title": "Access Reason" + }, + "ticketId": { + "type": "string", + "title": "External Ticket ID" + } + } + }, + "uiSchema": { + "ui:handler": { + "type": "dataAccessRequest", + "permission": "EDIT_ALL" + }, + "ui:order": [ + "accessType", + "columns", + "requestedAccess", + "duration", + "reason", + "ticketId" + ], + "accessType": { + "ui:widget": "radio" + }, + "columns": { + "ui:widget": "columnSelector" + }, + "requestedAccess": { + "ui:widget": "select" + }, + "duration": { + "ui:widget": "durationSelector" + }, + "reason": { + "ui:widget": "textarea" + } + }, + "transitionForms": { + "approve": { + "requiresComment": false, + "formSchema": { + "type": "object", + "properties": { + "comment": { + "type": "string", + "title": "Approval Comment" + } + } + }, + "uiSchema": { + "comment": { + "ui:widget": "textarea" + } + } + }, + "reject": { + "requiresComment": true, + "formSchema": { + "type": "object", + "required": ["comment"], + "properties": { + "comment": { + "type": "string", + "title": "Rejection Reason" + } + } + }, + "uiSchema": { + "comment": { + "ui:widget": "textarea" + } + } + }, + "revoke": { + "requiresComment": true, + "formSchema": { + "type": "object", + "required": ["comment"], + "properties": { + "comment": { + "type": "string", + "title": "Reason for Revoking Access" + } + } + }, + "uiSchema": { + "comment": { + "ui:widget": "textarea" + } + } + } + }, + "fullyQualifiedName": "DataAccessRequest" +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/tasks/DataAccessRequestWorkflowTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/tasks/DataAccessRequestWorkflowTest.java new file mode 100644 index 000000000000..687800e86c62 --- /dev/null +++ b/openmetadata-service/src/test/java/org/openmetadata/service/tasks/DataAccessRequestWorkflowTest.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.tasks; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.openmetadata.schema.entity.tasks.Task; +import org.openmetadata.schema.type.TaskAvailableTransition; +import org.openmetadata.schema.type.TaskCategory; +import org.openmetadata.schema.type.TaskEntityStatus; +import org.openmetadata.schema.type.TaskEntityType; +import org.openmetadata.schema.type.TaskResolutionType; + +/** + * Unit tests covering the Data Access Request additions to the task workflow plumbing: + * + *
    + *
  • The new Revoked branch on TaskEntityStatus and TaskResolutionType + *
  • The DataAccessRequestTaskWorkflow ↔ DataAccessRequest task type mapping + *
  • The DataAccess category default for DAR tasks + *
  • The defaultTransitionId resolution for the new revoke transition + *
+ * + *

Methods that flip on these enum values inside the service ({@code + * TaskRepository.mapResolutionToStatus}, {@code TaskWorkflowHandler.defaultWorkflowResult}, + * {@code TaskWorkflowHandler.resolveResolutionType}) are package-private and not directly + * exercised here; they are covered indirectly through the integration tests in {@code + * DataAccessRequestIT}. The branch coverage that matters at the unit level lives in {@link + * TaskWorkflowLifecycleResolver}. + */ +class DataAccessRequestWorkflowTest { + + @Test + void revokedStatusIsPresent() { + // Sanity check that the schema regen actually produced the Revoked enum entries. + assertEquals("Revoked", TaskEntityStatus.Revoked.value()); + assertEquals("Revoked", TaskResolutionType.Revoked.value()); + } + + @Test + void defaultWorkflowDefinitionRefMapsDataAccessRequest() { + assertEquals( + "DataAccessRequestTaskWorkflow", + TaskWorkflowLifecycleResolver.defaultWorkflowDefinitionRef( + TaskEntityType.DataAccessRequest)); + } + + @Test + void defaultTaskTypeForDataAccessRequestWorkflow() { + assertEquals( + TaskEntityType.DataAccessRequest, + TaskWorkflowLifecycleResolver.defaultTaskTypeForWorkflowDefinitionRef( + "DataAccessRequestTaskWorkflow")); + } + + @Test + void defaultCategoryForDataAccessRequestWorkflow() { + assertEquals( + TaskCategory.DataAccess, + TaskWorkflowLifecycleResolver.defaultTaskCategoryForWorkflowDefinitionRef( + "DataAccessRequestTaskWorkflow")); + } + + @Test + void defaultTransitionIdResolvesRevokeTransitionFromAvailableTransitions() { + Task task = + new Task() + .withType(TaskEntityType.DataAccessRequest) + .withAvailableTransitions( + List.of( + new TaskAvailableTransition() + .withId("revoke") + .withResolutionType(TaskResolutionType.Revoked))); + + assertEquals( + "revoke", + TaskWorkflowLifecycleResolver.defaultTransitionId(task, TaskResolutionType.Revoked)); + } + + @Test + void defaultTransitionIdFallsBackToTokenWhenNoMatchingResolution() { + // availableTransitions exist but none match Revoked → falls through to the resolution + // → token mapping which now includes Revoked → "revoke". + Task task = + new Task() + .withType(TaskEntityType.DataAccessRequest) + .withAvailableTransitions( + List.of( + new TaskAvailableTransition() + .withId("approve") + .withResolutionType(TaskResolutionType.Approved))); + + assertEquals( + "revoke", + TaskWorkflowLifecycleResolver.defaultTransitionId(task, TaskResolutionType.Revoked)); + } +} diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/tasks/task.json b/openmetadata-spec/src/main/resources/json/schema/entity/tasks/task.json index 6f9364979455..667eb1a97ef9 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/tasks/task.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/tasks/task.json @@ -77,7 +77,8 @@ "Rejected", "Completed", "Cancelled", - "Failed" + "Failed", + "Revoked" ], "javaEnums": [ {"name": "Open"}, @@ -87,7 +88,8 @@ {"name": "Rejected"}, {"name": "Completed"}, {"name": "Cancelled"}, - {"name": "Failed"} + {"name": "Failed"}, + {"name": "Revoked"} ], "default": "Open" }, @@ -115,7 +117,8 @@ "Cancelled", "TimedOut", "AutoApproved", - "AutoRejected" + "AutoRejected", + "Revoked" ], "javaEnums": [ {"name": "Approved"}, @@ -124,7 +127,8 @@ {"name": "Cancelled"}, {"name": "TimedOut"}, {"name": "AutoApproved"}, - {"name": "AutoRejected"} + {"name": "AutoRejected"}, + {"name": "Revoked"} ] }, "taskResolution": { diff --git a/openmetadata-spec/src/main/resources/json/schema/type/dataAccessRequestPayload.json b/openmetadata-spec/src/main/resources/json/schema/type/dataAccessRequestPayload.json index 11953f9ffcde..350f69c55218 100644 --- a/openmetadata-spec/src/main/resources/json/schema/type/dataAccessRequestPayload.json +++ b/openmetadata-spec/src/main/resources/json/schema/type/dataAccessRequestPayload.json @@ -5,11 +5,47 @@ "description": "Payload for Data Access Request tasks.", "javaType": "org.openmetadata.schema.type.DataAccessRequestPayload", "type": "object", - "properties": { + "definitions": { + "accessType": { + "javaType": "org.openmetadata.schema.type.DataAccessType", + "description": "Scope of access being requested against the target entity.", + "type": "string", + "enum": ["FullAccess", "ColumnLevel", "Masked"], + "javaEnums": [ + {"name": "FullAccess"}, + {"name": "ColumnLevel"}, + {"name": "Masked"} + ] + }, "requestedAccess": { - "description": "Type of access requested.", + "javaType": "org.openmetadata.schema.type.DataAccessPermission", + "description": "Permission level for the requested access.", "type": "string", - "enum": ["Read", "Write", "Admin"] + "enum": ["Read", "Write", "Admin"], + "javaEnums": [ + {"name": "Read"}, + {"name": "Write"}, + {"name": "Admin"} + ], + "default": "Read" + } + }, + "properties": { + "accessType": { + "description": "Scope of access being requested. FullAccess grants access to all columns, ColumnLevel restricts to the columns listed in 'columns', Masked grants access to masked or anonymized data.", + "$ref": "#/definitions/accessType" + }, + "requestedAccess": { + "description": "Permission level for the access (Read, Write, Admin). Defaults to Read.", + "$ref": "#/definitions/requestedAccess" + }, + "columns": { + "description": "Fully qualified column names included in the request when accessType is ColumnLevel.", + "type": "array", + "items": { + "type": "string" + }, + "default": [] }, "duration": { "description": "Requested duration for access (ISO 8601).", @@ -32,6 +68,6 @@ "$ref": "basic.json#/definitions/timestamp" } }, - "required": ["requestedAccess", "reason"], + "required": ["accessType", "reason"], "additionalProperties": false } diff --git a/openmetadata-ui/src/main/resources/ui/package.json b/openmetadata-ui/src/main/resources/ui/package.json index 13179d3fb862..2eda28f57d13 100644 --- a/openmetadata-ui/src/main/resources/ui/package.json +++ b/openmetadata-ui/src/main/resources/ui/package.json @@ -187,6 +187,7 @@ "@eslint/js": "^9.18.0", "@estruyf/github-actions-reporter": "^1.7.0", "@playwright/test": "1.57.0", + "@rollup/rollup-darwin-arm64": "^4.60.2", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "@testing-library/jest-dom": "^5.11.10", diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/DataAccessRequest.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/DataAccessRequest.spec.ts new file mode 100644 index 000000000000..a722532cb122 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/DataAccessRequest.spec.ts @@ -0,0 +1,181 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect, test } from '@playwright/test'; +import { TableClass } from '../../../support/entity/TableClass'; +import { UserClass } from '../../../support/user/UserClass'; +import { performAdminLogin } from '../../../utils/admin'; +import { uuid } from '../../../utils/common'; + +test.describe('Data Access Request - End to End', () => { + const adminUser = new UserClass(); + const reviewer = new UserClass(); + const requester = new UserClass(); + const table = new TableClass(); + let createdTaskId: string | undefined; + + test.beforeAll('Create users and a Table', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + await adminUser.create(apiContext); + await reviewer.create(apiContext); + await requester.create(apiContext); + await table.create(apiContext); + await afterAction(); + }); + + test.afterAll('Cleanup', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + if (createdTaskId) { + await apiContext.delete(`/api/v1/tasks/${createdTaskId}?hardDelete=true`); + } + await table.delete(apiContext); + await adminUser.delete(apiContext); + await reviewer.delete(apiContext); + await requester.delete(apiContext); + await afterAction(); + }); + + test('Create + Approve + Revoke DAR via API', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + + const tableFqn = table.entityResponseData.fullyQualifiedName as string; + + const createResponse = await apiContext.post('/api/v1/tasks', { + data: { + name: `dar-${uuid()}`, + about: tableFqn, + aboutType: 'table', + category: 'DataAccess', + type: 'DataAccessRequest', + priority: 'Medium', + reviewers: [reviewer.responseData.fullyQualifiedName], + payload: { + accessType: 'FullAccess', + requestedAccess: 'Read', + reason: 'Need access for Q4 analysis', + duration: 'P14D', + }, + }, + }); + expect(createResponse.ok()).toBe(true); + const created = await createResponse.json(); + createdTaskId = created.id; + + expect(created.category).toBe('DataAccess'); + expect(created.type).toBe('DataAccessRequest'); + expect(created.payload.accessType).toBe('FullAccess'); + expect(created.status).toBe('Open'); + + const approveResponse = await apiContext.post( + `/api/v1/tasks/${created.id}/resolve`, + { + data: { transitionId: 'approve', resolutionType: 'Approved' }, + } + ); + expect(approveResponse.ok()).toBe(true); + const approved = await approveResponse.json(); + expect(approved.status).toBe('Approved'); + expect(approved.resolution?.type).toBe('Approved'); + + const refreshed = await apiContext.get(`/api/v1/tasks/${created.id}`); + const detail = await refreshed.json(); + const revokeAvailable = (detail.availableTransitions ?? []).some( + (t: { id: string }) => t.id === 'revoke' + ); + expect(revokeAvailable).toBe(true); + + const revokeResponse = await apiContext.post( + `/api/v1/tasks/${created.id}/resolve`, + { + data: { + transitionId: 'revoke', + resolutionType: 'Revoked', + comment: 'Project completed; access no longer needed', + }, + } + ); + expect(revokeResponse.ok()).toBe(true); + const revoked = await revokeResponse.json(); + expect(revoked.status).toBe('Revoked'); + expect(revoked.resolution?.type).toBe('Revoked'); + + await afterAction(); + }); + + test('Create + Reject DAR via API', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + + const tableFqn = table.entityResponseData.fullyQualifiedName as string; + + const createResponse = await apiContext.post('/api/v1/tasks', { + data: { + name: `dar-reject-${uuid()}`, + about: tableFqn, + aboutType: 'table', + category: 'DataAccess', + type: 'DataAccessRequest', + priority: 'Medium', + reviewers: [reviewer.responseData.fullyQualifiedName], + payload: { + accessType: 'ColumnLevel', + requestedAccess: 'Read', + reason: 'Limited dashboard view', + columns: [`${tableFqn}.email`, `${tableFqn}.name`], + }, + }, + }); + expect(createResponse.ok()).toBe(true); + const created = await createResponse.json(); + expect(created.payload.columns).toHaveLength(2); + expect(created.payload.accessType).toBe('ColumnLevel'); + + const rejectResponse = await apiContext.post( + `/api/v1/tasks/${created.id}/resolve`, + { + data: { + transitionId: 'reject', + resolutionType: 'Rejected', + comment: 'Insufficient justification', + }, + } + ); + expect(rejectResponse.ok()).toBe(true); + const rejected = await rejectResponse.json(); + expect(rejected.status).toBe('Rejected'); + + await apiContext.delete(`/api/v1/tasks/${created.id}?hardDelete=true`); + await afterAction(); + }); + + test('Validation rejects DAR with missing accessType', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + + const tableFqn = table.entityResponseData.fullyQualifiedName as string; + + const response = await apiContext.post('/api/v1/tasks', { + data: { + name: `dar-invalid-${uuid()}`, + about: tableFqn, + aboutType: 'table', + category: 'DataAccess', + type: 'DataAccessRequest', + payload: { + reason: 'Missing accessType field', + }, + }, + }); + expect(response.status()).toBeGreaterThanOrEqual(400); + + await afterAction(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx index a2ad1eb88329..db8e453ce412 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/AuthenticatedAppRouter.tsx @@ -118,6 +118,15 @@ const DataMarketplacePage = withSuspenseFallback( ) ); +const DataAccessRequestPage = withSuspenseFallback( + React.lazy( + () => + import( + /* webpackChunkName: "DataAccessRequestPage" */ '../../pages/DataAccessRequestPage/DataAccessRequestPage.component' + ) + ) +); + const BotDetailsPage = withSuspenseFallback( React.lazy(() => import('../../pages/BotDetailsPage/BotDetailsPage')) ); @@ -488,6 +497,14 @@ const AuthenticatedAppRouter: FunctionComponent = () => { path={ROUTES.MARKETPLACE_APP_INSTALL} /> } path={ROUTES.DATA_MARKETPLACE} /> + } + path={ROUTES.DATA_ACCESS_REQUESTS_WITH_TAB} + /> + } + path={ROUTES.DATA_ACCESS_REQUESTS} + /> } path={ROUTES.SWAGGER} /> } path={ROUTES.DOMAIN_VERSION} /> } path={ROUTES.USER_PROFILE_WITH_SUB_TAB} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequest.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequest.interface.ts new file mode 100644 index 000000000000..ab5c05912bf1 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequest.interface.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { EntityType } from '../../enums/entity.enum'; +import { + DataAccessPermission, + DataAccessType, + Task, +} from '../../rest/tasksAPI'; + +export interface DataAccessRequestFormValues { + accessType: DataAccessType; + columns: string[]; + duration: string; + reason: string; + requestedAccess: DataAccessPermission; + ticketId?: string; +} + +export interface DataAccessRequestDrawerProps { + open: boolean; + entityFqn: string; + entityType: EntityType; + entityDisplayName: string; + availableColumns?: string[]; + reviewers?: string[]; + onClose: () => void; + onCreated?: (task: Task) => void; +} + +export interface DataAccessRequestWidgetProps { + entityFqn: string; + entityType: EntityType; + entityDisplayName: string; + availableColumns?: string[]; + reviewers?: string[]; + canRequestAccess?: boolean; +} + +export interface DataAccessRequestDetailDrawerProps { + taskId?: string; + open: boolean; + onClose: () => void; + onResolved?: (task: Task) => void; +} + +export type DataAccessTab = 'my-requests' | 'my-approvals'; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestDetailDrawer/DataAccessRequestDetailDrawer.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestDetailDrawer/DataAccessRequestDetailDrawer.component.tsx new file mode 100644 index 000000000000..fe53b20f9ea2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestDetailDrawer/DataAccessRequestDetailDrawer.component.tsx @@ -0,0 +1,439 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Badge } from '@openmetadata/ui-core-components'; +import { + Avatar, + Button, + Divider, + Drawer, + Empty, + Form, + Input, + Modal, + Skeleton, + Space, + Typography, +} from 'antd'; +import { AxiosError } from 'axios'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + addTaskComment, + getTaskById, + resolveTask, + Task, + TaskComment, + TaskResolutionType, +} from '../../../rest/tasksAPI'; +import { + buildResolveBody, + canApprove, + canReject, + canRevoke, + getApproveTransition, + getDataAccessPayload, + getDisplayStatus, + getRejectTransition, + getRevokeTransition, +} from '../../../utils/DataAccessRequest/DataAccessRequestUtils'; + +const STATUS_BADGE_COLOR: Record< + string, + 'success' | 'error' | 'warning' | 'gray' | 'orange' +> = { + Approved: 'success', + Pending: 'warning', + Rejected: 'error', + Revoked: 'orange', + Expired: 'gray', +}; +import { getEntityName } from '../../../utils/EntityUtils'; +import { + showErrorToast, + showSuccessToast, +} from '../../../utils/ToastUtils'; +import { DataAccessRequestDetailDrawerProps } from '../DataAccessRequest.interface'; + +const { Text, Title } = Typography; + +type ConfirmKind = 'approve' | 'reject' | 'revoke'; + +const DataAccessRequestDetailDrawer = ({ + taskId, + open, + onClose, + onResolved, +}: DataAccessRequestDetailDrawerProps) => { + const { t } = useTranslation(); + const [task, setTask] = useState(undefined); + const [loading, setLoading] = useState(false); + const [comment, setComment] = useState(''); + const [postingComment, setPostingComment] = useState(false); + const [confirmKind, setConfirmKind] = useState(null); + const [confirmComment, setConfirmComment] = useState(''); + const [resolving, setResolving] = useState(false); + + const loadTask = useCallback(async () => { + if (!taskId) { + return; + } + try { + setLoading(true); + const response = await getTaskById(taskId, { + fields: 'comments,assignees,reviewers,resolution,about', + }); + setTask(response.data); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setLoading(false); + } + }, [taskId]); + + useEffect(() => { + if (open && taskId) { + loadTask(); + } else { + setTask(undefined); + setComment(''); + } + }, [open, taskId, loadTask]); + + const payload = useMemo( + () => (task ? getDataAccessPayload(task) : undefined), + [task] + ); + + const handlePostComment = async () => { + if (!task || !comment.trim()) { + return; + } + try { + setPostingComment(true); + const updated = await addTaskComment(task.id, comment.trim()); + setTask(updated); + setComment(''); + showSuccessToast(t('message.comment-posted')); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setPostingComment(false); + } + }; + + const openConfirm = (kind: ConfirmKind) => { + setConfirmKind(kind); + setConfirmComment(''); + }; + + const closeConfirm = () => { + setConfirmKind(null); + setConfirmComment(''); + }; + + const transitionFor = (kind: ConfirmKind) => { + if (!task) { + return undefined; + } + if (kind === 'approve') { + return getApproveTransition(task); + } + if (kind === 'reject') { + return getRejectTransition(task); + } + + return getRevokeTransition(task); + }; + + const handleResolve = async () => { + if (!task || !confirmKind) { + return; + } + const transition = transitionFor(confirmKind); + if (!transition) { + return; + } + if (transition.requiresComment && !confirmComment.trim()) { + return; + } + try { + setResolving(true); + const resolutionType = + transition.resolutionType ?? + (confirmKind === 'approve' + ? TaskResolutionType.Approved + : confirmKind === 'reject' + ? TaskResolutionType.Rejected + : TaskResolutionType.Revoked); + + const body = buildResolveBody( + transition.id, + resolutionType, + confirmComment.trim() || undefined + ); + const updated = await resolveTask(task.id, body); + setTask(updated); + showSuccessToast(t(`message.task-${confirmKind}-success`)); + closeConfirm(); + onResolved?.(updated); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setResolving(false); + } + }; + + const renderField = (labelKey: string, value: React.ReactNode) => ( +

+ + {t(labelKey)} + +
{value}
+
+ ); + + const renderComments = (comments: TaskComment[]) => { + if (comments.length === 0) { + return ( + + ); + } + + return ( + + {comments.map((c) => ( +
+ + {(c.author?.displayName ?? c.author?.name ?? '?') + .charAt(0) + .toUpperCase()} + +
+
+ + {c.author?.displayName ?? c.author?.name ?? t('label.user')} + + + {new Date(c.createdAt).toLocaleString()} + +
+ {c.message} +
+
+ ))} +
+ ); + }; + + const renderActions = () => { + if (!task) { + return null; + } + const showApprove = canApprove(task); + const showReject = canReject(task); + const showRevoke = canRevoke(task); + + if (!showApprove && !showReject && !showRevoke) { + return null; + } + + return ( +
+ {t('label.action-required')} + + {showReject && ( + + )} + {showApprove && ( + + )} + {showRevoke && ( + + )} + +
+ ); + }; + + return ( + <> + + + {task.about?.displayName ?? task.about?.name ?? task.taskId} + + {(() => { + const display = getDisplayStatus(task); + const color = STATUS_BADGE_COLOR[display] ?? 'gray'; + + return ( + + {display} + + ); + })()} + + ) : ( + t('label.data-access-request') + ) + } + width={520} + onClose={onClose}> + {loading || !task ? ( + + ) : ( + + + {task.taskId} + {task.about?.fullyQualifiedName + ? ` · ${task.about.fullyQualifiedName}` + : ''} + + + {renderField( + 'label.requested-by', + task.createdBy ? getEntityName(task.createdBy) : '-' + )} + + {payload?.accessType && + renderField('label.access-type', payload.accessType)} + + {payload?.columns && + payload.columns.length > 0 && + renderField( + 'label.columns-requested', + payload.columns + .map((c) => c.split('.').pop() ?? c) + .join(', ') + )} + + {payload?.duration && + renderField('label.duration', payload.duration)} + + {payload?.reason && + renderField('label.reason-for-access', payload.reason)} + + {task.resolution?.resolvedBy && + renderField( + 'label.approver', + getEntityName(task.resolution.resolvedBy) + )} + + {task.resolution?.resolvedAt && + renderField( + 'label.resolved-on', + new Date(task.resolution.resolvedAt).toLocaleString() + )} + + {renderActions()} + + + + {t('label.comment-plural')} + + + setComment(e.target.value)} + /> +
+ +
+
+ + {renderComments(task.comments ?? [])} +
+ )} +
+ + + + setConfirmComment(e.target.value)} + /> + + + + ); +}; + +export default DataAccessRequestDetailDrawer; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestDrawer/DataAccessDatasetPicker.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestDrawer/DataAccessDatasetPicker.component.tsx new file mode 100644 index 000000000000..d627c0b87591 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestDrawer/DataAccessDatasetPicker.component.tsx @@ -0,0 +1,124 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Empty, Modal, Select, Spin } from 'antd'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { EntityType } from '../../../enums/entity.enum'; +import { SearchIndex } from '../../../enums/search.enum'; +import { searchData } from '../../../rest/miscAPI'; + +interface DatasetOption { + fqn: string; + displayName: string; + entityType: EntityType; +} + +interface Props { + open: boolean; + onClose: () => void; + onSelect: (option: DatasetOption) => void; +} + +const DataAccessDatasetPicker = ({ open, onClose, onSelect }: Props) => { + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open) { + setSearch(''); + setOptions([]); + return; + } + + let cancelled = false; + const handle = setTimeout(async () => { + try { + setLoading(true); + const res = await searchData( + search, + 1, + 20, + '', + 'updatedAt', + 'desc', + [SearchIndex.TABLE, SearchIndex.DATA_PRODUCT] + ); + if (cancelled) { + return; + } + const hits = + (res?.data?.hits?.hits as Array<{ _source: Record }>) ?? []; + setOptions( + hits.map((hit) => ({ + fqn: String(hit._source.fullyQualifiedName ?? hit._source.name ?? ''), + displayName: String( + hit._source.displayName ?? hit._source.name ?? hit._source.fullyQualifiedName + ), + entityType: (hit._source.entityType as EntityType) ?? EntityType.TABLE, + })) + ); + } catch { + if (!cancelled) { + setOptions([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }, 200); + + return () => { + cancelled = true; + clearTimeout(handle); + }; + }, [open, search]); + + return ( + + + + + + + {ACCESS_TYPE_OPTIONS.map((option) => { + const disabled = + option.value === DataAccessType.ColumnLevel && + columnOptions.length === 0; + + return ( + + {t(option.labelKey)} + + {t(option.descriptionKey)} + + + ); + })} + + + + {accessType === DataAccessType.ColumnLevel && ( + + ({ + label: t(opt.labelKey), + value: opt.value, + }))} + placeholder={t('label.select-duration')} + /> + + + + + + + + + + + + ); +}; + +export default DataAccessRequestDrawer; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestList/DataAccessRequestList.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestList/DataAccessRequestList.component.tsx new file mode 100644 index 000000000000..a54d7a60aeac --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestList/DataAccessRequestList.component.tsx @@ -0,0 +1,473 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Badge, Tabs } from '@openmetadata/ui-core-components'; +import { Button, Input, Select, Space, Table, Typography } from 'antd'; +import { ColumnsType } from 'antd/lib/table'; +import { AxiosError } from 'axios'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + DataAccessType, + listMyAssignedTasks, + listMyCreatedTasks, + Task, + TaskCategory, + TaskEntityType, +} from '../../../rest/tasksAPI'; +import { + formatDuration, + formatExpirationDate, + getDataAccessPayload, + getDisplayStatus, +} from '../../../utils/DataAccessRequest/DataAccessRequestUtils'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import ProfilePicture from '../../common/ProfilePicture/ProfilePicture'; +import { DataAccessTab } from '../DataAccessRequest.interface'; +import DataAccessRequestDetailDrawer from '../DataAccessRequestDetailDrawer/DataAccessRequestDetailDrawer.component'; + +const { Text } = Typography; + +interface DataAccessRequestListProps { + activeTab: DataAccessTab; + onTabChange: (tab: DataAccessTab) => void; +} + +const PAGE_SIZE = 13; + +const STATUS_FILTER_OPTIONS = [ + 'Pending', + 'Approved', + 'Rejected', + 'Expired', + 'Revoked', +]; + +const ACCESS_TYPE_LABELS: Record = { + [DataAccessType.FullAccess]: 'Full Access', + [DataAccessType.ColumnLevel]: 'Column-level', + [DataAccessType.Masked]: 'Masked', +}; + +// Maps the displayed status label to the Badge color from the OM design system. +// Tokens map directly to the Figma's Component colors/Utility palette. +const STATUS_BADGE_COLOR: Record< + string, + 'success' | 'error' | 'warning' | 'gray' | 'orange' +> = { + Approved: 'success', + Pending: 'warning', + Rejected: 'error', + Revoked: 'orange', + Expired: 'gray', +}; + +const formatFigmaDate = (timestamp?: number): string => { + if (!timestamp) { + return '-'; + } + const d = new Date(timestamp); + const months = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', + ]; + + return `${d.getDate()}, ${months[d.getMonth()]} ${d.getFullYear()}`; +}; + +const DataAccessRequestList = ({ + activeTab, + onTabChange, +}: DataAccessRequestListProps) => { + const { t } = useTranslation(); + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(false); + const [searchText, setSearchText] = useState(''); + const [statusFilter, setStatusFilter] = useState(); + const [accessTypeFilter, setAccessTypeFilter] = useState< + string | undefined + >(); + const [datasetFilter, setDatasetFilter] = useState(); + const [requestedByFilter, setRequestedByFilter] = useState< + string | undefined + >(); + const [approverFilter, setApproverFilter] = useState(); + const [detailTaskId, setDetailTaskId] = useState(); + + const fetchTasks = useCallback(async () => { + try { + setLoading(true); + const fetcher = + activeTab === 'my-requests' ? listMyCreatedTasks : listMyAssignedTasks; + const response = await fetcher({ + fields: 'about,assignees,resolution,createdBy,reviewers', + limit: 50, + }); + + const filtered = (response.data ?? []).filter( + (task) => + task.category === TaskCategory.DataAccess && + task.type === TaskEntityType.DataAccessRequest + ); + setTasks(filtered); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setLoading(false); + } + }, [activeTab]); + + useEffect(() => { + fetchTasks(); + }, [fetchTasks]); + + const datasetOptions = useMemo(() => { + const seen = new Set(); + + return tasks + .map((t) => t.about?.displayName ?? t.about?.name ?? '') + .filter((n) => n && !seen.has(n) && seen.add(n)) + .map((n) => ({ label: n, value: n })); + }, [tasks]); + + const requestedByOptions = useMemo(() => { + const seen = new Set(); + + return tasks + .map((t) => t.createdBy?.displayName ?? t.createdBy?.name ?? '') + .filter((n) => n && !seen.has(n) && seen.add(n)) + .map((n) => ({ label: n, value: n })); + }, [tasks]); + + const approverOptions = useMemo(() => { + const seen = new Set(); + + return tasks + .map( + (t) => + t.resolution?.resolvedBy?.displayName ?? + t.resolution?.resolvedBy?.name ?? + '' + ) + .filter((n) => n && !seen.has(n) && seen.add(n)) + .map((n) => ({ label: n, value: n })); + }, [tasks]); + + const filteredTasks = useMemo(() => { + const trimmed = searchText.trim().toLowerCase(); + + return tasks.filter((task) => { + const dataset = task.about?.displayName ?? task.about?.name ?? ''; + if ( + trimmed && + !task.taskId.toLowerCase().includes(trimmed) && + !dataset.toLowerCase().includes(trimmed) + ) { + return false; + } + if (statusFilter && getDisplayStatus(task) !== statusFilter) { + return false; + } + if ( + accessTypeFilter && + getDataAccessPayload(task)?.accessType !== accessTypeFilter + ) { + return false; + } + if (datasetFilter && dataset !== datasetFilter) { + return false; + } + const createdByName = + task.createdBy?.displayName ?? task.createdBy?.name ?? ''; + if (requestedByFilter && createdByName !== requestedByFilter) { + return false; + } + const approverName = + task.resolution?.resolvedBy?.displayName ?? + task.resolution?.resolvedBy?.name ?? + ''; + if (approverFilter && approverName !== approverFilter) { + return false; + } + + return true; + }); + }, [ + tasks, + searchText, + statusFilter, + accessTypeFilter, + datasetFilter, + requestedByFilter, + approverFilter, + ]); + + const renderUser = ( + user?: { name?: string; displayName?: string } | null + ) => { + if (!user) { + return --; + } + + return ( + + + + {user.displayName ?? user.name ?? '--'} + + + ); + }; + + const columns = useMemo>( + () => [ + { + dataIndex: 'taskId', + key: 'taskId', + title: t('label.task-id'), + width: 110, + render: (taskId: string, record) => ( + + ), + }, + { + key: 'dataset', + title: t('label.dataset'), + render: (_v, record) => ( + + ), + }, + { + key: 'requestedBy', + title: t('label.requested-by'), + render: (_v, record) => renderUser(record.createdBy), + }, + { + key: 'accessType', + title: t('label.access-type'), + render: (_v, record) => { + const at = getDataAccessPayload(record)?.accessType; + + return at ? ACCESS_TYPE_LABELS[at] : '--'; + }, + }, + { + key: 'columns', + title: t('label.columns-requested'), + render: (_v, record) => { + const cols = getDataAccessPayload(record)?.columns ?? []; + if (cols.length === 0) { + return --; + } + const first = cols[0].split('.').pop() ?? cols[0]; + if (cols.length === 1) { + return first; + } + + return ( + + {first} + + +{cols.length - 1} + + + ); + }, + }, + { + key: 'reason', + title: t('label.reason'), + ellipsis: true, + render: (_v, record) => { + const reason = getDataAccessPayload(record)?.reason ?? ''; + if (!reason) { + return --; + } + + return reason.length > 30 ? `${reason.slice(0, 30)}...` : reason; + }, + }, + { + dataIndex: 'status', + key: 'status', + title: t('label.status'), + width: 110, + render: (_v, record) => { + const display = getDisplayStatus(record); + const color = STATUS_BADGE_COLOR[display] ?? 'gray'; + + return ( + + {display} + + ); + }, + }, + { + key: 'requestedOn', + title: t('label.requested-on'), + render: (_v, record) => formatFigmaDate(record.createdAt), + }, + { + key: 'duration', + title: t('label.duration'), + render: (_v, record) => + formatDuration(getDataAccessPayload(record)?.duration), + }, + { + key: 'expiresOn', + title: t('label.expires-on'), + render: (_v, record) => { + const payload = getDataAccessPayload(record); + if (payload?.expirationDate) { + return formatFigmaDate(payload.expirationDate); + } + const computed = formatExpirationDate( + record.createdAt, + payload?.duration + ); + + return formatFigmaDate(computed); + }, + }, + ], + [t] + ); + + return ( +
+
+
+ + onTabChange(String(key) as DataAccessTab) + }> + + + {t('label.my-request-plural')} + + + {t('label.my-approval-plural')} + + + + setSearchText(e.target.value)} + /> +
+ + ({ + label: s, + value: s, + }))} + placeholder={t('label.status')} + style={{ minWidth: 110 }} + value={statusFilter} + onChange={setStatusFilter} + /> + + : } - onSearch={setSearch} - onSelect={(_value, option) => { - const found = options.find((o) => o.fqn === option.value); - if (found) { - onSelect(found); - } - }} - options={options.map((o) => ({ - label: `${o.displayName} (${o.entityType})`, - value: o.fqn, - }))} - placeholder={t('label.search-by-type', { - type: t('label.dataset'), - })} - style={{ width: '100%' }} - /> - - ); -}; - -export default DataAccessDatasetPicker; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestDrawer/DataAccessRequestDrawer.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestDrawer/DataAccessRequestDrawer.component.tsx deleted file mode 100644 index ebf65e4ed655..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestDrawer/DataAccessRequestDrawer.component.tsx +++ /dev/null @@ -1,281 +0,0 @@ -/* - * Copyright 2026 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - Button, - Drawer, - Form, - Input, - Radio, - Select, - Space, - Typography, -} from 'antd'; -import { AxiosError } from 'axios'; -import { useCallback, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - CreateTask, - createTask, - DataAccessPermission, - DataAccessRequestPayload, - DataAccessType, - TaskCategory, - TaskEntityType, - TaskPriority, -} from '../../../rest/tasksAPI'; -import { - showErrorToast, - showSuccessToast, -} from '../../../utils/ToastUtils'; -import { - ACCESS_TYPE_OPTIONS, - DURATION_OPTIONS, -} from '../../../utils/DataAccessRequest/DataAccessRequestUtils'; -import { DataAccessRequestDrawerProps } from '../DataAccessRequest.interface'; - -const { Title } = Typography; - -const DataAccessRequestDrawer = ({ - open, - entityFqn, - entityType, - entityDisplayName, - availableColumns, - reviewers, - onClose, - onCreated, -}: DataAccessRequestDrawerProps) => { - const { t } = useTranslation(); - const [form] = Form.useForm(); - const [submitting, setSubmitting] = useState(false); - const accessType = Form.useWatch('accessType', form) as - | DataAccessType - | undefined; - - const columnOptions = useMemo( - () => - (availableColumns ?? []).map((col) => ({ - label: col.split('.').pop() ?? col, - value: col, - })), - [availableColumns] - ); - - const handleSubmit = useCallback(async () => { - try { - const values = await form.validateFields(); - setSubmitting(true); - - const payload: DataAccessRequestPayload = { - accessType: values.accessType, - requestedAccess: - values.requestedAccess ?? DataAccessPermission.Read, - reason: values.reason, - ...(values.duration ? { duration: values.duration } : {}), - ...(values.accessType === DataAccessType.ColumnLevel - ? { columns: values.columns ?? [] } - : {}), - ...(values.ticketId ? { ticketId: values.ticketId } : {}), - }; - - const requestBody: CreateTask = { - name: `dar-${entityFqn}-${Date.now()}`, - displayName: t('label.request-access-to-entity', { - entity: entityDisplayName, - }), - category: TaskCategory.DataAccess, - type: TaskEntityType.DataAccessRequest, - priority: TaskPriority.Medium, - about: entityFqn, - aboutType: entityType, - ...(reviewers && reviewers.length > 0 ? { reviewers } : {}), - payload: payload as unknown as Record, - }; - - const created = await createTask(requestBody); - showSuccessToast(t('message.data-access-request-created')); - onCreated?.(created); - form.resetFields(); - onClose(); - } catch (error) { - const axiosError = error as AxiosError; - - if ((error as { errorFields?: unknown[] }).errorFields) { - return; - } - showErrorToast(axiosError); - } finally { - setSubmitting(false); - } - }, [ - entityDisplayName, - entityFqn, - entityType, - form, - onClose, - onCreated, - reviewers, - t, - ]); - - return ( - - - - - } - open={open} - title={ - - {t('label.request-data-access')} - - } - width={520} - onClose={onClose}> -
- - - - - - - {ACCESS_TYPE_OPTIONS.map((option) => { - const disabled = - option.value === DataAccessType.ColumnLevel && - columnOptions.length === 0; - - return ( - - {t(option.labelKey)} - - {t(option.descriptionKey)} - - - ); - })} - - - - {accessType === DataAccessType.ColumnLevel && ( - - ({ - label: t(opt.labelKey), - value: opt.value, - }))} - placeholder={t('label.select-duration')} - /> - - - - - - - - - -
- - ); -}; - -export default DataAccessRequestDrawer; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestList/DataAccessRequestList.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestList/DataAccessRequestList.component.tsx deleted file mode 100644 index a54d7a60aeac..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAccessRequest/DataAccessRequestList/DataAccessRequestList.component.tsx +++ /dev/null @@ -1,473 +0,0 @@ -/* - * Copyright 2026 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Badge, Tabs } from '@openmetadata/ui-core-components'; -import { Button, Input, Select, Space, Table, Typography } from 'antd'; -import { ColumnsType } from 'antd/lib/table'; -import { AxiosError } from 'axios'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - DataAccessType, - listMyAssignedTasks, - listMyCreatedTasks, - Task, - TaskCategory, - TaskEntityType, -} from '../../../rest/tasksAPI'; -import { - formatDuration, - formatExpirationDate, - getDataAccessPayload, - getDisplayStatus, -} from '../../../utils/DataAccessRequest/DataAccessRequestUtils'; -import { showErrorToast } from '../../../utils/ToastUtils'; -import ProfilePicture from '../../common/ProfilePicture/ProfilePicture'; -import { DataAccessTab } from '../DataAccessRequest.interface'; -import DataAccessRequestDetailDrawer from '../DataAccessRequestDetailDrawer/DataAccessRequestDetailDrawer.component'; - -const { Text } = Typography; - -interface DataAccessRequestListProps { - activeTab: DataAccessTab; - onTabChange: (tab: DataAccessTab) => void; -} - -const PAGE_SIZE = 13; - -const STATUS_FILTER_OPTIONS = [ - 'Pending', - 'Approved', - 'Rejected', - 'Expired', - 'Revoked', -]; - -const ACCESS_TYPE_LABELS: Record = { - [DataAccessType.FullAccess]: 'Full Access', - [DataAccessType.ColumnLevel]: 'Column-level', - [DataAccessType.Masked]: 'Masked', -}; - -// Maps the displayed status label to the Badge color from the OM design system. -// Tokens map directly to the Figma's Component colors/Utility palette. -const STATUS_BADGE_COLOR: Record< - string, - 'success' | 'error' | 'warning' | 'gray' | 'orange' -> = { - Approved: 'success', - Pending: 'warning', - Rejected: 'error', - Revoked: 'orange', - Expired: 'gray', -}; - -const formatFigmaDate = (timestamp?: number): string => { - if (!timestamp) { - return '-'; - } - const d = new Date(timestamp); - const months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', - ]; - - return `${d.getDate()}, ${months[d.getMonth()]} ${d.getFullYear()}`; -}; - -const DataAccessRequestList = ({ - activeTab, - onTabChange, -}: DataAccessRequestListProps) => { - const { t } = useTranslation(); - const [tasks, setTasks] = useState([]); - const [loading, setLoading] = useState(false); - const [searchText, setSearchText] = useState(''); - const [statusFilter, setStatusFilter] = useState(); - const [accessTypeFilter, setAccessTypeFilter] = useState< - string | undefined - >(); - const [datasetFilter, setDatasetFilter] = useState(); - const [requestedByFilter, setRequestedByFilter] = useState< - string | undefined - >(); - const [approverFilter, setApproverFilter] = useState(); - const [detailTaskId, setDetailTaskId] = useState(); - - const fetchTasks = useCallback(async () => { - try { - setLoading(true); - const fetcher = - activeTab === 'my-requests' ? listMyCreatedTasks : listMyAssignedTasks; - const response = await fetcher({ - fields: 'about,assignees,resolution,createdBy,reviewers', - limit: 50, - }); - - const filtered = (response.data ?? []).filter( - (task) => - task.category === TaskCategory.DataAccess && - task.type === TaskEntityType.DataAccessRequest - ); - setTasks(filtered); - } catch (error) { - showErrorToast(error as AxiosError); - } finally { - setLoading(false); - } - }, [activeTab]); - - useEffect(() => { - fetchTasks(); - }, [fetchTasks]); - - const datasetOptions = useMemo(() => { - const seen = new Set(); - - return tasks - .map((t) => t.about?.displayName ?? t.about?.name ?? '') - .filter((n) => n && !seen.has(n) && seen.add(n)) - .map((n) => ({ label: n, value: n })); - }, [tasks]); - - const requestedByOptions = useMemo(() => { - const seen = new Set(); - - return tasks - .map((t) => t.createdBy?.displayName ?? t.createdBy?.name ?? '') - .filter((n) => n && !seen.has(n) && seen.add(n)) - .map((n) => ({ label: n, value: n })); - }, [tasks]); - - const approverOptions = useMemo(() => { - const seen = new Set(); - - return tasks - .map( - (t) => - t.resolution?.resolvedBy?.displayName ?? - t.resolution?.resolvedBy?.name ?? - '' - ) - .filter((n) => n && !seen.has(n) && seen.add(n)) - .map((n) => ({ label: n, value: n })); - }, [tasks]); - - const filteredTasks = useMemo(() => { - const trimmed = searchText.trim().toLowerCase(); - - return tasks.filter((task) => { - const dataset = task.about?.displayName ?? task.about?.name ?? ''; - if ( - trimmed && - !task.taskId.toLowerCase().includes(trimmed) && - !dataset.toLowerCase().includes(trimmed) - ) { - return false; - } - if (statusFilter && getDisplayStatus(task) !== statusFilter) { - return false; - } - if ( - accessTypeFilter && - getDataAccessPayload(task)?.accessType !== accessTypeFilter - ) { - return false; - } - if (datasetFilter && dataset !== datasetFilter) { - return false; - } - const createdByName = - task.createdBy?.displayName ?? task.createdBy?.name ?? ''; - if (requestedByFilter && createdByName !== requestedByFilter) { - return false; - } - const approverName = - task.resolution?.resolvedBy?.displayName ?? - task.resolution?.resolvedBy?.name ?? - ''; - if (approverFilter && approverName !== approverFilter) { - return false; - } - - return true; - }); - }, [ - tasks, - searchText, - statusFilter, - accessTypeFilter, - datasetFilter, - requestedByFilter, - approverFilter, - ]); - - const renderUser = ( - user?: { name?: string; displayName?: string } | null - ) => { - if (!user) { - return --; - } - - return ( - - - - {user.displayName ?? user.name ?? '--'} - - - ); - }; - - const columns = useMemo>( - () => [ - { - dataIndex: 'taskId', - key: 'taskId', - title: t('label.task-id'), - width: 110, - render: (taskId: string, record) => ( - - ), - }, - { - key: 'dataset', - title: t('label.dataset'), - render: (_v, record) => ( - - ), - }, - { - key: 'requestedBy', - title: t('label.requested-by'), - render: (_v, record) => renderUser(record.createdBy), - }, - { - key: 'accessType', - title: t('label.access-type'), - render: (_v, record) => { - const at = getDataAccessPayload(record)?.accessType; - - return at ? ACCESS_TYPE_LABELS[at] : '--'; - }, - }, - { - key: 'columns', - title: t('label.columns-requested'), - render: (_v, record) => { - const cols = getDataAccessPayload(record)?.columns ?? []; - if (cols.length === 0) { - return --; - } - const first = cols[0].split('.').pop() ?? cols[0]; - if (cols.length === 1) { - return first; - } - - return ( - - {first} - - +{cols.length - 1} - - - ); - }, - }, - { - key: 'reason', - title: t('label.reason'), - ellipsis: true, - render: (_v, record) => { - const reason = getDataAccessPayload(record)?.reason ?? ''; - if (!reason) { - return --; - } - - return reason.length > 30 ? `${reason.slice(0, 30)}...` : reason; - }, - }, - { - dataIndex: 'status', - key: 'status', - title: t('label.status'), - width: 110, - render: (_v, record) => { - const display = getDisplayStatus(record); - const color = STATUS_BADGE_COLOR[display] ?? 'gray'; - - return ( - - {display} - - ); - }, - }, - { - key: 'requestedOn', - title: t('label.requested-on'), - render: (_v, record) => formatFigmaDate(record.createdAt), - }, - { - key: 'duration', - title: t('label.duration'), - render: (_v, record) => - formatDuration(getDataAccessPayload(record)?.duration), - }, - { - key: 'expiresOn', - title: t('label.expires-on'), - render: (_v, record) => { - const payload = getDataAccessPayload(record); - if (payload?.expirationDate) { - return formatFigmaDate(payload.expirationDate); - } - const computed = formatExpirationDate( - record.createdAt, - payload?.duration - ); - - return formatFigmaDate(computed); - }, - }, - ], - [t] - ); - - return ( -
-
-
- - onTabChange(String(key) as DataAccessTab) - }> - - - {t('label.my-request-plural')} - - - {t('label.my-approval-plural')} - - - - setSearchText(e.target.value)} - /> -
- - ({ - label: s, - value: s, - }))} - placeholder={t('label.status')} - style={{ minWidth: 110 }} - value={statusFilter} - onChange={setStatusFilter} - /> - -