diff --git a/src/frontend/apps/drive/src/features/api/APIError.ts b/src/frontend/apps/drive/src/features/api/APIError.ts index 8942ebf0..2fa7dc24 100644 --- a/src/frontend/apps/drive/src/features/api/APIError.ts +++ b/src/frontend/apps/drive/src/features/api/APIError.ts @@ -13,6 +13,13 @@ export class APIError extends Error { } } +export const errorToCode = (error: unknown): string | undefined => { + if (error instanceof APIError) { + return error.data?.errors?.[0]?.code; + } + return undefined; +}; + export const errorToString = (error: unknown): string => { if (typeof error === "string") { return error; diff --git a/src/frontend/apps/drive/src/features/drivers/Driver.ts b/src/frontend/apps/drive/src/features/drivers/Driver.ts index b083a792..25d34530 100644 --- a/src/frontend/apps/drive/src/features/drivers/Driver.ts +++ b/src/frontend/apps/drive/src/features/drivers/Driver.ts @@ -77,6 +77,9 @@ export type Entitlement = { export enum EntitlementCanUploadReasons { NO_ORGANIZATION = "no_organization", NOT_ACTIVATED = "not_activated", + USER_QUOTA_EXCEEDED = "user_quota_excedeed", + USER_OVERRIDE_QUOTA_EXCEEDED = "user_override_quota_excedeed", + ORGANIZATION_QUOTA_EXCEEDED = "organization_quota_excedeed", } type EntitlementOperator = { diff --git a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts index eb554e15..ea3b1932 100644 --- a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts +++ b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts @@ -163,10 +163,18 @@ export class StandardDriver extends Driver { const payload = { ...(parentId ? { target_item_id: parentId } : {}), }; - await fetchAPI(`items/${id}/move/`, { - method: "POST", - body: JSON.stringify(payload), - }); + await fetchAPI( + `items/${id}/move/`, + { + method: "POST", + body: JSON.stringify(payload), + }, + { + // A move can be rejected (e.g. quota gate): let the caller toast the + // error instead of redirecting the whole app to the 403 page. + redirectOn40x: false, + }, + ); } async getItemAccesses(itemId: string): Promise { @@ -421,10 +429,16 @@ export class StandardDriver extends Driver { throw new DOMException("Upload cancelled", "AbortError"); } - await fetchAPI(`items/${item.id}/upload-ended/`, { - method: "POST", - signal: abortController.signal, - }); + await fetchAPI( + `items/${item.id}/upload-ended/`, + { + method: "POST", + signal: abortController.signal, + }, + { + redirectOn40x: false, + }, + ); progressHandler?.(100); @@ -462,9 +476,17 @@ export class StandardDriver extends Driver { } async duplicateItem(id: string): Promise { - const response = await fetchAPI(`items/${id}/duplicate/`, { - method: "POST", - }); + const response = await fetchAPI( + `items/${id}/duplicate/`, + { + method: "POST", + }, + { + // A duplication can be rejected (e.g. quota gate): let the global + // handler toast the error instead of redirecting to the 403 page. + redirectOn40x: false, + }, + ); return jsonToItem(await response.json()); } diff --git a/src/frontend/apps/drive/src/features/entitlement-disclaimers/disclaimers/CannotUploadDisclaimer.tsx b/src/frontend/apps/drive/src/features/entitlement-disclaimers/disclaimers/CannotUploadDisclaimer.tsx index ce57cc8b..5ada8aaa 100644 --- a/src/frontend/apps/drive/src/features/entitlement-disclaimers/disclaimers/CannotUploadDisclaimer.tsx +++ b/src/frontend/apps/drive/src/features/entitlement-disclaimers/disclaimers/CannotUploadDisclaimer.tsx @@ -51,24 +51,12 @@ const Content = ({ entitlements: Entitlements; }) => { const { t } = useTranslation(); + const reasonTitle = getCannotUploadReasonDescription( + entitlements.can_upload.reason, + ); return (
- {entitlements.can_upload.reason === - EntitlementCanUploadReasons.NOT_ACTIVATED && ( -

- {t( - "entitlements.disclaimers.cannot_upload.not_activated.description", - )} -

- )} - {entitlements.can_upload.reason === - EntitlementCanUploadReasons.NO_ORGANIZATION && ( -

- {t( - "entitlements.disclaimers.cannot_upload.no_organization.description", - )} -

- )} + {reasonTitle &&

{reasonTitle}

} {config?.showPotentialOperators && (entitlements.context?.potentialOperators?.length ?? 0) > 0 && (
@@ -106,4 +94,15 @@ const Content = ({ ); }; +export const getCannotUploadReasonDescription = ( + reason?: EntitlementCanUploadReasons, +) => { + if (reason) { + return i18n.t( + `entitlements.disclaimers.cannot_upload.${reason}.description`, + ); + } + return undefined; +}; + export default CannotUploadDisclaimer; diff --git a/src/frontend/apps/drive/src/features/entitlement-disclaimers/types.ts b/src/frontend/apps/drive/src/features/entitlement-disclaimers/types.ts index 4a357198..01e9d749 100644 --- a/src/frontend/apps/drive/src/features/entitlement-disclaimers/types.ts +++ b/src/frontend/apps/drive/src/features/entitlement-disclaimers/types.ts @@ -12,7 +12,7 @@ export type EntitlementDisclaimer< name: Name; show: (entitlements: Entitlements) => boolean; render: ( - config: DisclaimersConfig[Name], + config: DisclaimersConfig[Name] | undefined, entitlements: Entitlements, ) => { title: string; diff --git a/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx b/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx index 45b76d83..9cc15186 100644 --- a/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx +++ b/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx @@ -1,5 +1,11 @@ import { getDriver } from "@/features/config/Config"; +import { getCanUploadErrorDescription } from "@/utils/entitlements"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { + addToast, + ToasterItem, +} from "@/features/ui/components/toaster/Toaster"; import { useRemoveItemsFromPaginatedList } from "../hooks/useOptimisticPagination"; import { getMyFilesQueryKey, @@ -14,6 +20,7 @@ export const useMoveItems = () => { oldParentId?: string; }; + const { t } = useTranslation(); const queryClient = useQueryClient(); const driver = getDriver(); @@ -51,6 +58,24 @@ export const useMoveItems = () => { queryClient.invalidateQueries({ queryKey: ["items", variables.parentId, "children", "infinite"], }); + + addToast( + + arrow_forward + + {getCanUploadErrorDescription(err, (key) => + t(`explorer.modal.move.errors.${key}`), + ) ?? + t("explorer.actions.move.toast_error", { + count: variables.ids.length, + })} + + , + ); + }, + meta: { + // The onError above already toasts a localized message. + noGlobalError: true, }, }); }; diff --git a/src/frontend/apps/drive/src/features/explorer/components/ExplorerDndProvider.tsx b/src/frontend/apps/drive/src/features/explorer/components/ExplorerDndProvider.tsx index 0447f6e2..c5d566ba 100644 --- a/src/frontend/apps/drive/src/features/explorer/components/ExplorerDndProvider.tsx +++ b/src/frontend/apps/drive/src/features/explorer/components/ExplorerDndProvider.tsx @@ -116,20 +116,24 @@ export const ExplorerDndProvider = ({ children }: ExplorerDndProviderProps) => { setOveredItemIds({}); const ids = currentSelected.map((item) => item.id); - await moveItems.mutateAsync( - { - ids: ids, - parentId: newParentId, - oldParentId: itemId, - }, - { - onSuccess: () => { - addItemsMovedToast(ids.length); - // Reset the selected items after the move - setSelectedItems([]); + await moveItems + .mutateAsync( + { + ids: ids, + parentId: newParentId, + oldParentId: itemId, }, - }, - ); + { + onSuccess: () => { + addItemsMovedToast(ids.length); + // Reset the selected items after the move + setSelectedItems([]); + }, + }, + ) + .catch(() => { + // The error feedback is already handled by the mutation's onError. + }); }; const handleDragEnd = async ({ active, over }: DragEndEvent) => { diff --git a/src/frontend/apps/drive/src/features/explorer/components/modals/move/ExplorerMoveFolderModal.tsx b/src/frontend/apps/drive/src/features/explorer/components/modals/move/ExplorerMoveFolderModal.tsx index 123a6ef0..fb53edd3 100644 --- a/src/frontend/apps/drive/src/features/explorer/components/modals/move/ExplorerMoveFolderModal.tsx +++ b/src/frontend/apps/drive/src/features/explorer/components/modals/move/ExplorerMoveFolderModal.tsx @@ -131,7 +131,9 @@ export const ExplorerMoveFolder = ({ newParentId: string | undefined, oldParentId: string, ) => { - moveItems.mutateAsync( + // mutate (not mutateAsync): a rejection is already handled by the + // mutation's onError, an unused rejected promise would be unhandled. + moveItems.mutate( { ids: ids, parentId: newParentId, diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useItemActionMenuItems.tsx b/src/frontend/apps/drive/src/features/explorer/hooks/useItemActionMenuItems.tsx index 0bb2b74b..38364d83 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useItemActionMenuItems.tsx +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useItemActionMenuItems.tsx @@ -34,6 +34,7 @@ import { useMutationDuplicateItem, } from "./useMutations"; import { DefaultRoute } from "@/utils/defaultRoutes"; +import { getCanUploadErrorDescription } from "@/utils/entitlements"; import { addToast, ToasterItem, @@ -179,11 +180,14 @@ export const useItemActionMenuItems = ({ callback: async () => { try { await duplicateItem(effectiveItemId); - } catch { + } catch (err) { addToast( content_copy - {t("explorer.item.actions.duplicate_error")} + + {getCanUploadErrorDescription(err) ?? + t("explorer.item.actions.duplicate_error")} + , ); } diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useMutations.ts b/src/frontend/apps/drive/src/features/explorer/hooks/useMutations.ts index e05a92b7..389ee15d 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useMutations.ts +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useMutations.ts @@ -231,6 +231,10 @@ export const useMutationDuplicateItem = () => { const parentId = item?.originalId ?? item?.id; refresh(parentId); }, + meta: { + // The caller already toasts a localized message on failure. + noGlobalError: true, + }, }); }; diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useUpload.tsx b/src/frontend/apps/drive/src/features/explorer/hooks/useUpload.tsx index 69b114c4..1f95adea 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useUpload.tsx +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useUpload.tsx @@ -16,7 +16,7 @@ import { useCanCreateChildren } from "@/features/items/utils"; import { getMyFilesQueryKey } from "@/utils/defaultRoutes"; import { useConfig } from "@/features/config/ConfigProvider"; import { getDriver } from "@/features/config/Config"; -import { APIError } from "@/features/api/APIError"; +import { errorToCode } from "@/features/api/APIError"; import { useRefreshQueryCacheAfterMutation } from "./useRefreshItems"; import { isIdInItemTree } from "../utils/utils"; @@ -33,6 +33,7 @@ import { customGetFilesFromEvent, isEmptyFolderMarker, } from "@/features/explorer/utils/dropTraversal"; +import { getCannotUploadReasonDescription } from "@/features/entitlement-disclaimers/disclaimers/CannotUploadDisclaimer"; type FileUpload = FileWithPath & { parentId?: string; @@ -421,10 +422,12 @@ export const useUploadZone = ({ item }: { item: Item }) => { ...prev, step: UploadingStep.NONE, })); + const description = getCannotUploadReasonDescription(entitlements.can_upload.reason); addToast( {entitlements.can_upload.message || + description || t("entitlements.can_upload.cannot_upload")} , @@ -618,10 +621,7 @@ export const useUploadZone = ({ item }: { item: Item }) => { // Already handled by onCancelFile/onCancelAll continue; } - let errorCode = "unknown"; - if (err instanceof APIError && err.data?.errors?.[0]?.code) { - errorCode = err.data.errors[0].code; - } + const errorCode = errorToCode(err) ?? "unknown"; // Keep file in state with error status setUploadingState((prev) => ({ @@ -705,9 +705,7 @@ export const useUploadZone = ({ item }: { item: Item }) => { // Collect matches first: onCancelFile mutates the map we're iterating. const toCancel: string[] = []; for (const [filePath, upload] of activeUploadsRef.current) { - if ( - deletedIds.some((id) => isIdInItemTree(upload.parentPath, id)) - ) { + if (deletedIds.some((id) => isIdInItemTree(upload.parentPath, id))) { toCancel.push(filePath); } } diff --git a/src/frontend/apps/drive/src/features/i18n/translations.json b/src/frontend/apps/drive/src/features/i18n/translations.json index f0dd74bc..ded6e6e0 100644 --- a/src/frontend/apps/drive/src/features/i18n/translations.json +++ b/src/frontend/apps/drive/src/features/i18n/translations.json @@ -9,6 +9,15 @@ "close": "OK", "cannot_upload": { "title": "Uploads are unavailable", + "user_quota_excedeed": { + "description": "You can no longer add documents, your personal quota has been reached. Contact your administrator." + }, + "user_override_quota_excedeed": { + "description": "You can no longer add documents, your personal storage limit has been reached. Contact your administrator." + }, + "organization_quota_excedeed": { + "description": "You can no longer add documents, your organization quota has been reached. Contact your administrator." + }, "no_organization": { "description": "Your account isn't linked to an organization, so you can't upload files yet. Contact your administrator to get access." }, @@ -157,7 +166,14 @@ "move_to_root": "Move to root", "aria_label": "Move folder modal", "description_one_item": "Choose the new location for {{name}}", - "description_multiple_items": "Choose the new location for the {{count}} selected elements" + "description_multiple_items": "Choose the new location for the {{count}} selected elements", + "errors": { + "user_quota_excedeed": "You can no longer move documents to the root, your personal quota has been reached. Contact your administrator.", + "user_override_quota_excedeed": "You can no longer move documents to the root, your personal storage limit has been reached. Contact your administrator.", + "organization_quota_excedeed": "You can no longer move documents to the root, your organization quota has been reached. Contact your administrator.", + "no_organization": "Your account isn't linked to an organization, so you can't move documents to the root yet. Contact your administrator to get access.", + "not_activated": "File upload isn't activated for your organization yet, so you can't move documents to the root. Contact your administrator to enable it." + } } }, "drag_overlay": { @@ -484,10 +500,20 @@ "file_type_not_allowed": "This file type is not allowed.", "item_create_file_extension_not_allowed": "This file extension is not allowed.", "file_too_large": "The file exceeds the maximum allowed size.", + "user_quota_excedeed": "You can no longer add documents, your personal quota has been reached. Contact your administrator.", + "user_override_quota_excedeed": "You can no longer add documents, your personal storage limit has been reached. Contact your administrator.", + "organization_quota_excedeed": "You can no longer add documents, your organization quota has been reached. Contact your administrator.", + "no_organization": "Your account isn't linked to an organization, so you can't upload files yet. Contact your administrator to get access.", + "not_activated": "File upload isn't activated for your organization yet. Contact your administrator to enable it.", "unknown": "An unexpected error occurred." }, "error_short": { - "file_too_large": "File too large" + "file_too_large": "File too large", + "user_quota_excedeed": "Quota exceeded", + "user_override_quota_excedeed": "Quota exceeded", + "organization_quota_excedeed": "Quota exceeded", + "no_organization": "Upload not allowed", + "not_activated": "Upload not allowed" } }, "cancel_modal": { @@ -510,7 +536,9 @@ }, "move": { "toast_one": "{{count}} item moved", - "toast_other": "{{count}} items moved" + "toast_other": "{{count}} items moved", + "toast_error_one": "An error occurred while moving the item.", + "toast_error_other": "An error occurred while moving the items." }, "share": { "modal": { @@ -656,6 +684,15 @@ "close": "OK", "cannot_upload": { "title": "Les téléversements ne sont pas disponibles", + "user_quota_excedeed": { + "description": "Vous ne pouvez plus ajouter de documents, votre quota personnel est atteint. Contactez votre administrateur." + }, + "user_override_quota_excedeed": { + "description": "Vous ne pouvez plus ajouter de documents, votre limite de stockage personnelle est atteinte. Contactez votre administrateur." + }, + "organization_quota_excedeed": { + "description": "Vous ne pouvez plus ajouter de documents, votre quota d'organisation est atteint. Contactez votre administrateur." + }, "no_organization": { "description": "Votre compte n'est pas rattaché à une organisation, vous ne pouvez pas encore téléverser de fichiers. Contactez votre administrateur pour obtenir un accès." }, @@ -804,7 +841,14 @@ "move_to_root": "Déplacer vers la racine", "aria_label": "Modal de déplacement de dossier", "description_one_item": "Choisissez le nouvel emplacement pour {{name}}", - "description_multiple_items": "Choisissez le nouvel emplacement pour les {{count}} éléments sélectionnés" + "description_multiple_items": "Choisissez le nouvel emplacement pour les {{count}} éléments sélectionnés", + "errors": { + "user_quota_excedeed": "Vous ne pouvez plus déplacer de documents à la racine, votre quota personnel est atteint. Contactez votre administrateur.", + "user_override_quota_excedeed": "Vous ne pouvez plus déplacer de documents à la racine, votre limite de stockage personnelle est atteinte. Contactez votre administrateur.", + "organization_quota_excedeed": "Vous ne pouvez plus déplacer de documents à la racine, votre quota d'organisation est atteint. Contactez votre administrateur.", + "no_organization": "Votre compte n'est pas rattaché à une organisation, vous ne pouvez pas encore déplacer de documents à la racine. Contactez votre administrateur pour obtenir un accès.", + "not_activated": "Le téléversement de fichiers n'est pas encore activé pour votre organisation. Vous ne pouvez donc pas déplacer de documents à la racine. Contactez votre administrateur pour l'activer." + } } }, "drag_overlay": { @@ -1134,10 +1178,20 @@ "file_type_not_allowed": "Ce type de fichier n'est pas autorisé.", "item_create_file_extension_not_allowed": "Cette extension de fichier n'est pas autorisée.", "file_too_large": "Le fichier dépasse la taille maximale autorisée.", + "user_quota_excedeed": "Vous ne pouvez plus ajouter de documents, votre quota personnel est atteint. Contactez votre administrateur.", + "user_override_quota_excedeed": "Vous ne pouvez plus ajouter de documents, votre limite de stockage personnelle est atteinte. Contactez votre administrateur.", + "organization_quota_excedeed": "Vous ne pouvez plus ajouter de documents, votre quota d'organisation est atteint. Contactez votre administrateur.", + "no_organization": "Votre compte n'est pas rattaché à une organisation, vous ne pouvez pas encore téléverser de fichiers. Contactez votre administrateur pour obtenir un accès.", + "not_activated": "Le téléversement de fichiers n'est pas encore activé pour votre organisation. Contactez votre administrateur pour l'activer.", "unknown": "Une erreur inattendue est survenue." }, "error_short": { - "file_too_large": "Fichier trop volumineux" + "file_too_large": "Fichier trop volumineux", + "user_quota_excedeed": "Quota atteint", + "user_override_quota_excedeed": "Quota atteint", + "organization_quota_excedeed": "Quota atteint", + "no_organization": "Téléversement non autorisé", + "not_activated": "Téléversement non autorisé" } }, "cancel_modal": { @@ -1164,7 +1218,9 @@ }, "move": { "toast_one": "{{count}} élément déplacé", - "toast_other": "{{count}} éléments déplacés" + "toast_other": "{{count}} éléments déplacés", + "toast_error_one": "Une erreur est survenue lors du déplacement de l'élément.", + "toast_error_other": "Une erreur est survenue lors du déplacement des éléments." }, "share": { "modal": { @@ -1311,6 +1367,15 @@ "close": "OK", "cannot_upload": { "title": "Uploaden is niet beschikbaar", + "user_quota_excedeed": { + "description": "U kunt geen documenten meer toevoegen, uw persoonlijke quotum is bereikt. Neem contact op met uw beheerder." + }, + "user_override_quota_excedeed": { + "description": "U kunt geen documenten meer toevoegen, uw persoonlijke opslaglimiet is bereikt. Neem contact op met uw beheerder." + }, + "organization_quota_excedeed": { + "description": "U kunt geen documenten meer toevoegen, het quotum van uw organisatie is bereikt. Neem contact op met uw beheerder." + }, "no_organization": { "description": "Uw account is niet gekoppeld aan een organisatie, dus u kunt nog geen bestanden uploaden. Neem contact op met uw beheerder voor toegang." }, @@ -1459,7 +1524,14 @@ "move_to_root": "Verplaats naar root", "aria_label": "Map verplaatsen modal", "description_one_item": "Kies de nieuwe locatie voor {{name}}", - "description_multiple_items": "Kies de nieuwe locatie voor de {{count}} geselecteerde elementen" + "description_multiple_items": "Kies de nieuwe locatie voor de {{count}} geselecteerde elementen", + "errors": { + "user_quota_excedeed": "U kunt geen documenten meer naar de root verplaatsen, uw persoonlijke quotum is bereikt. Neem contact op met uw beheerder.", + "user_override_quota_excedeed": "U kunt geen documenten meer naar de root verplaatsen, uw persoonlijke opslaglimiet is bereikt. Neem contact op met uw beheerder.", + "organization_quota_excedeed": "U kunt geen documenten meer naar de root verplaatsen, het quotum van uw organisatie is bereikt. Neem contact op met uw beheerder.", + "no_organization": "Uw account is niet gekoppeld aan een organisatie, dus u kunt nog geen documenten naar de root verplaatsen. Neem contact op met uw beheerder voor toegang.", + "not_activated": "Het uploaden van bestanden is nog niet geactiveerd voor uw organisatie, dus u kunt geen documenten naar de root verplaatsen. Neem contact op met uw beheerder om dit in te schakelen." + } } }, "drag_overlay": { @@ -1744,10 +1816,20 @@ "file_type_not_allowed": "Dit bestandstype is niet toegestaan.", "item_create_file_extension_not_allowed": "Deze bestandsextensie is niet toegestaan.", "file_too_large": "Het bestand overschrijdt de maximale toegestane grootte.", + "user_quota_excedeed": "U kunt geen documenten meer toevoegen, uw persoonlijke quotum is bereikt. Neem contact op met uw beheerder.", + "user_override_quota_excedeed": "U kunt geen documenten meer toevoegen, uw persoonlijke opslaglimiet is bereikt. Neem contact op met uw beheerder.", + "organization_quota_excedeed": "U kunt geen documenten meer toevoegen, het quotum van uw organisatie is bereikt. Neem contact op met uw beheerder.", + "no_organization": "Uw account is niet gekoppeld aan een organisatie, dus u kunt nog geen bestanden uploaden. Neem contact op met uw beheerder voor toegang.", + "not_activated": "Het uploaden van bestanden is nog niet geactiveerd voor uw organisatie. Neem contact op met uw beheerder om dit in te schakelen.", "unknown": "Er is een onverwachte fout opgetreden." }, "error_short": { - "file_too_large": "Bestand te groot" + "file_too_large": "Bestand te groot", + "user_quota_excedeed": "Quotum bereikt", + "user_override_quota_excedeed": "Quotum bereikt", + "organization_quota_excedeed": "Quotum bereikt", + "no_organization": "Uploaden niet toegestaan", + "not_activated": "Uploaden niet toegestaan" } }, "cancel_modal": { @@ -1770,7 +1852,9 @@ }, "move": { "toast_one": "{{count}} item verplaatst", - "toast_other": "{{count}} items verplaatst" + "toast_other": "{{count}} items verplaatst", + "toast_error_one": "Er is een fout opgetreden bij het verplaatsen van het item.", + "toast_error_other": "Er is een fout opgetreden bij het verplaatsen van de items." }, "share": { "modal": { diff --git a/src/frontend/apps/drive/src/utils/entitlements.ts b/src/frontend/apps/drive/src/utils/entitlements.ts index 9073bbd4..6c035df3 100644 --- a/src/frontend/apps/drive/src/utils/entitlements.ts +++ b/src/frontend/apps/drive/src/utils/entitlements.ts @@ -1,6 +1,34 @@ import { getDriver } from "@/features/config/Config"; +import { errorToCode } from "@/features/api/APIError"; +import { EntitlementCanUploadReasons } from "@/features/drivers/Driver"; +import { getCannotUploadReasonDescription } from "@/features/entitlement-disclaimers/disclaimers/CannotUploadDisclaimer"; export const getEntitlements = async () => { const driver = getDriver(); return driver.getEntitlements(); }; + +/** + * The quota gates (upload, move, duplicate) expose the can_upload reason as the + * API error code: map it back to its localized description when known. + */ +export const getCanUploadErrorDescription = ( + error: unknown, + translate?: (key: string) => string, +): string | undefined => { + const code = errorToCode(error); + if ( + code && + Object.values(EntitlementCanUploadReasons).includes( + code as EntitlementCanUploadReasons, + ) + ) { + if (translate) { + return translate(code); + } + return getCannotUploadReasonDescription( + code as EntitlementCanUploadReasons, + ); + } + return undefined; +}; diff --git a/src/frontend/apps/e2e/__tests__/app-drive/duplicate-item.spec.ts b/src/frontend/apps/e2e/__tests__/app-drive/duplicate-item.spec.ts index 4179e9b4..ad740bbc 100644 --- a/src/frontend/apps/e2e/__tests__/app-drive/duplicate-item.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-drive/duplicate-item.spec.ts @@ -121,6 +121,41 @@ test.describe("Duplicate item", () => { ).not.toBeVisible(); }); + test("Shows the specific quota message when the duplication is rejected", async ({ + page, + }) => { + await createFileFromTemplate(page, "TestDoc"); + + // Reject the duplication with the quota gate's reason code. + await page.route("**/api/v1.0/items/*/duplicate/", (route) => + route.fulfill({ + status: 403, + contentType: "application/json", + body: JSON.stringify({ + type: "client_error", + errors: [ + { + code: "user_quota_excedeed", + detail: "You do not have permission to upload files.", + attr: null, + }, + ], + }), + }), + ); + + await triggerDuplicate(page, "TestDoc"); + + await expect( + page.getByText( + "You can no longer add documents, your personal quota has been reached. Contact your administrator.", + ), + ).toBeVisible(); + await expect( + page.getByText("An error occurred while duplicating the item."), + ).not.toBeVisible(); + }); + test("Duplicate option is available for files but not folders", async ({ page, }) => { diff --git a/src/frontend/apps/e2e/__tests__/app-drive/move-item.spec.ts b/src/frontend/apps/e2e/__tests__/app-drive/move-item.spec.ts index 2dcd346b..5b3459d5 100644 --- a/src/frontend/apps/e2e/__tests__/app-drive/move-item.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-drive/move-item.spec.ts @@ -28,6 +28,96 @@ test("Move an item to a new folder", async ({ page }) => { await expect(JohnRow).not.toBeVisible(); }); +test("Show an error toast when the server rejects the move", async ({ + page, +}) => { + await clearDb(); + await login(page, "drive@example.com"); + await page.goto("/"); + await clickToMyFiles(page); + await createFolderInCurrentFolder(page, "John"); + await createFolderInCurrentFolder(page, "Doe"); + + // Reject the move server-side (e.g. quota gate on move-to-root). + await page.route("**/api/v1.0/items/*/move/", (route) => + route.fulfill({ + status: 403, + contentType: "application/json", + body: JSON.stringify({ + type: "client_error", + errors: [ + { + code: "permission_denied", + detail: "You cannot take ownership of more storage.", + attr: null, + }, + ], + }), + }), + ); + + const JohnRow = await getRowItem(page, "John"); + await clickOnRowItemActions(page, "John", "Move"); + const moveFolderModal = await getMoveFolderModal(page); + const DoeRow = await getRowItem(moveFolderModal, "Doe"); + await DoeRow.click(); + await acceptMoveItem(page); + + await expect( + page.getByText("An error occurred while moving the item."), + ).toBeVisible(); + + // The modal stays open on failure; close it and check the item stayed put. + await moveFolderModal.getByRole("button", { name: "Cancel" }).click(); + await expect(JohnRow).toBeVisible(); +}); + +test("Show the specific quota message when the move is rejected", async ({ + page, +}) => { + await clearDb(); + await login(page, "drive@example.com"); + await page.goto("/"); + await clickToMyFiles(page); + await createFolderInCurrentFolder(page, "John"); + await createFolderInCurrentFolder(page, "Doe"); + + // Reject the move with the quota gate's reason code. + await page.route("**/api/v1.0/items/*/move/", (route) => + route.fulfill({ + status: 403, + contentType: "application/json", + body: JSON.stringify({ + type: "client_error", + errors: [ + { + code: "user_quota_excedeed", + detail: "You cannot take ownership of more storage.", + attr: null, + }, + ], + }), + }), + ); + + const JohnRow = await getRowItem(page, "John"); + await clickOnRowItemActions(page, "John", "Move"); + const moveFolderModal = await getMoveFolderModal(page); + const DoeRow = await getRowItem(moveFolderModal, "Doe"); + await DoeRow.click(); + await acceptMoveItem(page); + + await expect( + page.getByText( + "You can no longer move documents to the root, your personal quota has been reached. Contact your administrator.", + ), + ).toBeVisible(); + + // The modal stays open on failure; close it and check the item stayed put. + await moveFolderModal.getByRole("button", { name: "Cancel" }).click(); + await expect(JohnRow).toBeVisible(); +}); + test("Search and select to move an item", async ({ page }) => { await clearDb(); await login(page, "drive@example.com");