@scality/data-browser-library 1.1.10 → 1.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/components/__tests__/BucketCorsPage.test.js +14 -6
  2. package/dist/components/__tests__/BucketLifecycleFormPage.test.js +25 -0
  3. package/dist/components/__tests__/BucketNotificationFormPage.test.js +15 -3
  4. package/dist/components/buckets/BucketCorsPage.js +5 -7
  5. package/dist/components/buckets/BucketLifecycleFormPage.js +13 -7
  6. package/dist/components/buckets/BucketOverview.js +1 -1
  7. package/dist/components/buckets/BucketPolicyPage.js +4 -7
  8. package/dist/components/buckets/BucketReplicationFormPage.js +12 -6
  9. package/dist/components/buckets/BucketVersioning.js +1 -1
  10. package/dist/components/buckets/notifications/BucketNotificationFormPage.js +2 -2
  11. package/dist/components/buckets/notifications/BucketNotificationList.js +1 -1
  12. package/dist/components/objects/DeleteObjectButton.js +1 -1
  13. package/dist/components/objects/GetPresignedUrlButton.js +1 -1
  14. package/dist/components/objects/ObjectDetails/ObjectMetadata.js +1 -1
  15. package/dist/components/objects/ObjectDetails/ObjectSummary.js +2 -2
  16. package/dist/components/objects/ObjectDetails/ObjectTags.js +1 -1
  17. package/dist/components/objects/ObjectDetails/__tests__/ObjectSummary.test.js +37 -1
  18. package/dist/components/objects/ObjectLock/ObjectLockSettings.js +1 -1
  19. package/dist/components/objects/__tests__/GetPresignedUrlButton.test.js +30 -1
  20. package/dist/components/providers/QueryProvider.js +2 -1
  21. package/dist/hooks/bucketConfiguration.js +15 -15
  22. package/dist/hooks/bucketOperations.js +1 -1
  23. package/dist/hooks/factories/__tests__/useCreateS3FunctionMutationHook.test.js +7 -35
  24. package/dist/hooks/factories/__tests__/useCreateS3InfiniteQueryHook.test.js +1 -1
  25. package/dist/hooks/factories/__tests__/useCreateS3MutationHook.test.js +1 -1
  26. package/dist/hooks/factories/__tests__/useCreateS3QueryHook.test.js +1 -1
  27. package/dist/hooks/factories/useCreateS3InfiniteQueryHook.d.ts +1 -1
  28. package/dist/hooks/factories/useCreateS3InfiniteQueryHook.js +2 -2
  29. package/dist/hooks/factories/useCreateS3MutationHook.d.ts +3 -3
  30. package/dist/hooks/factories/useCreateS3MutationHook.js +20 -8
  31. package/dist/hooks/factories/useCreateS3QueryHook.d.ts +1 -1
  32. package/dist/hooks/factories/useCreateS3QueryHook.js +2 -2
  33. package/dist/hooks/objectOperations.js +6 -6
  34. package/dist/hooks/presignedOperations.js +1 -1
  35. package/dist/hooks/useDeleteFolder.js +1 -1
  36. package/dist/test/utils/errorHandling.test.js +45 -33
  37. package/dist/utils/errorHandling.d.ts +2 -1
  38. package/dist/utils/errorHandling.js +8 -7
  39. package/package.json +1 -1
@@ -306,16 +306,24 @@ describe('BucketCorsPage', ()=>{
306
306
  replace: true
307
307
  });
308
308
  });
309
- it('shows error message when fetching CORS fails with unexpected error', ()=>{
310
- const accessDeniedError = new Error('Access Denied');
311
- accessDeniedError.name = 'AccessDenied';
309
+ it('shows permission message when fetching CORS fails with 403 error', ()=>{
310
+ const authError = new EnhancedS3Error("You don't have permission to load CORS configuration. Contact your administrator.", 'AccessDenied', ErrorCategory.AUTHORIZATION, new Error('Access Denied'), 403);
312
311
  mockUseGetBucketCors.mockReturnValue(createMockQueryResult({
313
312
  status: 'error',
314
- error: accessDeniedError
313
+ error: authError
315
314
  }));
316
315
  renderBucketCorsPage();
317
- expect(screen.getByText(/Failed to load CORS configuration/i)).toBeInTheDocument();
318
- expect(screen.getByText(/Access Denied/i)).toBeInTheDocument();
316
+ expect(screen.getByText("You don't have permission to load CORS configuration. Contact your administrator.")).toBeInTheDocument();
317
+ expect(screen.queryByTestId('cors-editor')).not.toBeInTheDocument();
318
+ });
319
+ it('shows original error message when fetching CORS fails with non-403 error', ()=>{
320
+ const serverError = new EnhancedS3Error('Internal Server Error', 'InternalError', ErrorCategory.SERVER_ERROR, new Error('Internal Server Error'), 500);
321
+ mockUseGetBucketCors.mockReturnValue(createMockQueryResult({
322
+ status: 'error',
323
+ error: serverError
324
+ }));
325
+ renderBucketCorsPage();
326
+ expect(screen.getByText('Internal Server Error')).toBeInTheDocument();
319
327
  expect(screen.queryByTestId('cors-editor')).not.toBeInTheDocument();
320
328
  });
321
329
  });
@@ -441,6 +441,31 @@ describe('BucketLifecycleFormPage', ()=>{
441
441
  });
442
442
  });
443
443
  describe('Rule Data Loading', ()=>{
444
+ it('loads rule with empty prefix filter as "All objects" (no filter)', async ()=>{
445
+ const rule = {
446
+ ID: 'no-filter-rule',
447
+ Status: 'Enabled',
448
+ Filter: {
449
+ Prefix: ''
450
+ },
451
+ Expiration: {
452
+ Days: 30
453
+ }
454
+ };
455
+ mockUseGetBucketLifecycle.mockReturnValue({
456
+ data: {
457
+ Rules: [
458
+ rule
459
+ ]
460
+ },
461
+ status: 'success'
462
+ });
463
+ renderBucketLifecycleFormPage('test-bucket', 'no-filter-rule');
464
+ await waitFor(()=>{
465
+ expect(screen.getByText('All objects')).toBeInTheDocument();
466
+ });
467
+ expect(screen.queryByPlaceholderText('folder/')).not.toBeInTheDocument();
468
+ });
444
469
  it('loads rule with prefix filter correctly', async ()=>{
445
470
  const rule = {
446
471
  ID: 'prefix-rule',
@@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from "react-router";
5
5
  import { useGetBucketNotification, useSetBucketNotification } from "../../hooks/index.js";
6
6
  import { useSupportedNotificationEvents } from "../../hooks/useSupportedNotificationEvents.js";
7
7
  import { createTestWrapper } from "../../test/testUtils.js";
8
+ import { EnhancedS3Error, ErrorCategory } from "../../utils/errorHandling.js";
8
9
  import { BucketNotificationFormPage } from "../buckets/notifications/BucketNotificationFormPage.js";
9
10
  jest.mock('../../hooks', ()=>({
10
11
  useGetBucketNotification: jest.fn(),
@@ -235,14 +236,25 @@ describe('BucketNotificationFormPage', ()=>{
235
236
  renderCreatePage();
236
237
  expect(screen.getByText(/failed to fetch notification configuration/i)).toBeInTheDocument();
237
238
  });
238
- it('shows generic error message when error is not an Error instance', ()=>{
239
+ it('shows permission message when notification data fails to load with 403 error', ()=>{
240
+ const authError = new EnhancedS3Error("You don't have permission to load the notification configuration. Contact your administrator.", 'AccessDenied', ErrorCategory.AUTHORIZATION, new Error('Access Denied'), 403);
239
241
  mockUseGetBucketNotification.mockReturnValue({
240
242
  data: void 0,
241
243
  status: 'error',
242
- error: 'Unknown error'
244
+ error: authError
243
245
  });
244
246
  renderCreatePage();
245
- expect(screen.getByText(/failed to load notification configuration/i)).toBeInTheDocument();
247
+ expect(screen.getByText("You don't have permission to load the notification configuration. Contact your administrator.")).toBeInTheDocument();
248
+ });
249
+ it('shows error message from EnhancedS3Error', ()=>{
250
+ const error = new EnhancedS3Error('Something went wrong', 'InternalError', ErrorCategory.SERVER_ERROR, new Error('Something went wrong'), 500);
251
+ mockUseGetBucketNotification.mockReturnValue({
252
+ data: void 0,
253
+ status: 'error',
254
+ error
255
+ });
256
+ renderCreatePage();
257
+ expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument();
246
258
  });
247
259
  });
248
260
  describe('Edit Mode', ()=>{
@@ -102,7 +102,7 @@ const BucketCorsPage = ()=>{
102
102
  onError: (error)=>{
103
103
  setError('content', {
104
104
  type: 'server',
105
- message: error instanceof Error ? error.message : 'Failed to delete'
105
+ message: error.message
106
106
  });
107
107
  }
108
108
  });
@@ -123,7 +123,7 @@ const BucketCorsPage = ()=>{
123
123
  onError: (error)=>{
124
124
  setError('content', {
125
125
  type: 'server',
126
- message: error instanceof Error ? error.message : 'Failed to save'
126
+ message: error.message
127
127
  });
128
128
  }
129
129
  });
@@ -163,12 +163,9 @@ const BucketCorsPage = ()=>{
163
163
  size: "2x",
164
164
  color: "statusWarning"
165
165
  }),
166
- /*#__PURE__*/ jsxs(Text, {
166
+ /*#__PURE__*/ jsx(Text, {
167
167
  variant: "Large",
168
- children: [
169
- "Failed to load CORS configuration: ",
170
- corsError.message || 'Unknown error'
171
- ]
168
+ children: corsError.message
172
169
  })
173
170
  ]
174
171
  })
@@ -214,6 +211,7 @@ const BucketCorsPage = ()=>{
214
211
  id: "corsConfiguration",
215
212
  label: "CORS Rules",
216
213
  direction: "vertical",
214
+ helpErrorPosition: "bottom",
217
215
  error: errors.content?.message,
218
216
  content: /*#__PURE__*/ jsxs(Stack, {
219
217
  direction: "horizontal",
@@ -209,7 +209,7 @@ const ruleToFormValues = (rule)=>{
209
209
  key: tag.Key || '',
210
210
  value: tag.Value || ''
211
211
  })) || [];
212
- } else if (void 0 !== rule.Filter.Prefix) {
212
+ } else if (void 0 !== rule.Filter.Prefix && '' !== rule.Filter.Prefix) {
213
213
  formValues.filterType = 'prefix';
214
214
  formValues.prefix = rule.Filter.Prefix;
215
215
  } else if (rule.Filter.Tag) {
@@ -347,7 +347,8 @@ function BucketLifecycleFormPage() {
347
347
  understandISVRisk: true
348
348
  }
349
349
  });
350
- const { handleSubmit, register, control, watch, reset, formState: { isValid, isDirty, errors } } = methods;
350
+ const { handleSubmit, register, control, watch, reset, formState: { isValid, dirtyFields, errors } } = methods;
351
+ const hasRuleChanges = Object.keys(dirtyFields).some((key)=>'understandISVRisk' !== key);
351
352
  const { fields: transitionFields, append: appendTransition, remove: removeTransition } = useFieldArray({
352
353
  control,
353
354
  name: 'transitions'
@@ -390,18 +391,23 @@ function BucketLifecycleFormPage() {
390
391
  useEffect(()=>{
391
392
  if (isEditMode && existingRule) {
392
393
  const formValues = ruleToFormValues(existingRule);
393
- reset(formValues);
394
+ reset({
395
+ ...formValues,
396
+ understandISVRisk: !isISVManaged
397
+ });
394
398
  }
395
399
  }, [
396
400
  isEditMode,
397
401
  existingRule,
398
- reset
402
+ reset,
403
+ isISVManaged
399
404
  ]);
400
405
  useEffect(()=>{
401
- methods.setValue('understandISVRisk', !isISVManaged, {
406
+ if (!isEditMode) methods.setValue('understandISVRisk', !isISVManaged, {
402
407
  shouldValidate: true
403
408
  });
404
409
  }, [
410
+ isEditMode,
405
411
  isISVManaged,
406
412
  methods
407
413
  ]);
@@ -496,7 +502,7 @@ function BucketLifecycleFormPage() {
496
502
  navigate(`/buckets/${bucketName}?tab=lifecycle`);
497
503
  },
498
504
  onError: (error)=>{
499
- const errorMessage = error instanceof Error ? error.message : `Failed to ${isEditMode ? 'update' : 'create'} lifecycle rule`;
505
+ const errorMessage = error.message;
500
506
  showToast({
501
507
  open: true,
502
508
  message: errorMessage,
@@ -554,7 +560,7 @@ function BucketLifecycleFormPage() {
554
560
  icon: isEditMode ? /*#__PURE__*/ jsx(Icon, {
555
561
  name: "Save"
556
562
  }) : void 0,
557
- disabled: isEditMode ? !isDirty || !isValid || isSaving : !isValid || isSaving
563
+ disabled: isEditMode ? !hasRuleChanges || !isValid || isSaving : !isValid || isSaving
558
564
  })
559
565
  ]
560
566
  }),
@@ -476,7 +476,7 @@ const PermissionsSection = /*#__PURE__*/ memo(({ onEditPolicy, onEditCors, owner
476
476
  onError: (error)=>{
477
477
  showToast({
478
478
  open: true,
479
- message: error instanceof Error ? error.message : 'Failed to update bucket visibility',
479
+ message: error.message,
480
480
  status: 'error'
481
481
  });
482
482
  }
@@ -94,7 +94,7 @@ const BucketPolicyPage = ()=>{
94
94
  onError: (error)=>{
95
95
  setError('content', {
96
96
  type: 'server',
97
- message: error instanceof Error ? error.message : 'Failed to delete'
97
+ message: error.message
98
98
  });
99
99
  }
100
100
  });
@@ -114,7 +114,7 @@ const BucketPolicyPage = ()=>{
114
114
  onError: (error)=>{
115
115
  setError('content', {
116
116
  type: 'server',
117
- message: error instanceof Error ? error.message : 'Failed to save'
117
+ message: error.message
118
118
  });
119
119
  }
120
120
  });
@@ -153,12 +153,9 @@ const BucketPolicyPage = ()=>{
153
153
  size: "2x",
154
154
  color: "statusWarning"
155
155
  }),
156
- /*#__PURE__*/ jsxs(Text, {
156
+ /*#__PURE__*/ jsx(Text, {
157
157
  variant: "Large",
158
- children: [
159
- "Failed to load bucket policy: ",
160
- policyError.message || 'Unknown error'
161
- ]
158
+ children: policyError.message
162
159
  })
163
160
  ]
164
161
  })
@@ -450,7 +450,8 @@ function BucketReplicationFormPage() {
450
450
  understandISVRisk: true
451
451
  }
452
452
  });
453
- const { handleSubmit, register, control, watch, reset, formState: { isValid, isDirty, errors } } = methods;
453
+ const { handleSubmit, register, control, watch, reset, formState: { isValid, isDirty, dirtyFields, errors } } = methods;
454
+ const hasRuleChanges = Object.keys(dirtyFields).some((key)=>'understandISVRisk' !== key);
454
455
  const { fields: tagFields, append: appendTag, remove: removeTag } = useFieldArray({
455
456
  control,
456
457
  name: 'tags'
@@ -491,7 +492,10 @@ function BucketReplicationFormPage() {
491
492
  if (isEditMode && existingRule) {
492
493
  if (loadedRuleIdRef.current !== existingRule.ID) {
493
494
  const formValues = ruleToFormValues(existingRule, existingRole);
494
- reset(formValues);
495
+ reset({
496
+ ...formValues,
497
+ understandISVRisk: !isISVManaged
498
+ });
495
499
  loadedRuleIdRef.current = existingRule.ID || null;
496
500
  }
497
501
  } else if (!isEditMode && !isDirty) {
@@ -509,7 +513,8 @@ function BucketReplicationFormPage() {
509
513
  replicationRoleDefault,
510
514
  nextAvailablePriority,
511
515
  isDirty,
512
- reset
516
+ reset,
517
+ isISVManaged
513
518
  ]);
514
519
  const prevFilterTypeRef = useRef();
515
520
  useEffect(()=>{
@@ -564,10 +569,11 @@ function BucketReplicationFormPage() {
564
569
  methods
565
570
  ]);
566
571
  useEffect(()=>{
567
- methods.setValue('understandISVRisk', !isISVManaged, {
572
+ if (!isEditMode) methods.setValue('understandISVRisk', !isISVManaged, {
568
573
  shouldValidate: true
569
574
  });
570
575
  }, [
576
+ isEditMode,
571
577
  isISVManaged,
572
578
  methods
573
579
  ]);
@@ -601,7 +607,7 @@ function BucketReplicationFormPage() {
601
607
  navigate(`/buckets/${bucketName}?tab=replication`);
602
608
  },
603
609
  onError: (error)=>{
604
- const errorMessage = error instanceof Error ? error.message : `Failed to ${isEditMode ? 'update' : 'create'} replication rule`;
610
+ const errorMessage = error.message;
605
611
  showToast({
606
612
  open: true,
607
613
  message: errorMessage,
@@ -660,7 +666,7 @@ function BucketReplicationFormPage() {
660
666
  icon: isEditMode ? /*#__PURE__*/ jsx(Icon, {
661
667
  name: "Save"
662
668
  }) : void 0,
663
- disabled: !isDirty || !isValid || isSaving
669
+ disabled: isEditMode ? !hasRuleChanges || !isValid || isSaving : !isValid || isSaving
664
670
  })
665
671
  ]
666
672
  }),
@@ -35,7 +35,7 @@ function BucketVersioning({ tooltipOverlay }) {
35
35
  onError: (error)=>{
36
36
  showToast({
37
37
  open: true,
38
- message: error instanceof Error ? error.message : 'Failed to update bucket versioning',
38
+ message: error.message,
39
39
  status: 'error'
40
40
  });
41
41
  }
@@ -157,7 +157,7 @@ function BucketNotificationFormPage() {
157
157
  navigate(`/buckets/${bucketName}?tab=notification`);
158
158
  },
159
159
  onError: (error)=>{
160
- const errorMessage = error instanceof Error ? error.message : `Failed to ${isEditMode ? 'update' : 'create'} notification rule`;
160
+ const errorMessage = error.message;
161
161
  showToast({
162
162
  open: true,
163
163
  message: errorMessage,
@@ -181,7 +181,7 @@ function BucketNotificationFormPage() {
181
181
  })
182
182
  });
183
183
  if ('error' === notificationStatus && notificationError) {
184
- const errorMessage = notificationError instanceof Error ? notificationError.message : 'Failed to load notification configuration';
184
+ const errorMessage = notificationError.message;
185
185
  return /*#__PURE__*/ jsx(Form, {
186
186
  layout: {
187
187
  kind: 'page',
@@ -45,7 +45,7 @@ function BucketNotificationList({ bucketName, notificationRules, notificationSta
45
45
  });
46
46
  },
47
47
  onError: (error)=>{
48
- const errorMessage = error instanceof Error ? error.message : 'Failed to delete notification rule';
48
+ const errorMessage = error.message;
49
49
  showToast({
50
50
  open: true,
51
51
  message: errorMessage,
@@ -110,7 +110,7 @@ const DeleteObjectButton = ({ objects, bucketName, onDeleteSuccess })=>{
110
110
  } catch (error) {
111
111
  showToast({
112
112
  open: true,
113
- message: error instanceof Error ? error.message : 'Failed to delete objects',
113
+ message: error instanceof Error ? error.message : 'Unknown error',
114
114
  status: 'error'
115
115
  });
116
116
  } finally{
@@ -62,7 +62,7 @@ const GetPresignedUrlButton = ({ bucketName, objectKey, versionId })=>{
62
62
  } catch (error) {
63
63
  showToast({
64
64
  open: true,
65
- message: error instanceof Error ? error.message : 'Failed to generate presigned URL',
65
+ message: error instanceof Error ? error.message : 'Unknown error',
66
66
  status: 'error'
67
67
  });
68
68
  }
@@ -160,7 +160,7 @@ const ObjectMetadata = ({ bucketName, objectKey, versionId })=>{
160
160
  status: 'success'
161
161
  });
162
162
  } catch (error) {
163
- const errorMessage = error instanceof Error ? error.message : 'Failed to save metadata';
163
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
164
164
  showToast({
165
165
  open: true,
166
166
  message: errorMessage,
@@ -106,7 +106,7 @@ const ObjectSummary = ({ bucketName, objectKey, versionId })=>{
106
106
  onError: (error)=>{
107
107
  showToast({
108
108
  open: true,
109
- message: error instanceof Error ? error.message : 'Failed to update legal hold',
109
+ message: error.message,
110
110
  status: 'error'
111
111
  });
112
112
  }
@@ -138,7 +138,7 @@ const ObjectSummary = ({ bucketName, objectKey, versionId })=>{
138
138
  onError: (error)=>{
139
139
  showToast({
140
140
  open: true,
141
- message: error instanceof Error ? error.message : 'Failed to update object visibility',
141
+ message: error.message,
142
142
  status: 'error'
143
143
  });
144
144
  }
@@ -108,7 +108,7 @@ const ObjectTags = ({ bucketName, objectKey, versionId })=>{
108
108
  status: 'success'
109
109
  });
110
110
  } catch (error) {
111
- const errorMessage = error instanceof Error ? error.message : 'Failed to save tags';
111
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
112
112
  showToast({
113
113
  open: true,
114
114
  message: errorMessage,
@@ -6,6 +6,7 @@ import user_event from "@testing-library/user-event";
6
6
  import { DataBrowserUICustomizationProvider } from "../../../../contexts/DataBrowserUICustomizationContext.js";
7
7
  import { useGetPresignedDownload, useGetPublicAccessBlock, useObjectAcl, useObjectLegalHold, useObjectMetadata, useObjectRetention, useSetObjectAcl, useSetObjectLegalHold } from "../../../../hooks/index.js";
8
8
  import { findToggleByLabel } from "../../../../test/testUtils.js";
9
+ import { EnhancedS3Error, ErrorCategory } from "../../../../utils/errorHandling.js";
9
10
  import { ObjectSummary } from "../ObjectSummary.js";
10
11
  jest.mock('../../../../hooks');
11
12
  jest.mock('@scality/core-ui', ()=>{
@@ -663,6 +664,41 @@ describe('ObjectSummary', ()=>{
663
664
  });
664
665
  });
665
666
  });
667
+ it('should show permission message when mutation fails with 403 error', async ()=>{
668
+ const user = user_event.setup();
669
+ const mockShowToast = jest.fn();
670
+ const authError = new EnhancedS3Error("You don't have permission to update legal hold. Contact your administrator.", 'AccessDenied', ErrorCategory.AUTHORIZATION, new Error('Access Denied'), 403);
671
+ const mockMutate = jest.fn((_params, callbacks)=>{
672
+ callbacks.onError(authError);
673
+ });
674
+ mockUseToast.mockReturnValue({
675
+ showToast: mockShowToast
676
+ });
677
+ mockUseObjectRetention.mockReturnValueOnce({
678
+ data: mockRetentionData,
679
+ status: 'success'
680
+ });
681
+ mockUseObjectLegalHold.mockReturnValueOnce({
682
+ data: mockLegalHoldData,
683
+ status: 'success'
684
+ });
685
+ mockUseSetObjectLegalHold.mockReturnValueOnce({
686
+ mutate: mockMutate,
687
+ isPending: false
688
+ });
689
+ renderWithProviders(/*#__PURE__*/ jsx(ObjectSummary, {
690
+ ...defaultProps
691
+ }));
692
+ const toggle = getLegalHoldToggle();
693
+ await user.click(toggle);
694
+ await waitFor(()=>{
695
+ expect(mockShowToast).toHaveBeenCalledWith({
696
+ open: true,
697
+ message: "You don't have permission to update legal hold. Contact your administrator.",
698
+ status: 'error'
699
+ });
700
+ });
701
+ });
666
702
  it('should show generic error message when mutation fails with non-Error object', async ()=>{
667
703
  const user = user_event.setup();
668
704
  const mockShowToast = jest.fn();
@@ -694,7 +730,7 @@ describe('ObjectSummary', ()=>{
694
730
  await waitFor(()=>{
695
731
  expect(mockShowToast).toHaveBeenCalledWith({
696
732
  open: true,
697
- message: 'Failed to update legal hold',
733
+ message: 'Custom error',
698
734
  status: 'error'
699
735
  });
700
736
  });
@@ -102,7 +102,7 @@ function ObjectLockSettings() {
102
102
  navigate(`/buckets/${bucketName}`);
103
103
  },
104
104
  onError: (error)=>{
105
- const errorMessage = error instanceof Error ? error.message : 'Failed to save Object Lock settings';
105
+ const errorMessage = error.message;
106
106
  showToast({
107
107
  open: true,
108
108
  message: errorMessage,
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
4
  import { cleanup, render, screen, waitFor } from "@testing-library/react";
5
5
  import user_event from "@testing-library/user-event";
6
6
  import { useGetPresignedDownload } from "../../../hooks/index.js";
7
+ import { EnhancedS3Error, ErrorCategory } from "../../../utils/errorHandling.js";
7
8
  import { GetPresignedUrlButton } from "../GetPresignedUrlButton.js";
8
9
  jest.mock('../../../hooks');
9
10
  jest.mock('@scality/core-ui', ()=>{
@@ -462,6 +463,34 @@ describe('GetPresignedUrlButton', ()=>{
462
463
  });
463
464
  });
464
465
  });
466
+ it('should show permission message for 403 errors', async ()=>{
467
+ const mockShowToast = jest.fn();
468
+ mockUseToast.mockReturnValue({
469
+ showToast: mockShowToast
470
+ });
471
+ const authError = new EnhancedS3Error("You don't have permission to generate the presigned URL. Contact your administrator.", 'AccessDenied', ErrorCategory.AUTHORIZATION, new Error('Access Denied'), 403);
472
+ mockUseGetPresignedDownload.mockReturnValue({
473
+ mutateAsync: jest.fn().mockRejectedValue(authError),
474
+ isPending: false
475
+ });
476
+ const user = user_event.setup();
477
+ renderWithProviders(/*#__PURE__*/ jsx(GetPresignedUrlButton, {
478
+ ...defaultProps
479
+ }));
480
+ await user.click(screen.getByRole('button', {
481
+ name: /get pre-signed url/i
482
+ }));
483
+ await user.click(screen.getByRole('button', {
484
+ name: /generate a pre-signed url/i
485
+ }));
486
+ await waitFor(()=>{
487
+ expect(mockShowToast).toHaveBeenCalledWith({
488
+ open: true,
489
+ message: "You don't have permission to generate the presigned URL. Contact your administrator.",
490
+ status: 'error'
491
+ });
492
+ });
493
+ });
465
494
  it('should show generic toast for non-Error failures', async ()=>{
466
495
  const mockShowToast = jest.fn();
467
496
  mockUseToast.mockReturnValue({
@@ -484,7 +513,7 @@ describe('GetPresignedUrlButton', ()=>{
484
513
  await waitFor(()=>{
485
514
  expect(mockShowToast).toHaveBeenCalledWith({
486
515
  open: true,
487
- message: 'Failed to generate presigned URL',
516
+ message: 'Unknown error',
488
517
  status: 'error'
489
518
  });
490
519
  });
@@ -1,11 +1,12 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
3
  import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
4
+ import { shouldRetryError } from "../../utils/errorHandling.js";
4
5
  const defaultQueryClient = new QueryClient({
5
6
  defaultOptions: {
6
7
  queries: {
7
8
  staleTime: 300000,
8
- retry: 2
9
+ retry: (failureCount, error)=>shouldRetryError(error, failureCount, 2)
9
10
  }
10
11
  }
11
12
  });
@@ -5,40 +5,40 @@ const useGetBucketAcl = useCreateS3QueryHook(GetBucketAclCommand, 'GetBucketAcl'
5
5
  const useSetBucketAcl = useCreateS3MutationHook(PutBucketAclCommand, 'PutBucketAcl', [
6
6
  'GetBucketAcl',
7
7
  'ListBuckets'
8
- ]);
9
- const useGetBucketPolicy = useCreateS3QueryHook(GetBucketPolicyCommand, 'GetBucketPolicy');
8
+ ], 'update bucket visibility');
9
+ const useGetBucketPolicy = useCreateS3QueryHook(GetBucketPolicyCommand, 'GetBucketPolicy', 'load the bucket policy');
10
10
  const useSetBucketPolicy = useCreateS3MutationHook(PutBucketPolicyCommand, 'PutBucketPolicy', [
11
11
  'GetBucketPolicy',
12
12
  'ListBuckets'
13
- ]);
13
+ ], 'save the bucket policy');
14
14
  const useDeleteBucketPolicy = useCreateS3MutationHook(DeleteBucketPolicyCommand, 'DeleteBucketPolicy', [
15
15
  'GetBucketPolicy'
16
- ]);
16
+ ], 'delete the bucket policy');
17
17
  const useGetBucketVersioning = useCreateS3QueryHook(GetBucketVersioningCommand, 'GetBucketVersioning');
18
18
  const useSetBucketVersioning = useCreateS3MutationHook(PutBucketVersioningCommand, 'PutBucketVersioning', [
19
19
  'GetBucketVersioning'
20
- ]);
21
- const useGetBucketCors = useCreateS3QueryHook(GetBucketCorsCommand, 'GetBucketCors');
20
+ ], 'update bucket versioning');
21
+ const useGetBucketCors = useCreateS3QueryHook(GetBucketCorsCommand, 'GetBucketCors', 'load CORS configuration');
22
22
  const useSetBucketCors = useCreateS3MutationHook(PutBucketCorsCommand, 'PutBucketCors', [
23
23
  'GetBucketCors',
24
24
  'ListBuckets'
25
- ]);
25
+ ], 'save the CORS configuration');
26
26
  const useDeleteBucketCors = useCreateS3MutationHook(DeleteBucketCorsCommand, 'DeleteBucketCors', [
27
27
  'GetBucketCors'
28
- ]);
28
+ ], 'delete the CORS rule');
29
29
  const useGetBucketLifecycle = useCreateS3QueryHook(GetBucketLifecycleConfigurationCommand, 'GetBucketLifecycleConfiguration');
30
30
  const useSetBucketLifecycle = useCreateS3MutationHook(PutBucketLifecycleConfigurationCommand, 'PutBucketLifecycleConfiguration', [
31
31
  'GetBucketLifecycleConfiguration',
32
32
  'ListBuckets'
33
- ]);
33
+ ], 'save the lifecycle rule');
34
34
  const useDeleteBucketLifecycle = useCreateS3MutationHook(DeleteBucketLifecycleCommand, 'DeleteBucketLifecycle', [
35
35
  'GetBucketLifecycleConfiguration'
36
- ]);
37
- const useGetBucketNotification = useCreateS3QueryHook(GetBucketNotificationConfigurationCommand, 'GetBucketNotificationConfiguration');
36
+ ], 'delete the lifecycle rule');
37
+ const useGetBucketNotification = useCreateS3QueryHook(GetBucketNotificationConfigurationCommand, 'GetBucketNotificationConfiguration', 'load the notification configuration');
38
38
  const useSetBucketNotification = useCreateS3MutationHook(PutBucketNotificationConfigurationCommand, 'PutBucketNotificationConfiguration', [
39
39
  'GetBucketNotificationConfiguration',
40
40
  'ListBuckets'
41
- ]);
41
+ ], 'save the notification configuration');
42
42
  const useGetBucketEncryption = useCreateS3QueryHook(GetBucketEncryptionCommand, 'GetBucketEncryption');
43
43
  const useSetBucketEncryption = useCreateS3MutationHook(PutBucketEncryptionCommand, 'PutBucketEncryption', [
44
44
  'GetBucketEncryption',
@@ -55,15 +55,15 @@ const useDeleteBucketTagging = useCreateS3MutationHook(DeleteBucketTaggingComman
55
55
  const useGetBucketObjectLockConfiguration = useCreateS3QueryHook(GetObjectLockConfigurationCommand, 'GetObjectLockConfiguration');
56
56
  const useSetBucketObjectLockConfiguration = useCreateS3MutationHook(PutObjectLockConfigurationCommand, 'PutObjectLockConfiguration', [
57
57
  'GetObjectLockConfiguration'
58
- ]);
58
+ ], 'save Object Lock settings');
59
59
  const useGetBucketReplication = useCreateS3QueryHook(GetBucketReplicationCommand, 'GetBucketReplication');
60
60
  const useSetBucketReplication = useCreateS3MutationHook(PutBucketReplicationCommand, 'PutBucketReplication', [
61
61
  'GetBucketReplication',
62
62
  'ListBuckets'
63
- ]);
63
+ ], 'save the replication rule');
64
64
  const useDeleteBucketReplication = useCreateS3MutationHook(DeleteBucketReplicationCommand, 'DeleteBucketReplication', [
65
65
  'GetBucketReplication'
66
- ]);
66
+ ], 'delete the replication rule');
67
67
  const useGetPublicAccessBlock = useCreateS3QueryHook(GetPublicAccessBlockCommand, 'GetPublicAccessBlock');
68
68
  const usePutPublicAccessBlock = useCreateS3MutationHook(PutPublicAccessBlockCommand, 'PutPublicAccessBlock', [
69
69
  'GetPublicAccessBlock',
@@ -5,7 +5,7 @@ const useBuckets = useCreateS3QueryHook(ListBucketsCommand, 'ListBuckets');
5
5
  const useGetBucketLocation = useCreateS3QueryHook(GetBucketLocationCommand, 'GetBucketLocation');
6
6
  const useCreateBucket = useCreateS3MutationHook(CreateBucketCommand, 'CreateBucket', [
7
7
  'ListBuckets'
8
- ]);
8
+ ], 'create the bucket');
9
9
  const useDeleteBucket = useCreateS3MutationHook(DeleteBucketCommand, 'DeleteBucket', [
10
10
  'ListBuckets'
11
11
  ]);
@@ -61,9 +61,13 @@ describe('useCreateS3FunctionMutationHook - Factory Specific', ()=>{
61
61
  expect(mockOperation).toHaveBeenCalledWith(mockS3Client, inputParams);
62
62
  expect(mockOperation).toHaveBeenCalledTimes(1);
63
63
  });
64
- it('should handle function operation errors', async ()=>{
64
+ it('should handle function operation errors by wrapping with createS3OperationError', async ()=>{
65
65
  const mockError = new Error('Presigned URL generation failed');
66
+ const enhancedError = {
67
+ message: 'Enhanced error'
68
+ };
66
69
  mockOperation.mockRejectedValue(mockError);
70
+ mockCreateS3OperationError.mockReturnValue(enhancedError);
67
71
  const useMutation = useCreateS3FunctionMutationHook(mockOperation);
68
72
  const { result } = renderHook(()=>useMutation(), {
69
73
  wrapper: createTestWrapper()
@@ -75,40 +79,8 @@ describe('useCreateS3FunctionMutationHook - Factory Specific', ()=>{
75
79
  await waitFor(()=>{
76
80
  expect(result.current.isError).toBe(true);
77
81
  });
78
- expect(result.current.error).toBe(mockError);
79
- expect(mockOperation).toHaveBeenCalledWith(mockS3Client, {
80
- Bucket: 'test-bucket',
81
- Key: 'test-key'
82
- });
83
- expect(mockCreateS3OperationError).not.toHaveBeenCalled();
84
- });
85
- it('should handle function operation with internal error wrapping', async ()=>{
86
- const mockInternalError = new Error('Internal operation error');
87
- const mockWrappedError = new Error('Wrapped operation error');
88
- const mockOperationWithErrorHandling = jest.fn().mockImplementation(async ()=>{
89
- try {
90
- throw mockInternalError;
91
- } catch (error) {
92
- const wrappedError = mockWrappedError;
93
- throw wrappedError;
94
- }
95
- });
96
- const useMutation = useCreateS3FunctionMutationHook(mockOperationWithErrorHandling);
97
- const { result } = renderHook(()=>useMutation(), {
98
- wrapper: createTestWrapper()
99
- });
100
- result.current.mutate({
101
- Bucket: 'test-bucket',
102
- Key: 'test-key'
103
- });
104
- await waitFor(()=>{
105
- expect(result.current.isError).toBe(true);
106
- });
107
- expect(result.current.error).toBe(mockWrappedError);
108
- expect(mockOperationWithErrorHandling).toHaveBeenCalledWith(mockS3Client, {
109
- Bucket: 'test-bucket',
110
- Key: 'test-key'
111
- });
82
+ expect(result.current.error).toBe(enhancedError);
83
+ expect(mockCreateS3OperationError).toHaveBeenCalledWith(mockError, 'FunctionMutation', void 0, void 0, void 0);
112
84
  });
113
85
  it('should handle mutation callbacks correctly', async ()=>{
114
86
  const mockResponse = {
@@ -173,7 +173,7 @@ describe('useCreateS3InfiniteQueryHook - Pagination Logic', ()=>{
173
173
  expect(result.current.isError).toBe(true);
174
174
  });
175
175
  expect(result.current.error).toBe(enhancedError);
176
- expect(mockCreateS3OperationError).toHaveBeenCalledWith(mockError, 'ListObjects', 'test-bucket');
176
+ expect(mockCreateS3OperationError).toHaveBeenCalledWith(mockError, 'ListObjects', 'test-bucket', void 0, void 0);
177
177
  });
178
178
  it('should handle fetchNextPage correctly', async ()=>{
179
179
  const mockFirstPage = {
@@ -84,7 +84,7 @@ describe('useCreateS3MutationHook - Factory Specific', ()=>{
84
84
  expect(result.current.isError).toBe(true);
85
85
  });
86
86
  expect(result.current.error).toBe(enhancedError);
87
- expect(mockCreateS3OperationError).toHaveBeenCalledWith(mockError, 'PutObject', 'test-bucket');
87
+ expect(mockCreateS3OperationError).toHaveBeenCalledWith(mockError, 'PutObject', 'test-bucket', void 0, void 0);
88
88
  });
89
89
  it('should handle mutation callbacks correctly', async ()=>{
90
90
  const mockResponse = {
@@ -84,7 +84,7 @@ describe('useCreateS3QueryHook - Factory Specific', ()=>{
84
84
  expect(result.current.isError).toBe(true);
85
85
  });
86
86
  expect(result.current.error).toBe(enhancedError);
87
- expect(mockCreateS3OperationError).toHaveBeenCalledWith(mockError, 'testOperation', 'test-bucket');
87
+ expect(mockCreateS3OperationError).toHaveBeenCalledWith(mockError, 'testOperation', 'test-bucket', void 0, void 0);
88
88
  });
89
89
  it('should handle parameter validation correctly', ()=>{
90
90
  const useTestQuery = useCreateS3QueryHook(MockCommand, 'testOperation');
@@ -10,4 +10,4 @@ import { type EnhancedS3Error } from '../../utils/errorHandling';
10
10
  * - AbortSignal support for cancellation
11
11
  * - Pagination parameters and logic
12
12
  */
13
- export declare function useCreateS3InfiniteQueryHook<TInput extends object, TOutput>(Command: new (input: TInput) => any, operationName: string): (params: TInput, options?: Partial<UseInfiniteQueryOptions<TOutput, EnhancedS3Error, TOutput, (string | TInput)[], string | undefined>>) => UseInfiniteQueryResult<TOutput, EnhancedS3Error>;
13
+ export declare function useCreateS3InfiniteQueryHook<TInput extends object, TOutput>(Command: new (input: TInput) => any, operationName: string, operationLabel?: string): (params: TInput, options?: Partial<UseInfiniteQueryOptions<TOutput, EnhancedS3Error, TOutput, (string | TInput)[], string | undefined>>) => UseInfiniteQueryResult<TOutput, EnhancedS3Error>;
@@ -2,7 +2,7 @@ import { useInfiniteQuery } from "@tanstack/react-query";
2
2
  import { useDataBrowserContext } from "../../components/providers/DataBrowserProvider.js";
3
3
  import { createS3OperationError, shouldRetryError } from "../../utils/errorHandling.js";
4
4
  import { useS3Client } from "../useS3Client.js";
5
- function useCreateS3InfiniteQueryHook(Command, operationName) {
5
+ function useCreateS3InfiniteQueryHook(Command, operationName, operationLabel) {
6
6
  return (params, options)=>{
7
7
  const { s3ConfigIdentifier } = useDataBrowserContext();
8
8
  const s3Client = useS3Client();
@@ -24,7 +24,7 @@ function useCreateS3InfiniteQueryHook(Command, operationName) {
24
24
  return response;
25
25
  } catch (error) {
26
26
  const bucketName = 'Bucket' in params ? params.Bucket : void 0;
27
- throw createS3OperationError(error, operationName, bucketName);
27
+ throw createS3OperationError(error, operationName, bucketName, void 0, operationLabel);
28
28
  }
29
29
  },
30
30
  retry: options?.retry ?? ((failureCount, error)=>shouldRetryError(error, failureCount)),
@@ -1,7 +1,7 @@
1
1
  import type { S3Client } from '@aws-sdk/client-s3';
2
2
  import { type UseMutationOptions, type UseMutationResult } from '@tanstack/react-query';
3
3
  import { type EnhancedS3Error } from '../../utils/errorHandling';
4
- export declare function useCreateS3MutationHook<TInput extends object, TOutput>(Command: new (input: TInput) => any, operationName: string, invalidationKeys?: string[]): (options?: Omit<UseMutationOptions<TOutput, EnhancedS3Error, TInput>, "mutationFn">) => UseMutationResult<TOutput, EnhancedS3Error, TInput>;
5
- export declare function useCreateS3FunctionMutationHook<TInput, TOutput>(operation: (s3Client: S3Client, input: TInput) => Promise<TOutput>, invalidationKeys?: string[]): (options?: Omit<UseMutationOptions<TOutput, EnhancedS3Error, TInput, unknown>, "mutationFn"> | undefined) => UseMutationResult<TOutput, EnhancedS3Error, TInput>;
4
+ export declare function useCreateS3MutationHook<TInput extends object, TOutput>(Command: new (input: TInput) => any, operationName: string, invalidationKeys?: string[], operationLabel?: string): (options?: Omit<UseMutationOptions<TOutput, EnhancedS3Error, TInput>, "mutationFn">) => UseMutationResult<TOutput, EnhancedS3Error, TInput>;
5
+ export declare function useCreateS3FunctionMutationHook<TInput, TOutput>(operation: (s3Client: S3Client, input: TInput) => Promise<TOutput>, invalidationKeys?: string[], operationLabel?: string): (options?: Omit<UseMutationOptions<TOutput, EnhancedS3Error, TInput, unknown>, "mutationFn"> | undefined) => UseMutationResult<TOutput, EnhancedS3Error, TInput>;
6
6
  export type UrlRewriter = (url: string) => string;
7
- export declare function useCreatePresigningMutationHook<TInput, TOutput>(operation: (s3Client: S3Client, input: TInput, rewriteUrl: UrlRewriter) => Promise<TOutput>, invalidationKeys?: string[]): (options?: Omit<UseMutationOptions<TOutput, EnhancedS3Error, TInput>, "mutationFn">) => UseMutationResult<TOutput, EnhancedS3Error, TInput>;
7
+ export declare function useCreatePresigningMutationHook<TInput, TOutput>(operation: (s3Client: S3Client, input: TInput, rewriteUrl: UrlRewriter) => Promise<TOutput>, invalidationKeys?: string[], operationLabel?: string): (options?: Omit<UseMutationOptions<TOutput, EnhancedS3Error, TInput>, "mutationFn">) => UseMutationResult<TOutput, EnhancedS3Error, TInput>;
@@ -3,7 +3,7 @@ import { useDataBrowserContext } from "../../components/providers/DataBrowserPro
3
3
  import { createS3OperationError } from "../../utils/errorHandling.js";
4
4
  import { usePresigningS3Client } from "../usePresigningS3Client.js";
5
5
  import { useS3Client } from "../useS3Client.js";
6
- function useCreateS3MutationHook(Command, operationName, invalidationKeys) {
6
+ function useCreateS3MutationHook(Command, operationName, invalidationKeys, operationLabel) {
7
7
  return (options)=>{
8
8
  const { s3ConfigIdentifier } = useDataBrowserContext();
9
9
  const s3Client = useS3Client();
@@ -15,7 +15,7 @@ function useCreateS3MutationHook(Command, operationName, invalidationKeys) {
15
15
  return await s3Client.send(command);
16
16
  } catch (error) {
17
17
  const bucketName = 'Bucket' in params ? params.Bucket : void 0;
18
- throw createS3OperationError(error, operationName, bucketName);
18
+ throw createS3OperationError(error, operationName, bucketName, void 0, operationLabel);
19
19
  }
20
20
  },
21
21
  onSuccess: (_data, _variables)=>{
@@ -32,13 +32,19 @@ function useCreateS3MutationHook(Command, operationName, invalidationKeys) {
32
32
  });
33
33
  };
34
34
  }
35
- function createFunctionMutationHook(operation, useClient, invalidationKeys) {
35
+ function createFunctionMutationHook(operation, useClient, invalidationKeys, operationLabel) {
36
36
  return (options)=>{
37
37
  const { s3ConfigIdentifier } = useDataBrowserContext();
38
38
  const s3Client = useClient();
39
39
  const queryClient = useQueryClient();
40
40
  return useMutation({
41
- mutationFn: async (params)=>operation(s3Client, params),
41
+ mutationFn: async (params)=>{
42
+ try {
43
+ return await operation(s3Client, params);
44
+ } catch (error) {
45
+ throw createS3OperationError(error, 'FunctionMutation', void 0, void 0, operationLabel);
46
+ }
47
+ },
42
48
  onSuccess: (_data, _variables)=>{
43
49
  if (invalidationKeys) invalidationKeys.forEach((key)=>{
44
50
  queryClient.invalidateQueries({
@@ -53,16 +59,22 @@ function createFunctionMutationHook(operation, useClient, invalidationKeys) {
53
59
  });
54
60
  };
55
61
  }
56
- function useCreateS3FunctionMutationHook(operation, invalidationKeys) {
57
- return createFunctionMutationHook(operation, useS3Client, invalidationKeys);
62
+ function useCreateS3FunctionMutationHook(operation, invalidationKeys, operationLabel) {
63
+ return createFunctionMutationHook(operation, useS3Client, invalidationKeys, operationLabel);
58
64
  }
59
- function useCreatePresigningMutationHook(operation, invalidationKeys) {
65
+ function useCreatePresigningMutationHook(operation, invalidationKeys, operationLabel) {
60
66
  return (options)=>{
61
67
  const { s3ConfigIdentifier } = useDataBrowserContext();
62
68
  const { client, rewriteUrl } = usePresigningS3Client();
63
69
  const queryClient = useQueryClient();
64
70
  return useMutation({
65
- mutationFn: async (params)=>operation(client, params, rewriteUrl),
71
+ mutationFn: async (params)=>{
72
+ try {
73
+ return await operation(client, params, rewriteUrl);
74
+ } catch (error) {
75
+ throw createS3OperationError(error, 'PresigningMutation', void 0, void 0, operationLabel);
76
+ }
77
+ },
66
78
  onSuccess: (_data, _variables)=>{
67
79
  if (invalidationKeys) invalidationKeys.forEach((key)=>{
68
80
  queryClient.invalidateQueries({
@@ -1,4 +1,4 @@
1
1
  import { type QueryKey, type UseQueryOptions, type UseQueryResult } from '@tanstack/react-query';
2
2
  import { type EnhancedS3Error } from '../../utils/errorHandling';
3
3
  export declare function createS3QueryKey(s3ConfigIdentifier: string, operationName: string, params?: object): QueryKey;
4
- export declare function useCreateS3QueryHook<TInput extends object, TOutput>(Command: new (input: TInput) => any, operationName: string): (params?: TInput, options?: Omit<UseQueryOptions<TOutput, EnhancedS3Error, TOutput, QueryKey>, "queryKey" | "queryFn">) => UseQueryResult<TOutput, EnhancedS3Error>;
4
+ export declare function useCreateS3QueryHook<TInput extends object, TOutput>(Command: new (input: TInput) => any, operationName: string, operationLabel?: string): (params?: TInput, options?: Omit<UseQueryOptions<TOutput, EnhancedS3Error, TOutput, QueryKey>, "queryKey" | "queryFn">) => UseQueryResult<TOutput, EnhancedS3Error>;
@@ -28,7 +28,7 @@ function createS3QueryKey(s3ConfigIdentifier, operationName, params) {
28
28
  params
29
29
  ];
30
30
  }
31
- function useCreateS3QueryHook(Command, operationName) {
31
+ function useCreateS3QueryHook(Command, operationName, operationLabel) {
32
32
  return (params, options)=>{
33
33
  const { s3ConfigIdentifier } = useDataBrowserContext();
34
34
  const s3Client = useS3Client();
@@ -47,7 +47,7 @@ function useCreateS3QueryHook(Command, operationName) {
47
47
  const emptyConfig = getEmptyConfigForOperation(operationName);
48
48
  if (null !== emptyConfig) return emptyConfig;
49
49
  }
50
- throw createS3OperationError(error, operationName, bucketName);
50
+ throw createS3OperationError(error, operationName, bucketName, void 0, operationLabel);
51
51
  }
52
52
  },
53
53
  retry: options?.retry ?? ((failureCount, error)=>shouldRetryError(error, failureCount)),
@@ -30,12 +30,12 @@ const useDeleteObjects = useCreateS3MutationHook(DeleteObjectsCommand, 'DeleteOb
30
30
  'ListObjectVersions',
31
31
  'SearchObjects',
32
32
  'SearchObjectsVersions'
33
- ]);
33
+ ], 'delete objects');
34
34
  const useCopyObject = useCreateS3MutationHook(CopyObjectCommand, 'CopyObject', [
35
35
  'ListObjects',
36
36
  'SearchObjects',
37
37
  'HeadObject'
38
- ]);
38
+ ], 'save metadata');
39
39
  const useObjectRetention = useCreateS3QueryHook(GetObjectRetentionCommand, 'GetObjectRetention');
40
40
  const useSetObjectRetention = useCreateS3MutationHook(PutObjectRetentionCommand, 'PutObjectRetention', [
41
41
  'GetObjectRetention'
@@ -44,18 +44,18 @@ const useObjectLegalHold = useCreateS3QueryHook(GetObjectLegalHoldCommand, 'GetO
44
44
  const useSetObjectLegalHold = useCreateS3MutationHook(PutObjectLegalHoldCommand, 'PutObjectLegalHold', [
45
45
  'GetObjectLegalHold',
46
46
  'BatchObjectLegalHold'
47
- ]);
47
+ ], 'update legal hold');
48
48
  const useObjectTagging = useCreateS3QueryHook(GetObjectTaggingCommand, 'GetObjectTagging');
49
49
  const useSetObjectTagging = useCreateS3MutationHook(PutObjectTaggingCommand, 'PutObjectTagging', [
50
50
  'GetObjectTagging'
51
- ]);
51
+ ], 'save tags');
52
52
  const useDeleteObjectTagging = useCreateS3MutationHook(DeleteObjectTaggingCommand, 'DeleteObjectTagging', [
53
53
  'GetObjectTagging'
54
- ]);
54
+ ], 'save tags');
55
55
  const useObjectAcl = useCreateS3QueryHook(GetObjectAclCommand, 'GetObjectAcl');
56
56
  const useSetObjectAcl = useCreateS3MutationHook(PutObjectAclCommand, 'PutObjectAcl', [
57
57
  'GetObjectAcl'
58
- ]);
58
+ ], 'update object visibility');
59
59
  const useGetObjectAttributes = useCreateS3QueryHook(GetObjectAttributesCommand, 'GetObjectAttributes');
60
60
  const useGetObjectTorrent = useCreateS3QueryHook(GetObjectTorrentCommand, 'GetObjectTorrent');
61
61
  const useRestoreObject = useCreateS3MutationHook(RestoreObjectCommand, 'RestoreObject', [
@@ -67,7 +67,7 @@ const generatePresignedPost = async (client, config, rewriteUrl)=>{
67
67
  throw createS3OperationError(error, 'GeneratePresignedPost', config.Bucket, config.Key);
68
68
  }
69
69
  };
70
- const useGetPresignedDownload = useCreatePresigningMutationHook(generatePresignedDownloadUrl);
70
+ const useGetPresignedDownload = useCreatePresigningMutationHook(generatePresignedDownloadUrl, void 0, 'generate the presigned URL');
71
71
  const useGetPresignedUpload = useCreatePresigningMutationHook(generatePresignedUploadUrl);
72
72
  const useGetPresignedPost = useCreatePresigningMutationHook(generatePresignedPost);
73
73
  export { useGetPresignedDownload, useGetPresignedPost, useGetPresignedUpload };
@@ -36,5 +36,5 @@ async function deleteFolder(s3Client, input) {
36
36
  }
37
37
  }
38
38
  }
39
- const useDeleteFolder = useCreateS3FunctionMutationHook(deleteFolder);
39
+ const useDeleteFolder = useCreateS3FunctionMutationHook(deleteFolder, void 0, 'delete the folder');
40
40
  export { useDeleteFolder };
@@ -117,6 +117,18 @@ describe('EnhancedS3Error', ()=>{
117
117
  expect(TestEnhancedErrors.unknown().shouldRetry()).toBe(false);
118
118
  });
119
119
  });
120
+ describe('getUserMessage', ()=>{
121
+ test('returns permission message for AUTHORIZATION errors', ()=>{
122
+ expect(TestEnhancedErrors.auth().getUserMessage('save the bucket policy')).toBe("You don't have permission to save the bucket policy. Contact your administrator.");
123
+ });
124
+ test('returns original message for non-authorization errors', ()=>{
125
+ expect(TestEnhancedErrors.server().getUserMessage('save tags')).toBe('Server error');
126
+ });
127
+ test('returns fallback when message is empty', ()=>{
128
+ const error = new EnhancedS3Error('', 'EmptyError', ErrorCategory.SERVER_ERROR, new Error());
129
+ expect(error.getUserMessage('save tags')).toBe('Failed to save tags');
130
+ });
131
+ });
120
132
  });
121
133
  describe('Type Guards', ()=>{
122
134
  describe('isS3ServiceException', ()=>{
@@ -272,39 +284,15 @@ describe('createS3Error', ()=>{
272
284
  });
273
285
  });
274
286
  describe('createS3OperationError', ()=>{
275
- test('message modification scenarios', ()=>{
276
- const testCases = [
277
- {
278
- name: 'preserves existing operation in message',
279
- error: new Error('ListObjectsV2 failed: Access denied'),
280
- operation: 'ListObjectsV2',
281
- bucket: TEST_CONSTANTS.BUCKET,
282
- expectedMessage: 'ListObjectsV2 failed: Access denied'
283
- },
284
- {
285
- name: 'enhances message with operation context',
286
- error: new Error(TEST_CONSTANTS.MESSAGES.ACCESS_DENIED),
287
- operation: 'GetObject',
288
- bucket: TEST_CONSTANTS.BUCKET,
289
- key: TEST_CONSTANTS.KEY,
290
- expectedMessage: `GetObject failed for bucket '${TEST_CONSTANTS.BUCKET}', key '${TEST_CONSTANTS.KEY}': ${TEST_CONSTANTS.MESSAGES.ACCESS_DENIED}`
291
- },
292
- {
293
- name: 'handles operation without bucket',
294
- error: new Error(TEST_CONSTANTS.MESSAGES.SERVICE_UNAVAILABLE),
295
- operation: 'ListBuckets',
296
- expectedMessage: `ListBuckets failed: ${TEST_CONSTANTS.MESSAGES.SERVICE_UNAVAILABLE}`
297
- }
298
- ];
299
- testCases.forEach(({ error, operation, bucket, key, expectedMessage })=>{
300
- const result = createS3OperationError(error, operation, bucket, key);
301
- expect(result.message).toBe(expectedMessage);
302
- expect(result.context).toEqual({
303
- operation,
304
- bucketName: bucket || void 0,
305
- objectKey: key || void 0,
306
- timestamp: expect.any(String)
307
- });
287
+ test('preserves original error message without operationLabel', ()=>{
288
+ const error = new Error(TEST_CONSTANTS.MESSAGES.ACCESS_DENIED);
289
+ const result = createS3OperationError(error, 'GetObject', TEST_CONSTANTS.BUCKET, TEST_CONSTANTS.KEY);
290
+ expect(result.message).toBe(TEST_CONSTANTS.MESSAGES.ACCESS_DENIED);
291
+ expect(result.context).toEqual({
292
+ operation: 'GetObject',
293
+ bucketName: TEST_CONSTANTS.BUCKET,
294
+ objectKey: TEST_CONSTANTS.KEY,
295
+ timestamp: expect.any(String)
308
296
  });
309
297
  });
310
298
  test('preserves all error properties', ()=>{
@@ -411,6 +399,30 @@ describe('isNotFoundError', ()=>{
411
399
  expect(result).toBe(false);
412
400
  });
413
401
  });
402
+ describe('createS3OperationError with operationLabel', ()=>{
403
+ test('embeds permission message for 403 errors', ()=>{
404
+ const error = TestErrors.accessDenied();
405
+ const result = createS3OperationError(error, 'PutBucketPolicy', 'my-bucket', void 0, 'save the bucket policy');
406
+ expect(result.message).toBe("You don't have permission to save the bucket policy. Contact your administrator.");
407
+ });
408
+ test('preserves original message for non-403 errors', ()=>{
409
+ const error = TestErrors.internalError();
410
+ const result = createS3OperationError(error, 'PutBucketPolicy', 'my-bucket', void 0, 'save the bucket policy');
411
+ expect(result.message).toBe('Internal error');
412
+ });
413
+ test('uses fallback when error has no message', ()=>{
414
+ const error = new Error('');
415
+ const result = createS3OperationError(error, 'PutBucketPolicy', 'my-bucket', void 0, 'save the bucket policy');
416
+ expect(result.message).toBe('Failed to save the bucket policy');
417
+ });
418
+ test('preserves category when double-wrapping an EnhancedS3Error', ()=>{
419
+ const inner = createS3OperationError(TestErrors.accessDenied(), 'GeneratePresignedDownload', 'my-bucket');
420
+ expect(inner.category).toBe(ErrorCategory.AUTHORIZATION);
421
+ const outer = createS3OperationError(inner, 'PresigningMutation', void 0, void 0, 'generate the presigned URL');
422
+ expect(outer.category).toBe(ErrorCategory.AUTHORIZATION);
423
+ expect(outer.message).toBe("You don't have permission to generate the presigned URL. Contact your administrator.");
424
+ });
425
+ });
414
426
  describe('Integration Tests', ()=>{
415
427
  test('complete error processing workflow', ()=>{
416
428
  const testCases = [
@@ -29,6 +29,7 @@ export declare class EnhancedS3Error extends Error {
29
29
  * Server errors, network issues, and expired credentials are retryable.
30
30
  */
31
31
  shouldRetry(): boolean;
32
+ getUserMessage(operationLabel: string): string;
32
33
  }
33
34
  /**
34
35
  * Type guard to check if an error is an AWS S3 service exception.
@@ -47,7 +48,7 @@ export declare function createS3Error(error: unknown, context?: Record<string, u
47
48
  * Creates an enhanced S3 error with operation context and enriched error messages.
48
49
  * Automatically adds operation details to error messages for better debugging.
49
50
  */
50
- export declare function createS3OperationError(error: unknown, operation: string, bucketName?: string, objectKey?: string): EnhancedS3Error;
51
+ export declare function createS3OperationError(error: unknown, operation: string, bucketName?: string, objectKey?: string, operationLabel?: string): EnhancedS3Error;
51
52
  /**
52
53
  * Determines whether an error should be retried based on its classification.
53
54
  * Implements unified retry policy for all S3 operations.
@@ -29,6 +29,10 @@ class EnhancedS3Error extends Error {
29
29
  shouldRetry() {
30
30
  return "SERVER_ERROR" === this.category || "NETWORK_ERROR" === this.category || "EXPIRED_CREDENTIALS" === this.category;
31
31
  }
32
+ getUserMessage(operationLabel) {
33
+ if ("AUTHORIZATION" === this.category) return `You don't have permission to ${operationLabel}. Contact your administrator.`;
34
+ return this.message || `Failed to ${operationLabel}`;
35
+ }
32
36
  }
33
37
  const EXPIRED_CREDENTIAL_ERROR_NAMES = new Set([
34
38
  'ExpiredToken',
@@ -68,19 +72,16 @@ function createS3Error(error, context) {
68
72
  const message = 'string' == typeof error ? error : 'Unknown error occurred';
69
73
  return new EnhancedS3Error(message, 'UnknownError', "UNKNOWN", new Error(message), void 0, void 0, context);
70
74
  }
71
- function createS3OperationError(error, operation, bucketName, objectKey) {
75
+ function createS3OperationError(error, operation, bucketName, objectKey, operationLabel) {
72
76
  const context = {
73
77
  operation,
74
78
  bucketName,
75
79
  objectKey,
76
80
  timestamp: new Date().toISOString()
77
81
  };
78
- const enhancedError = createS3Error(error, context);
79
- if (!enhancedError.message.includes(operation)) {
80
- const prefix = bucketName ? `${operation} failed for bucket '${bucketName}'${objectKey ? `, key '${objectKey}'` : ''}` : `${operation} failed`;
81
- return new EnhancedS3Error(`${prefix}: ${enhancedError.message}`, enhancedError.name, enhancedError.category, enhancedError.originalError, enhancedError.statusCode, enhancedError.metadata, context);
82
- }
83
- return enhancedError;
82
+ const enhancedError = isEnhancedS3Error(error) ? error : createS3Error(error, context);
83
+ const message = operationLabel ? enhancedError.getUserMessage(operationLabel) : enhancedError.message;
84
+ return new EnhancedS3Error(message, enhancedError.name, enhancedError.category, enhancedError.originalError, enhancedError.statusCode, enhancedError.metadata, context);
84
85
  }
85
86
  function shouldRetryError(error, failureCount, maxRetries = 3) {
86
87
  if (failureCount >= maxRetries) return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scality/data-browser-library",
3
- "version": "1.1.10",
3
+ "version": "1.1.11",
4
4
  "description": "A modular React component library for browsing S3 buckets and objects",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.ts",