From 52fa9b6a5b102d3f1985ea2af0d67c0e921538fc Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Thu, 27 Aug 2026 15:57:29 +0000 Subject: [PATCH 01/22] WIP: s3 request file upload --- web/src/core/ports/S3Client.ts | 18 +++++++++++++++++ web/src/ui/App/GlobalAlert.tsx | 11 +++++++++++ web/src/ui/App/LeftBar.tsx | 4 ++++ web/src/ui/pages/index.ts | 5 +++-- web/src/ui/pages/s3FileRequest/Page.tsx | 26 +++++++++++++++++++++++++ web/src/ui/pages/s3FileRequest/index.ts | 3 +++ web/src/ui/pages/s3FileRequest/route.ts | 25 ++++++++++++++++++++++++ 7 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 web/src/ui/pages/s3FileRequest/Page.tsx create mode 100644 web/src/ui/pages/s3FileRequest/index.ts create mode 100644 web/src/ui/pages/s3FileRequest/route.ts diff --git a/web/src/core/ports/S3Client.ts b/web/src/core/ports/S3Client.ts index 26fff88fd..9905de8f9 100644 --- a/web/src/core/ports/S3Client.ts +++ b/web/src/core/ports/S3Client.ts @@ -37,6 +37,24 @@ export type S3Client = { isForDirectDownload: boolean; }) => Promise; + /** + * Creates a form that can be used without S3 credentials to upload objects + * whose keys start with the key of `s3UriPrefix`. + * + * `maxObjectSizeInBytes` applies to each object independently. Enforcing a + * maximum size across all objects uploaded with the form requires a stateful + * service in front of S3. + */ + createPresignedPost: (params: { + s3UriPrefix: S3Uri.TerminatedByDelimiter; + validityDurationSecond: number; + maxObjectSizeInBytes: number | undefined; + }) => Promise<{ + url: string; + fields: Record; + expirationTime: number; + }>; + getUnsignedObjectHttpUrl: (params: { s3Uri: S3Uri.NonTerminatedByDelimiter; isForDirectDownload: boolean; diff --git a/web/src/ui/App/GlobalAlert.tsx b/web/src/ui/App/GlobalAlert.tsx index d72a39de9..2f2f14216 100644 --- a/web/src/ui/App/GlobalAlert.tsx +++ b/web/src/ui/App/GlobalAlert.tsx @@ -6,6 +6,7 @@ import { Alert } from "onyxia-ui/Alert"; import { simpleHash } from "ui/tools/simpleHash"; import { LocalizedMarkdown } from "ui/shared/Markdown"; import { type LocalizedString } from "ui/i18n"; +import { useRoute } from "ui/routes"; type Props = { className?: string; @@ -48,6 +49,16 @@ export const GlobalAlert = memo( const { css, theme } = useStyles(); + const route = useRoute(); + + if (route.name === "s3FileRequest") { + return null; + } + + if (route.name === "s3Explorer") { + return null; + } + return ( { const { urlToLink } = useUrlToLink(); + if (route.name === "s3FileRequest") { + return null; + } + return ( Hello; +} diff --git a/web/src/ui/pages/s3FileRequest/index.ts b/web/src/ui/pages/s3FileRequest/index.ts new file mode 100644 index 000000000..9cf4bc637 --- /dev/null +++ b/web/src/ui/pages/s3FileRequest/index.ts @@ -0,0 +1,3 @@ +import { lazy, memo } from "react"; +export * from "./route"; +export const LazyComponent = memo(lazy(() => import("./Page"))); diff --git a/web/src/ui/pages/s3FileRequest/route.ts b/web/src/ui/pages/s3FileRequest/route.ts new file mode 100644 index 000000000..a0c606168 --- /dev/null +++ b/web/src/ui/pages/s3FileRequest/route.ts @@ -0,0 +1,25 @@ +import { defineRoute, createGroup, param } from "type-route"; +import { id } from "tsafe"; +import type { ValueSerializer } from "type-route"; + +type PresignedPost = { + url: string; + fields: Record; + expirationTime: number; +}; + +export const routeDefs = { + s3FileRequest: defineRoute( + { + presignedPost: param.path.ofType( + id>({ + parse: raw => JSON.parse(raw), + stringify: value => JSON.stringify(value) + }) + ) + }, + () => `/s3FileRequest` + ) +}; + +export const routeGroup = createGroup(routeDefs); From 08d9685ba0dbe806f41d6364e19bffc5a7d54413 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 28 Aug 2026 14:16:37 +0000 Subject: [PATCH 02/22] Implement user facing upload request page --- web/src/core/usecases/index.ts | 4 +- .../s3FileRequestUiController/index.ts | 3 + .../s3FileRequestUiController/selectors.ts | 29 + .../s3FileRequestUiController/state.ts | 139 ++++ .../s3FileRequestUiController/thunks.ts | 196 +++++ web/src/ui/App/GlobalAlert.tsx | 4 - web/src/ui/i18n/resources/de.tsx | 20 + web/src/ui/i18n/resources/en.tsx | 23 + web/src/ui/i18n/resources/es.tsx | 20 + web/src/ui/i18n/resources/fi.tsx | 20 + web/src/ui/i18n/resources/fr.tsx | 23 + web/src/ui/i18n/resources/it.tsx | 20 + web/src/ui/i18n/resources/nl.tsx | 20 + web/src/ui/i18n/resources/no.tsx | 20 + web/src/ui/i18n/resources/zh-CN.tsx | 20 + web/src/ui/i18n/types.ts | 1 + web/src/ui/pages/s3FileRequest/Page.tsx | 721 +++++++++++++++++- web/src/ui/pages/s3FileRequest/route.ts | 2 +- 18 files changed, 1276 insertions(+), 9 deletions(-) create mode 100644 web/src/core/usecases/s3FileRequestUiController/index.ts create mode 100644 web/src/core/usecases/s3FileRequestUiController/selectors.ts create mode 100644 web/src/core/usecases/s3FileRequestUiController/state.ts create mode 100644 web/src/core/usecases/s3FileRequestUiController/thunks.ts diff --git a/web/src/core/usecases/index.ts b/web/src/core/usecases/index.ts index 3aba184a3..c145dbbe0 100644 --- a/web/src/core/usecases/index.ts +++ b/web/src/core/usecases/index.ts @@ -24,6 +24,7 @@ import * as s3ProfilesManagement from "./s3ProfilesManagement"; import * as s3ShareObjectUiController from "./s3ShareObjectUiController"; import * as s3ProfilesCreationUiController from "./s3ProfilesCreationUiController"; import * as s3ExplorerUiController from "./s3ExplorerUiController"; +import * as s3FileRequestUiController from "./s3FileRequestUiController"; export const usecases = { autoLogoutCountdown, @@ -51,5 +52,6 @@ export const usecases = { s3ProfilesManagement, s3ShareObjectUiController, s3ProfilesCreationUiController, - s3ExplorerUiController + s3ExplorerUiController, + s3FileRequestUiController }; diff --git a/web/src/core/usecases/s3FileRequestUiController/index.ts b/web/src/core/usecases/s3FileRequestUiController/index.ts new file mode 100644 index 000000000..3f3843384 --- /dev/null +++ b/web/src/core/usecases/s3FileRequestUiController/index.ts @@ -0,0 +1,3 @@ +export * from "./state"; +export * from "./selectors"; +export * from "./thunks"; diff --git a/web/src/core/usecases/s3FileRequestUiController/selectors.ts b/web/src/core/usecases/s3FileRequestUiController/selectors.ts new file mode 100644 index 000000000..c91112ded --- /dev/null +++ b/web/src/core/usecases/s3FileRequestUiController/selectors.ts @@ -0,0 +1,29 @@ +import type { State as RootState } from "core/bootstrap"; +import { createSelector } from "clean-architecture"; +import { name, type State } from "./state"; + +const state = (rootState: RootState): State => rootState[name]; + +const presignedPost = createSelector(state, state => state.presignedPost); + +const uploads = createSelector(state, state => state.uploads); + +export type MainView = { + expirationTime: number; + uploads: State.Upload[]; + isUploading: boolean; +}; + +const mainView = createSelector( + presignedPost, + uploads, + (presignedPost, uploads): MainView => ({ + expirationTime: presignedPost.expirationTime, + uploads, + isUploading: uploads.some(upload => upload.status === "uploading") + }) +); + +export const selectors = { mainView }; + +export const privateSelectors = { presignedPost, uploads }; diff --git a/web/src/core/usecases/s3FileRequestUiController/state.ts b/web/src/core/usecases/s3FileRequestUiController/state.ts new file mode 100644 index 000000000..f36d08b4f --- /dev/null +++ b/web/src/core/usecases/s3FileRequestUiController/state.ts @@ -0,0 +1,139 @@ +import { + createObjectThatThrowsIfAccessed, + createUsecaseActions +} from "clean-architecture"; +import type { S3Client } from "core/ports/S3Client"; +import { assert } from "tsafe/assert"; +import { id } from "tsafe/id"; + +export type PresignedPost = Awaited>; + +export type State = { + presignedPost: PresignedPost; + uploads: State.Upload[]; +}; + +export namespace State { + export type Upload = { + uploadId: string; + fileName: string; + sizeInBytes: number; + status: "uploading" | "success" | "failed"; + uploadPercent: number; + errorMessage: string | undefined; + }; +} + +export const name = "s3FileRequestUiController"; + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: createObjectThatThrowsIfAccessed(), + reducers: { + loaded: (_state, { payload }: { payload: { presignedPost: PresignedPost } }) => { + const { presignedPost } = payload; + + return id({ + presignedPost, + uploads: [] + }); + }, + uploadsStarted: ( + state, + { + payload + }: { + payload: { + uploads: { + uploadId: string; + fileName: string; + sizeInBytes: number; + }[]; + }; + } + ) => { + state.uploads.push( + ...payload.uploads.map(({ uploadId, fileName, sizeInBytes }) => ({ + uploadId, + fileName, + sizeInBytes, + status: "uploading" as const, + uploadPercent: 0, + errorMessage: undefined + })) + ); + }, + uploadProgressReported: ( + state, + { payload }: { payload: { uploadId: string; uploadPercent: number } } + ) => { + const upload = state.uploads.find( + upload => upload.uploadId === payload.uploadId + ); + + if (upload === undefined) { + return; + } + + assert(upload.status === "uploading"); + + upload.uploadPercent = payload.uploadPercent; + }, + uploadSucceeded: (state, { payload }: { payload: { uploadId: string } }) => { + const upload = state.uploads.find( + upload => upload.uploadId === payload.uploadId + ); + + if (upload === undefined) { + return; + } + + assert(upload.status === "uploading"); + + upload.status = "success"; + upload.uploadPercent = 100; + }, + uploadFailed: ( + state, + { payload }: { payload: { uploadId: string; errorMessage: string } } + ) => { + const upload = state.uploads.find( + upload => upload.uploadId === payload.uploadId + ); + + if (upload === undefined) { + return; + } + + assert(upload.status === "uploading"); + + upload.status = "failed"; + upload.errorMessage = payload.errorMessage; + }, + uploadCanceled: (state, { payload }: { payload: { uploadId: string } }) => { + const uploadIndex = state.uploads.findIndex( + upload => upload.uploadId === payload.uploadId + ); + + if (uploadIndex === -1) { + return; + } + + assert(state.uploads[uploadIndex].status === "uploading"); + + state.uploads.splice(uploadIndex, 1); + }, + uploadRetried: (state, { payload }: { payload: { uploadId: string } }) => { + const upload = state.uploads.find( + upload => upload.uploadId === payload.uploadId + ); + + assert(upload !== undefined); + assert(upload.status === "failed"); + + upload.status = "uploading"; + upload.uploadPercent = 0; + upload.errorMessage = undefined; + } + } +}); diff --git a/web/src/core/usecases/s3FileRequestUiController/thunks.ts b/web/src/core/usecases/s3FileRequestUiController/thunks.ts new file mode 100644 index 000000000..96e640040 --- /dev/null +++ b/web/src/core/usecases/s3FileRequestUiController/thunks.ts @@ -0,0 +1,196 @@ +import type { Thunks } from "core/bootstrap"; +import { actions, type PresignedPost } from "./state"; +import { privateSelectors } from "./selectors"; +import { assert } from "tsafe/assert"; + +const fileByUploadId = new Map(); +const xhrByUploadId = new Map(); + +export const thunks = { + load: + (params: { presignedPost: PresignedPost }) => + (...args) => { + const { presignedPost } = params; + const [dispatch] = args; + + for (const xhr of [...xhrByUploadId.values()]) { + xhr.abort(); + } + + xhrByUploadId.clear(); + fileByUploadId.clear(); + + dispatch(actions.loaded({ presignedPost })); + }, + uploadFiles: + (params: { files: readonly File[] }) => + async (...args) => { + const { files } = params; + const [dispatch] = args; + + const uploads = files.map(file => { + const uploadId = `${Date.now()}-${Math.random()}`; + + fileByUploadId.set(uploadId, file); + + return { + uploadId, + fileName: file.name, + sizeInBytes: file.size + }; + }); + + if (uploads.length === 0) { + return; + } + + dispatch(actions.uploadsStarted({ uploads })); + + await Promise.all( + uploads.map(({ uploadId }) => + dispatch(privateThunks.uploadFile({ uploadId })) + ) + ); + }, + cancelUpload: (params: { uploadId: string }) => () => { + xhrByUploadId.get(params.uploadId)?.abort(); + }, + retryUpload: + (params: { uploadId: string }) => + async (...args) => { + const { uploadId } = params; + const [dispatch, getState] = args; + + const upload = privateSelectors + .uploads(getState()) + .find(upload => upload.uploadId === uploadId); + + assert(upload !== undefined); + assert(upload.status === "failed"); + assert(fileByUploadId.has(uploadId)); + + dispatch(actions.uploadRetried({ uploadId })); + + await dispatch(privateThunks.uploadFile({ uploadId })); + } +} satisfies Thunks; + +export const privateThunks = { + uploadFile: + (params: { uploadId: string }) => + async (...args) => { + const { uploadId } = params; + const [dispatch, getState] = args; + + const file = fileByUploadId.get(uploadId); + assert(file !== undefined); + + const presignedPost = privateSelectors.presignedPost(getState()); + + if (Date.now() >= presignedPost.expirationTime) { + dispatch( + actions.uploadFailed({ + uploadId, + errorMessage: "This file request has expired." + }) + ); + return; + } + + await new Promise(resolve => { + const xhr = new XMLHttpRequest(); + const formData = new FormData(); + + for (const [name, value] of Object.entries(presignedPost.fields)) { + formData.append(name, value); + } + + // S3 requires the file to be the last field in a POST form. + formData.append("file", file); + + const complete = (params: { + status: "success" | "failed" | "canceled"; + errorMessage?: string; + }) => { + if (xhrByUploadId.get(uploadId) !== xhr) { + resolve(); + return; + } + + xhrByUploadId.delete(uploadId); + + switch (params.status) { + case "success": + fileByUploadId.delete(uploadId); + dispatch(actions.uploadSucceeded({ uploadId })); + break; + case "failed": + dispatch( + actions.uploadFailed({ + uploadId, + errorMessage: + params.errorMessage ?? "The upload failed." + }) + ); + break; + case "canceled": + fileByUploadId.delete(uploadId); + dispatch(actions.uploadCanceled({ uploadId })); + break; + } + + resolve(); + }; + + xhr.upload.onprogress = event => { + if (!event.lengthComputable) { + return; + } + + dispatch( + actions.uploadProgressReported({ + uploadId, + uploadPercent: Math.round((event.loaded / event.total) * 100) + }) + ); + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + complete({ status: "success" }); + return; + } + + complete({ + status: "failed", + errorMessage: + xhr.statusText || + `The upload failed with HTTP status ${xhr.status}.` + }); + }; + + xhr.onerror = () => + complete({ + status: "failed", + errorMessage: "A network error occurred during the upload." + }); + + xhr.onabort = () => complete({ status: "canceled" }); + + xhrByUploadId.set(uploadId, xhr); + + try { + xhr.open("POST", presignedPost.url); + xhr.send(formData); + } catch (error) { + complete({ + status: "failed", + errorMessage: + error instanceof Error + ? error.message + : "The upload could not be started." + }); + } + }); + } +} satisfies Thunks; diff --git a/web/src/ui/App/GlobalAlert.tsx b/web/src/ui/App/GlobalAlert.tsx index 2f2f14216..6e0089bd5 100644 --- a/web/src/ui/App/GlobalAlert.tsx +++ b/web/src/ui/App/GlobalAlert.tsx @@ -55,10 +55,6 @@ export const GlobalAlert = memo( return null; } - if (route.name === "s3Explorer") { - return null; - } - return ( = { "create new folder": "Neuen Ordner erstellen", "download file": "Datei herunterladen" }, + S3FileRequest: { + "page title": undefined, + "page description": undefined, + "expires on": undefined, + "link expired": undefined, + "link expired description": undefined, + "drop files": undefined, + "drop files active": undefined, + "drop files hint": undefined, + "choose files": undefined, + "all files uploaded": undefined, + "all files uploaded description": undefined, + "uploads title": undefined, + uploading: undefined, + uploaded: undefined, + "upload failed": undefined, + "cancel upload": undefined, + "retry upload": undefined, + "privacy note": undefined + }, S3ShareObjectDialogContainer: { "dialog title": "Objekt teilen" }, diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 13c79f1e7..62a375109 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -338,6 +338,29 @@ export const translations: Translations<"en"> = { "create new folder": "Create new folder", "download file": "Download file" }, + S3FileRequest: { + "page title": "Upload requested files", + "page description": + "Someone shared this secure link so you can send files directly to their storage space. You do not need an Onyxia account.", + "expires on": ({ date }) => `This link expires on ${date}`, + "link expired": "This upload link has expired", + "link expired description": + "Ask the person who shared it with you to create a new link.", + "drop files": "Drag and drop your files here", + "drop files active": "Drop your files to upload them", + "drop files hint": "Files start uploading as soon as you select them.", + "choose files": "Choose files", + "all files uploaded": "Your files have been sent", + "all files uploaded description": + "You can close this page or add more files while the link is valid.", + "uploads title": "Your uploads", + uploading: ({ percent }) => `Uploading · ${percent}%`, + uploaded: "Uploaded", + "upload failed": "Upload failed", + "cancel upload": "Cancel upload", + "retry upload": "Retry upload", + "privacy note": "Only the files you choose are sent through this link." + }, S3ShareObjectDialogContainer: { "dialog title": "Share object" }, diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index 95126cb78..0a5415505 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -208,6 +208,26 @@ export const translations: Translations<"es"> = { "create new folder": "Crear nueva carpeta", "download file": "Descargar archivo" }, + S3FileRequest: { + "page title": undefined, + "page description": undefined, + "expires on": undefined, + "link expired": undefined, + "link expired description": undefined, + "drop files": undefined, + "drop files active": undefined, + "drop files hint": undefined, + "choose files": undefined, + "all files uploaded": undefined, + "all files uploaded description": undefined, + "uploads title": undefined, + uploading: undefined, + uploaded: undefined, + "upload failed": undefined, + "cancel upload": undefined, + "retry upload": undefined, + "privacy note": undefined + }, S3ShareObjectDialogContainer: { "dialog title": "Compartir objeto" }, diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 288690dca..3df679a92 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -205,6 +205,26 @@ export const translations: Translations<"fi"> = { "create new folder": "Luo uusi kansio", "download file": "lataa tiedosto" }, + S3FileRequest: { + "page title": undefined, + "page description": undefined, + "expires on": undefined, + "link expired": undefined, + "link expired description": undefined, + "drop files": undefined, + "drop files active": undefined, + "drop files hint": undefined, + "choose files": undefined, + "all files uploaded": undefined, + "all files uploaded description": undefined, + "uploads title": undefined, + uploading: undefined, + uploaded: undefined, + "upload failed": undefined, + "cancel upload": undefined, + "retry upload": undefined, + "privacy note": undefined + }, S3ShareObjectDialogContainer: { "dialog title": "Jaa objekti" }, diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 23b526f97..3f1d708dc 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -211,6 +211,29 @@ export const translations: Translations<"fr"> = { "create new folder": "Créer un nouveau dossier", "download file": "télécharger le fichier" }, + S3FileRequest: { + "page title": "Envoyer les fichiers demandés", + "page description": + "Une personne a partagé ce lien sécurisé afin que vous puissiez envoyer des fichiers directement dans son espace de stockage. Aucun compte Onyxia n’est nécessaire.", + "expires on": ({ date }) => `Ce lien expire le ${date}`, + "link expired": "Ce lien d’envoi a expiré", + "link expired description": + "Demandez à la personne qui vous l’a transmis de créer un nouveau lien.", + "drop files": "Glissez-déposez vos fichiers ici", + "drop files active": "Déposez vos fichiers pour les envoyer", + "drop files hint": "L’envoi commence dès que vous sélectionnez les fichiers.", + "choose files": "Choisir des fichiers", + "all files uploaded": "Vos fichiers ont bien été envoyés", + "all files uploaded description": + "Vous pouvez fermer cette page ou ajouter d’autres fichiers tant que le lien reste valide.", + "uploads title": "Vos envois", + uploading: ({ percent }) => `Envoi en cours · ${percent} %`, + uploaded: "Envoyé", + "upload failed": "Échec de l’envoi", + "cancel upload": "Annuler l’envoi", + "retry upload": "Réessayer", + "privacy note": "Seuls les fichiers que vous choisissez sont envoyés via ce lien." + }, S3ShareObjectDialogContainer: { "dialog title": "Partager l'objet" }, diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index ddd296668..66764060f 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -208,6 +208,26 @@ export const translations: Translations<"it"> = { "create new folder": "Crea nuova cartella", "download file": "scarica file" }, + S3FileRequest: { + "page title": undefined, + "page description": undefined, + "expires on": undefined, + "link expired": undefined, + "link expired description": undefined, + "drop files": undefined, + "drop files active": undefined, + "drop files hint": undefined, + "choose files": undefined, + "all files uploaded": undefined, + "all files uploaded description": undefined, + "uploads title": undefined, + uploading: undefined, + uploaded: undefined, + "upload failed": undefined, + "cancel upload": undefined, + "retry upload": undefined, + "privacy note": undefined + }, S3ShareObjectDialogContainer: { "dialog title": "Condividi oggetto" }, diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 48c86bf8c..c13bd3a8f 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -209,6 +209,26 @@ export const translations: Translations<"nl"> = { "create new folder": "Nieuwe map maken", "download file": "bestand downloaden" }, + S3FileRequest: { + "page title": undefined, + "page description": undefined, + "expires on": undefined, + "link expired": undefined, + "link expired description": undefined, + "drop files": undefined, + "drop files active": undefined, + "drop files hint": undefined, + "choose files": undefined, + "all files uploaded": undefined, + "all files uploaded description": undefined, + "uploads title": undefined, + uploading: undefined, + uploaded: undefined, + "upload failed": undefined, + "cancel upload": undefined, + "retry upload": undefined, + "privacy note": undefined + }, S3ShareObjectDialogContainer: { "dialog title": "Object delen" }, diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 8f943ac53..04c7890d5 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -205,6 +205,26 @@ export const translations: Translations<"no"> = { "create new folder": "Opprett ny mappe", "download file": "last ned fil" }, + S3FileRequest: { + "page title": undefined, + "page description": undefined, + "expires on": undefined, + "link expired": undefined, + "link expired description": undefined, + "drop files": undefined, + "drop files active": undefined, + "drop files hint": undefined, + "choose files": undefined, + "all files uploaded": undefined, + "all files uploaded description": undefined, + "uploads title": undefined, + uploading: undefined, + uploaded: undefined, + "upload failed": undefined, + "cancel upload": undefined, + "retry upload": undefined, + "privacy note": undefined + }, S3ShareObjectDialogContainer: { "dialog title": "Del objekt" }, diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index 5e0a19f78..da016cf68 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -186,6 +186,26 @@ export const translations: Translations<"zh-CN"> = { "create new folder": "创建新文件夹", "download file": "下载文件" }, + S3FileRequest: { + "page title": undefined, + "page description": undefined, + "expires on": undefined, + "link expired": undefined, + "link expired description": undefined, + "drop files": undefined, + "drop files active": undefined, + "drop files hint": undefined, + "choose files": undefined, + "all files uploaded": undefined, + "all files uploaded description": undefined, + "uploads title": undefined, + uploading: undefined, + uploaded: undefined, + "upload failed": undefined, + "cancel upload": undefined, + "retry upload": undefined, + "privacy note": undefined + }, S3ShareObjectDialogContainer: { "dialog title": "共享对象" }, diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index cdc38feed..f7b530251 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -30,6 +30,7 @@ export type ComponentKey = | import("ui/pages/s3Explorer/dialogs/S3SharePrefixDialog").I18n | import("ui/pages/s3Explorer/dialogs/S3ProfileDialog").I18n | import("ui/pages/s3Explorer/Page").I18n + | import("ui/pages/s3FileRequest/Page").I18n | import("ui/shared/codex/S3Bookmarks/S3BookmarksBar").I18n | import("ui/shared/codex/S3Bookmarks/S3BookmarksBarItem/S3BookmarksBarItem").S3BookmarkItemI18n | import("ui/shared/codex/S3Bookmarks/S3BookmarksEntryPointItem").I18n diff --git a/web/src/ui/pages/s3FileRequest/Page.tsx b/web/src/ui/pages/s3FileRequest/Page.tsx index 5b3d23d38..5e63e435e 100644 --- a/web/src/ui/pages/s3FileRequest/Page.tsx +++ b/web/src/ui/pages/s3FileRequest/Page.tsx @@ -2,7 +2,26 @@ import { getRoute } from "ui/routes"; import { routeGroup } from "./route"; import { assert } from "tsafe"; import { withLoader } from "ui/tools/withLoader"; -import { getCore } from "core"; +import { getCore, getCoreSync, useCoreState } from "core"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type DragEvent +} from "react"; +import { tss } from "tss"; +import { alpha } from "@mui/material/styles"; +import { Icon } from "onyxia-ui/Icon"; +import { IconButton } from "onyxia-ui/IconButton"; +import { Button } from "onyxia-ui/Button"; +import { Text } from "onyxia-ui/Text"; +import { getIconUrlByName } from "lazy-icons"; +import bytes from "bytes"; +import { getS3ObjectIconUrl } from "ui/shared/codex/getS3ObjectIconUrl"; +import { declareComponentKeys, useLang, useTranslation } from "ui/i18n"; const Page = withLoader({ loader, @@ -16,11 +35,707 @@ async function loader() { const core = await getCore(); - core.functions.s3FileRequest.load({ + core.functions.s3FileRequestUiController.load({ presignedPost: route.params.presignedPost }); } function S3FileRequest() { - return

Hello

; + const { classes, cx } = useStyles(); + const { t } = useTranslation({ S3FileRequest }); + const { lang } = useLang(); + const { expirationTime, uploads } = useCoreState( + "s3FileRequestUiController", + "mainView" + ); + const { + functions: { s3FileRequestUiController } + } = getCoreSync(); + + const fileInputRef = useRef(null); + const dragDepthRef = useRef(0); + const [isDragActive, setIsDragActive] = useState(false); + const now = useNowUntil({ expirationTime }); + + const isExpired = !Number.isFinite(expirationTime) || now >= expirationTime; + + const formattedExpirationTime = useMemo(() => { + if (!Number.isFinite(expirationTime)) { + return ""; + } + + return new Intl.DateTimeFormat(lang, { + dateStyle: "medium", + timeStyle: "short" + }).format(new Date(expirationTime)); + }, [expirationTime, lang]); + + useEffect(() => { + if (!isExpired) { + return; + } + + dragDepthRef.current = 0; + setIsDragActive(false); + }, [isExpired]); + + const uploadFiles = useCallback( + (files: readonly File[]) => { + if (isExpired || files.length === 0) { + return; + } + + void s3FileRequestUiController.uploadFiles({ files }); + }, + [isExpired, s3FileRequestUiController] + ); + + const onFileInputChange = (event: ChangeEvent) => { + uploadFiles(Array.from(event.target.files ?? [])); + + // Allow selecting the same file again after the upload has completed. + event.target.value = ""; + }; + + const onDragEnter = (event: DragEvent) => { + if (isExpired || !getHasDraggedFiles(event)) { + return; + } + + event.preventDefault(); + dragDepthRef.current += 1; + setIsDragActive(true); + }; + + const onDragOver = (event: DragEvent) => { + if (isExpired || !getHasDraggedFiles(event)) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }; + + const onDragLeave = (event: DragEvent) => { + if (!getHasDraggedFiles(event)) { + return; + } + + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + + if (dragDepthRef.current === 0) { + setIsDragActive(false); + } + }; + + const onDrop = (event: DragEvent) => { + if (!getHasDraggedFiles(event)) { + return; + } + + event.preventDefault(); + dragDepthRef.current = 0; + setIsDragActive(false); + + uploadFiles(Array.from(event.dataTransfer.files)); + }; + + const hasUploads = uploads.length !== 0; + const areAllUploadsSuccessful = + hasUploads && uploads.every(upload => upload.status === "success"); + + return ( +
+
+
+
+ +
+ + {t("page title")} + + + {t("page description")} + +
+
+ +
+ +
+
+ {isExpired + ? t("link expired") + : t("expires on", { + date: formattedExpirationTime + })} +
+ {isExpired && ( +
+ {t("link expired description")} +
+ )} +
+
+ + {!isExpired && ( +
+ + +
+ {t(isDragActive ? "drop files active" : "drop files")} +
+
+ {t("drop files hint")} +
+ +
+ )} + + {areAllUploadsSuccessful && ( +
+ +
+
+ {t("all files uploaded")} +
+
+ {t("all files uploaded description")} +
+
+
+ )} + + {hasUploads && ( +
+
+
+ {t("uploads title")} +
+
+ {uploads.length} +
+
+
+ {uploads.map(upload => { + const uploadPercent = Math.max( + 0, + Math.min(100, upload.uploadPercent) + ); + + return ( +
+
+ +
+
+
+
+ {upload.fileName} +
+
+ {formatSize(upload.sizeInBytes)} +
+
+
+ + {upload.status === "uploading" + ? t("uploading", { + percent: + Math.round( + uploadPercent + ) + }) + : upload.status === "success" + ? t("uploaded") + : t("upload failed")} + + {upload.errorMessage !== + undefined && ( + + {upload.errorMessage} + + )} +
+ {upload.status === "uploading" && ( +
+
+
+ )} +
+ {upload.status === "uploading" ? ( + + s3FileRequestUiController.cancelUpload( + { + uploadId: upload.uploadId + } + ) + } + /> + ) : upload.status === "failed" ? ( + + void s3FileRequestUiController.retryUpload( + { + uploadId: upload.uploadId + } + ) + } + /> + ) : ( +
+ +
+ )} +
+ ); + })} +
+
+ )} + +
+ + {t("privacy note")} +
+
+
+
+ ); +} + +function useNowUntil(params: { expirationTime: number }): number { + const { expirationTime } = params; + const [now, setNow] = useState(Date.now()); + + useEffect(() => { + if (!Number.isFinite(expirationTime) || now >= expirationTime) { + return; + } + + const timeoutId = window.setTimeout( + () => setNow(Date.now()), + Math.min(30_000, expirationTime - now + 50) + ); + + return () => window.clearTimeout(timeoutId); + }, [expirationTime, now]); + + return now; +} + +function getHasDraggedFiles(event: DragEvent): boolean { + return Array.from(event.dataTransfer.types).includes("Files"); +} + +function formatSize(sizeInBytes: number): string { + return bytes(sizeInBytes) ?? `${sizeInBytes}B`; } + +const useStyles = tss.withName({ S3FileRequest }).create(({ theme }) => ({ + root: { + height: "100%", + overflow: "auto", + boxSizing: "border-box", + backgroundColor: theme.colors.useCases.surfaces.background, + padding: `${theme.spacing(4)}px ${theme.spacing(3)}px ${theme.spacing(8)}px` + }, + content: { + width: "100%", + maxWidth: 780, + margin: "0 auto" + }, + card: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(3), + padding: theme.spacing(4), + borderRadius: 24, + border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + backgroundColor: theme.colors.useCases.surfaces.surface1, + boxShadow: theme.shadows[3], + "@media (max-width: 640px)": { + padding: theme.spacing(2.5), + borderRadius: 18 + } + }, + header: { + display: "flex", + alignItems: "flex-start", + gap: theme.spacing(2.5), + "@media (max-width: 520px)": { + flexDirection: "column" + } + }, + heroIcon: { + width: 64, + height: 64, + borderRadius: 18, + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + color: theme.colors.useCases.typography.textFocus, + backgroundColor: alpha(theme.colors.useCases.typography.textFocus, 0.1) + }, + headerText: { + minWidth: 0, + display: "flex", + flexDirection: "column", + gap: theme.spacing(1) + }, + title: { + margin: 0, + color: theme.colors.useCases.typography.textPrimary + }, + description: { + color: theme.colors.useCases.typography.textSecondary, + lineHeight: 1.6, + maxWidth: 650 + }, + expiration: { + display: "flex", + alignItems: "flex-start", + gap: theme.spacing(1.5), + padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px`, + borderRadius: 12, + color: theme.colors.useCases.typography.textSecondary, + backgroundColor: theme.colors.useCases.surfaces.background, + border: `1px solid ${theme.colors.useCases.surfaces.surface2}` + }, + expirationExpired: { + color: theme.colors.useCases.alertSeverity.error.main, + borderColor: alpha(theme.colors.useCases.alertSeverity.error.main, 0.35), + backgroundColor: theme.colors.useCases.alertSeverity.error.background + }, + expirationTitle: { + ...theme.typography.variants["label 1"].style + }, + expirationDescription: { + ...theme.typography.variants["body 2"].style, + marginTop: theme.spacing(0.5) + }, + dropZone: { + minHeight: 260, + boxSizing: "border-box", + borderRadius: 18, + border: `2px dashed ${alpha(theme.colors.useCases.typography.textFocus, 0.38)}`, + backgroundColor: alpha(theme.colors.useCases.typography.textFocus, 0.035), + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + textAlign: "center", + gap: theme.spacing(1.25), + padding: theme.spacing(4), + transition: + "border-color 160ms ease, background-color 160ms ease, transform 160ms ease" + }, + dropZoneActive: { + borderColor: theme.colors.useCases.typography.textFocus, + backgroundColor: alpha(theme.colors.useCases.typography.textFocus, 0.1), + transform: "scale(1.006)" + }, + dropZoneIcon: { + width: 58, + height: 58, + borderRadius: 9999, + display: "flex", + alignItems: "center", + justifyContent: "center", + marginBottom: theme.spacing(0.5), + color: theme.colors.useCases.typography.textFocus, + backgroundColor: theme.colors.useCases.surfaces.surface1, + boxShadow: theme.shadows[2] + }, + dropZoneTitle: { + ...theme.typography.variants["section heading"].style, + color: theme.colors.useCases.typography.textPrimary + }, + dropZoneHint: { + ...theme.typography.variants["body 2"].style, + color: theme.colors.useCases.typography.textSecondary, + marginBottom: theme.spacing(1) + }, + successNotice: { + display: "flex", + alignItems: "flex-start", + gap: theme.spacing(1.5), + padding: theme.spacing(2), + borderRadius: 12, + color: theme.colors.useCases.alertSeverity.success.main, + border: `1px solid ${alpha( + theme.colors.useCases.alertSeverity.success.main, + 0.35 + )}`, + backgroundColor: theme.colors.useCases.alertSeverity.success.background + }, + successNoticeTitle: { + ...theme.typography.variants["label 1"].style + }, + successNoticeDescription: { + ...theme.typography.variants["body 2"].style, + marginTop: theme.spacing(0.5) + }, + uploadsSection: { + display: "flex", + flexDirection: "column", + borderRadius: 16, + overflow: "hidden", + border: `1px solid ${theme.colors.useCases.surfaces.surface2}` + }, + uploadsHeader: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: `${theme.spacing(1.75)}px ${theme.spacing(2)}px`, + backgroundColor: theme.colors.useCases.surfaces.background + }, + uploadsTitle: { + ...theme.typography.variants["label 1"].style, + color: theme.colors.useCases.typography.textPrimary + }, + uploadsCount: { + ...theme.typography.variants["caption"].style, + minWidth: 26, + height: 26, + borderRadius: 9999, + display: "flex", + alignItems: "center", + justifyContent: "center", + color: theme.colors.useCases.typography.textSecondary, + backgroundColor: theme.colors.useCases.surfaces.surface2 + }, + uploadsList: { + display: "flex", + flexDirection: "column" + }, + uploadItem: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + minWidth: 0, + padding: theme.spacing(2), + backgroundColor: theme.colors.useCases.surfaces.surface1, + "&:not(:last-child)": { + borderBottom: `1px solid ${theme.colors.useCases.surfaces.surface2}` + } + }, + fileIcon: { + width: 42, + height: 42, + borderRadius: 11, + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + color: theme.colors.useCases.typography.textPrimary, + backgroundColor: theme.colors.useCases.surfaces.surface2 + }, + uploadItemBody: { + minWidth: 0, + flex: 1, + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.75) + }, + fileNameRow: { + minWidth: 0, + display: "flex", + alignItems: "baseline", + gap: theme.spacing(1.5) + }, + fileName: { + ...theme.typography.variants["label 1"].style, + minWidth: 0, + flex: 1, + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + color: theme.colors.useCases.typography.textPrimary + }, + fileSize: { + ...theme.typography.variants["caption"].style, + flexShrink: 0, + color: theme.colors.useCases.typography.textSecondary + }, + statusRow: { + minWidth: 0, + display: "flex", + alignItems: "baseline", + gap: theme.spacing(1) + }, + status: { + ...theme.typography.variants["caption"].style, + flexShrink: 0, + color: theme.colors.useCases.typography.textSecondary + }, + statusSuccess: { + color: theme.colors.useCases.alertSeverity.success.main + }, + statusError: { + color: theme.colors.useCases.alertSeverity.error.main + }, + errorMessage: { + ...theme.typography.variants["caption"].style, + minWidth: 0, + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + color: theme.colors.useCases.typography.textSecondary + }, + progressTrack: { + width: "100%", + height: 4, + overflow: "hidden", + borderRadius: 9999, + backgroundColor: theme.colors.useCases.surfaces.surface3 + }, + progressFill: { + height: "100%", + borderRadius: 9999, + backgroundColor: theme.colors.useCases.typography.textFocus, + transition: "width 160ms ease" + }, + uploadAction: { + flexShrink: 0 + }, + uploadSuccessIcon: { + width: 32, + height: 32, + borderRadius: 9999, + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + color: theme.colors.useCases.alertSeverity.success.main + }, + privacyNote: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing(1), + textAlign: "center", + color: theme.colors.useCases.typography.textSecondary, + ...theme.typography.variants["caption"].style + } +})); + +const { i18n } = declareComponentKeys< + | "page title" + | "page description" + | { K: "expires on"; P: { date: string }; R: string } + | "link expired" + | "link expired description" + | "drop files" + | "drop files active" + | "drop files hint" + | "choose files" + | "all files uploaded" + | "all files uploaded description" + | "uploads title" + | { K: "uploading"; P: { percent: number }; R: string } + | "uploaded" + | "upload failed" + | "cancel upload" + | "retry upload" + | "privacy note" +>()({ S3FileRequest }); +export type I18n = typeof i18n; diff --git a/web/src/ui/pages/s3FileRequest/route.ts b/web/src/ui/pages/s3FileRequest/route.ts index a0c606168..ddf265add 100644 --- a/web/src/ui/pages/s3FileRequest/route.ts +++ b/web/src/ui/pages/s3FileRequest/route.ts @@ -11,7 +11,7 @@ type PresignedPost = { export const routeDefs = { s3FileRequest: defineRoute( { - presignedPost: param.path.ofType( + presignedPost: param.query.ofType( id>({ parse: raw => JSON.parse(raw), stringify: value => JSON.stringify(value) From de03676fb410a760b2507d01653b90fcf469ddaa Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 28 Aug 2026 17:06:58 +0000 Subject: [PATCH 03/22] Done with upload request --- web/package.json | 1 + web/src/core/adapters/s3Client/s3Client.ts | 75 +++- web/src/core/ports/S3Client.ts | 30 +- web/src/core/usecases/index.ts | 4 +- .../s3FileRequestCreationUiController/evt.ts | 27 ++ .../index.ts | 4 + .../selectors.ts | 44 +++ .../state.ts | 102 +++++ .../thunks.ts | 112 ++++++ .../s3FileRequestUiController/state.ts | 2 +- .../s3FileRequestUiController/thunks.ts | 14 +- web/src/ui/i18n/resources/de.tsx | 23 ++ web/src/ui/i18n/resources/en.tsx | 25 ++ web/src/ui/i18n/resources/es.tsx | 23 ++ web/src/ui/i18n/resources/fi.tsx | 23 ++ web/src/ui/i18n/resources/fr.tsx | 25 ++ web/src/ui/i18n/resources/it.tsx | 23 ++ web/src/ui/i18n/resources/nl.tsx | 23 ++ web/src/ui/i18n/resources/no.tsx | 23 ++ web/src/ui/i18n/resources/zh-CN.tsx | 23 ++ web/src/ui/i18n/types.ts | 2 + web/src/ui/pages/s3Explorer/Page.tsx | 8 + .../s3Explorer/dialogs/S3ExplorerDialogs.tsx | 7 + .../dialogs/S3FileRequestCreationDialog.tsx | 96 +++++ web/src/ui/pages/s3FileRequest/route.ts | 7 +- .../S3ExplorerMainView.stories.tsx | 8 + .../S3ExplorerMainView/S3ExplorerMainView.tsx | 74 ++++ .../S3FileRequestCreationDialog.spec.md | 160 ++++++++ .../S3FileRequestCreationDialog.tsx | 362 ++++++++++++++++++ .../S3FileRequestCreationDialog/index.ts | 1 + .../S3SelectionActionBar.stories.tsx | 7 + .../S3SelectionActionBar.tsx | 21 + web/yarn.lock | 17 +- 33 files changed, 1367 insertions(+), 29 deletions(-) create mode 100644 web/src/core/usecases/s3FileRequestCreationUiController/evt.ts create mode 100644 web/src/core/usecases/s3FileRequestCreationUiController/index.ts create mode 100644 web/src/core/usecases/s3FileRequestCreationUiController/selectors.ts create mode 100644 web/src/core/usecases/s3FileRequestCreationUiController/state.ts create mode 100644 web/src/core/usecases/s3FileRequestCreationUiController/thunks.ts create mode 100644 web/src/ui/pages/s3Explorer/dialogs/S3FileRequestCreationDialog.tsx create mode 100644 web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.spec.md create mode 100644 web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.tsx create mode 100644 web/src/ui/shared/codex/S3FileRequestCreationDialog/index.ts diff --git a/web/package.json b/web/package.json index 5576f025b..f0a4ccf41 100644 --- a/web/package.json +++ b/web/package.json @@ -23,6 +23,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.828.0", "@aws-sdk/lib-storage": "^3.828.0", + "@aws-sdk/s3-presigned-post": "3.828.0", "@aws-sdk/s3-request-presigner": "^3.828.0", "@aws-sdk/client-sts": "^3.907.0", "@babel/runtime": "7.26.0", diff --git a/web/src/core/adapters/s3Client/s3Client.ts b/web/src/core/adapters/s3Client/s3Client.ts index e6abd4682..7b74d1abd 100644 --- a/web/src/core/adapters/s3Client/s3Client.ts +++ b/web/src/core/adapters/s3Client/s3Client.ts @@ -169,9 +169,15 @@ export function createS3Client( import("@aws-sdk/client-s3").S3Client >(); - async function getAwsS3Client() { + type Token = NonNullable< + Awaited> + >; + + async function getAwsS3Client(options?: { token: Token }) { const [tokens, AwsS3Client] = await Promise.all([ - getNewlyRequestedOrCachedToken(), + options === undefined + ? getNewlyRequestedOrCachedToken() + : Promise.resolve(options.token), import("@aws-sdk/client-s3").then(({ S3Client }) => S3Client) ] as const); @@ -550,6 +556,71 @@ export function createS3Client( return downloadUrl; }, + createPresignedPost: async ({ + s3Uri, + validityDurationSecond, + maxObjectSizeInBytes + }) => { + assert( + !isAnonymousProfile, + "Trying to generate a presigned POST with a public client" + ); + + const { getAwsS3Client, getNewlyRequestedOrCachedToken } = await prApi; + + // This is the only recoverable boundary in this operation: obtaining + // temporary credentials can fail when the identity or STS service is + // unreachable. Signing the POST below is otherwise a local operation. + const tokenResult = await getNewlyRequestedOrCachedToken().then( + token => ({ isSuccess: true as const, token }), + error => ({ + isSuccess: false as const, + errorMessage: error instanceof Error ? error.message : String(error) + }) + ); + + if (!tokenResult.isSuccess) { + return tokenResult; + } + + const { token } = tokenResult; + + assert(token !== undefined); + + const { awsS3Client } = await getAwsS3Client({ token }); + + const now = Date.now(); + const requestedExpirationTime = now + validityDurationSecond * 1_000; + const expirationTime = Math.min( + requestedExpirationTime, + token.expirationTime ?? requestedExpirationTime + ); + const expiresInSecond = Math.max( + 1, + Math.floor((expirationTime - now) / 1_000) + ); + + const { url, fields } = await ( + await import("@aws-sdk/s3-presigned-post") + ).createPresignedPost(awsS3Client, { + Bucket: s3Uri.bucket, + Key: `${getS3UriKey(s3Uri)}\${filename}`, + Expires: expiresInSecond, + Conditions: + maxObjectSizeInBytes === undefined + ? [] + : [["content-length-range", 0, maxObjectSizeInBytes]] + }); + + return { + isSuccess: true, + presignedPost: { + url, + fields, + expirationTime: now + expiresInSecond * 1_000 + } + }; + }, getObjectContent: async ({ s3Uri, range }) => { const { getAwsS3Client } = await prApi; diff --git a/web/src/core/ports/S3Client.ts b/web/src/core/ports/S3Client.ts index 9905de8f9..3116bef3b 100644 --- a/web/src/core/ports/S3Client.ts +++ b/web/src/core/ports/S3Client.ts @@ -39,21 +39,21 @@ export type S3Client = { /** * Creates a form that can be used without S3 credentials to upload objects - * whose keys start with the key of `s3UriPrefix`. + * whose keys start with the key of `s3Uri`. * * `maxObjectSizeInBytes` applies to each object independently. Enforcing a * maximum size across all objects uploaded with the form requires a stateful * service in front of S3. + * + * A failure value represents an expected failure to acquire temporary S3 + * credentials. Invalid state or unexpected signing errors are not converted + * into this result and still throw. */ createPresignedPost: (params: { - s3UriPrefix: S3Uri.TerminatedByDelimiter; + s3Uri: S3Uri.TerminatedByDelimiter; validityDurationSecond: number; maxObjectSizeInBytes: number | undefined; - }) => Promise<{ - url: string; - fields: Record; - expirationTime: number; - }>; + }) => Promise; getUnsignedObjectHttpUrl: (params: { s3Uri: S3Uri.NonTerminatedByDelimiter; @@ -95,6 +95,22 @@ export type S3Client = { export namespace S3Client { export type BucketPolicies = Record; + export type PresignedPost = { + url: string; + fields: Record; + expirationTime: number; + }; + + export type CreatePresignedPostReturn = + | { + isSuccess: true; + presignedPost: PresignedPost; + } + | { + isSuccess: false; + errorMessage: string; + }; + export type ListObjectsReturn = ListObjectsReturn.Error | ListObjectsReturn.Success; export namespace ListObjectsReturn { diff --git a/web/src/core/usecases/index.ts b/web/src/core/usecases/index.ts index c145dbbe0..eba2db47a 100644 --- a/web/src/core/usecases/index.ts +++ b/web/src/core/usecases/index.ts @@ -25,6 +25,7 @@ import * as s3ShareObjectUiController from "./s3ShareObjectUiController"; import * as s3ProfilesCreationUiController from "./s3ProfilesCreationUiController"; import * as s3ExplorerUiController from "./s3ExplorerUiController"; import * as s3FileRequestUiController from "./s3FileRequestUiController"; +import * as s3FileRequestCreationUiController from "./s3FileRequestCreationUiController"; export const usecases = { autoLogoutCountdown, @@ -53,5 +54,6 @@ export const usecases = { s3ShareObjectUiController, s3ProfilesCreationUiController, s3ExplorerUiController, - s3FileRequestUiController + s3FileRequestUiController, + s3FileRequestCreationUiController }; diff --git a/web/src/core/usecases/s3FileRequestCreationUiController/evt.ts b/web/src/core/usecases/s3FileRequestCreationUiController/evt.ts new file mode 100644 index 000000000..3da943907 --- /dev/null +++ b/web/src/core/usecases/s3FileRequestCreationUiController/evt.ts @@ -0,0 +1,27 @@ +import type { CreateEvt } from "core/bootstrap"; +import { Evt } from "evt"; +import { name } from "./state"; +import { privateThunks } from "./thunks"; + +export const createEvt = (({ evtAction, dispatch }) => { + evtAction + .pipe(action => { + if (action.usecaseName !== name) { + return false; + } + + switch (action.actionName) { + case "loaded": + case "validityDurationChanged": + case "maxObjectSizeChanged": + return true; + case "generationStarted": + case "generationSucceeded": + case "generationFailed": + return false; + } + }) + .attach(() => dispatch(privateThunks.updatePresignedPost())); + + return Evt.create(); +}) satisfies CreateEvt; diff --git a/web/src/core/usecases/s3FileRequestCreationUiController/index.ts b/web/src/core/usecases/s3FileRequestCreationUiController/index.ts new file mode 100644 index 000000000..dd6008150 --- /dev/null +++ b/web/src/core/usecases/s3FileRequestCreationUiController/index.ts @@ -0,0 +1,4 @@ +export * from "./state"; +export * from "./thunks"; +export * from "./selectors"; +export * from "./evt"; diff --git a/web/src/core/usecases/s3FileRequestCreationUiController/selectors.ts b/web/src/core/usecases/s3FileRequestCreationUiController/selectors.ts new file mode 100644 index 000000000..9a8ae4945 --- /dev/null +++ b/web/src/core/usecases/s3FileRequestCreationUiController/selectors.ts @@ -0,0 +1,44 @@ +import { createSelector } from "clean-architecture"; +import type { State as RootState } from "core/bootstrap"; +import { name, type State } from "./state"; + +const state = (rootState: RootState): State => rootState[name]; + +export type MainView = { + folderName: string; + validityDuration: State.ValidityDuration; + maxObjectSize: State.MaxObjectSize; + presignedPost: State["presignedPost"]; + errorMessage: string | undefined; +}; + +const mainView = createSelector( + state, + ({ + s3Uri, + validityDuration, + maxObjectSize, + presignedPost, + errorMessage + }): MainView => ({ + folderName: s3Uri.keySegments.at(-1) ?? s3Uri.bucket, + validityDuration, + maxObjectSize, + presignedPost, + errorMessage + }) +); + +const createPresignedPostParams = createSelector( + state, + ({ s3Uri, profileName, validityDuration, maxObjectSize }) => ({ + s3Uri, + profileName, + validityDuration, + maxObjectSize + }) +); + +export const selectors = { mainView }; + +export const privateSelectors = { createPresignedPostParams }; diff --git a/web/src/core/usecases/s3FileRequestCreationUiController/state.ts b/web/src/core/usecases/s3FileRequestCreationUiController/state.ts new file mode 100644 index 000000000..ac4ae7f16 --- /dev/null +++ b/web/src/core/usecases/s3FileRequestCreationUiController/state.ts @@ -0,0 +1,102 @@ +import { + createObjectThatThrowsIfAccessed, + createUsecaseActions +} from "clean-architecture"; +import type { S3Client } from "core/ports/S3Client"; +import type { S3Uri } from "core/tools/S3Uri"; +import { id } from "tsafe/id"; + +export type PresignedPost = S3Client.PresignedPost; + +export type State = { + s3Uri: S3Uri.TerminatedByDelimiter; + profileName: string; + validityDuration: State.ValidityDuration; + maxObjectSize: State.MaxObjectSize; + generationId: number | undefined; + presignedPost: PresignedPost | undefined; + errorMessage: string | undefined; +}; + +export namespace State { + export type ValidityDuration = "one hour" | "one day" | "one week"; + + export type MaxObjectSize = "no limit" | "10 MB" | "100 MB" | "1 GB" | "5 GB"; +} + +export const name = "s3FileRequestCreationUiController"; + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: createObjectThatThrowsIfAccessed(), + reducers: { + loaded: ( + _state, + { + payload + }: { + payload: { + s3Uri: S3Uri.TerminatedByDelimiter; + profileName: string; + }; + } + ) => + id({ + ...payload, + validityDuration: "one day", + maxObjectSize: "no limit", + generationId: undefined, + presignedPost: undefined, + errorMessage: undefined + }), + validityDurationChanged: ( + state, + { payload }: { payload: { validityDuration: State.ValidityDuration } } + ) => { + state.validityDuration = payload.validityDuration; + }, + maxObjectSizeChanged: ( + state, + { payload }: { payload: { maxObjectSize: State.MaxObjectSize } } + ) => { + state.maxObjectSize = payload.maxObjectSize; + }, + generationStarted: ( + state, + { payload }: { payload: { generationId: number } } + ) => { + state.generationId = payload.generationId; + state.presignedPost = undefined; + state.errorMessage = undefined; + }, + generationSucceeded: ( + state, + { + payload + }: { + payload: { + generationId: number; + presignedPost: PresignedPost; + }; + } + ) => { + if (state.generationId !== payload.generationId) { + return; + } + + state.generationId = undefined; + state.presignedPost = payload.presignedPost; + }, + generationFailed: ( + state, + { payload }: { payload: { generationId: number; errorMessage: string } } + ) => { + if (state.generationId !== payload.generationId) { + return; + } + + state.generationId = undefined; + state.errorMessage = payload.errorMessage; + } + } +}); diff --git a/web/src/core/usecases/s3FileRequestCreationUiController/thunks.ts b/web/src/core/usecases/s3FileRequestCreationUiController/thunks.ts new file mode 100644 index 000000000..97e131828 --- /dev/null +++ b/web/src/core/usecases/s3FileRequestCreationUiController/thunks.ts @@ -0,0 +1,112 @@ +import type { Thunks } from "core/bootstrap"; +import * as s3ProfilesManagement from "core/usecases/s3ProfilesManagement"; +import { assert } from "tsafe/assert"; +import { actions, type State } from "./state"; +import { privateSelectors } from "./selectors"; +import type { S3Uri } from "core/tools/S3Uri"; + +let nextGenerationId = 0; + +export const thunks = { + load: + (params: { s3Uri: S3Uri.TerminatedByDelimiter }) => + (...args) => { + const [dispatch, getState] = args; + + const s3Profile = s3ProfilesManagement.selectors.ambientS3Profile(getState()); + + assert(s3Profile !== undefined); + + dispatch( + actions.loaded({ + s3Uri: params.s3Uri, + profileName: s3Profile.profileName + }) + ); + }, + changeValidityDuration: + (params: { validityDuration: State.ValidityDuration }) => + (...args) => { + const [dispatch] = args; + + dispatch(actions.validityDurationChanged(params)); + }, + changeMaxObjectSize: + (params: { maxObjectSize: State.MaxObjectSize }) => + (...args) => { + const [dispatch] = args; + + dispatch(actions.maxObjectSizeChanged(params)); + }, + retryGeneration: + () => + (...args) => { + const [dispatch] = args; + + dispatch(privateThunks.updatePresignedPost()); + } +} satisfies Thunks; + +export const privateThunks = { + updatePresignedPost: + () => + async (...args) => { + const [dispatch, getState] = args; + + const generationId = ++nextGenerationId; + + dispatch(actions.generationStarted({ generationId })); + + const { s3Uri, profileName, validityDuration, maxObjectSize } = + privateSelectors.createPresignedPostParams(getState()); + + const s3Client = await dispatch( + s3ProfilesManagement.protectedThunks.getS3Client({ profileName }) + ); + + const result = await s3Client.createPresignedPost({ + s3Uri, + validityDurationSecond: (() => { + switch (validityDuration) { + case "one hour": + return 60 * 60; + case "one day": + return 60 * 60 * 24; + case "one week": + return 60 * 60 * 24 * 7; + } + })(), + maxObjectSizeInBytes: (() => { + switch (maxObjectSize) { + case "no limit": + return undefined; + case "10 MB": + return 10 * 1024 ** 2; + case "100 MB": + return 100 * 1024 ** 2; + case "1 GB": + return 1024 ** 3; + case "5 GB": + return 5 * 1024 ** 3; + } + })() + }); + + if (!result.isSuccess) { + dispatch( + actions.generationFailed({ + generationId, + errorMessage: result.errorMessage + }) + ); + return; + } + + dispatch( + actions.generationSucceeded({ + generationId, + presignedPost: result.presignedPost + }) + ); + } +} satisfies Thunks; diff --git a/web/src/core/usecases/s3FileRequestUiController/state.ts b/web/src/core/usecases/s3FileRequestUiController/state.ts index f36d08b4f..0cfdc7b8c 100644 --- a/web/src/core/usecases/s3FileRequestUiController/state.ts +++ b/web/src/core/usecases/s3FileRequestUiController/state.ts @@ -6,7 +6,7 @@ import type { S3Client } from "core/ports/S3Client"; import { assert } from "tsafe/assert"; import { id } from "tsafe/id"; -export type PresignedPost = Awaited>; +export type PresignedPost = S3Client.PresignedPost; export type State = { presignedPost: PresignedPost; diff --git a/web/src/core/usecases/s3FileRequestUiController/thunks.ts b/web/src/core/usecases/s3FileRequestUiController/thunks.ts index 96e640040..eb2c30df7 100644 --- a/web/src/core/usecases/s3FileRequestUiController/thunks.ts +++ b/web/src/core/usecases/s3FileRequestUiController/thunks.ts @@ -179,18 +179,8 @@ export const privateThunks = { xhrByUploadId.set(uploadId, xhr); - try { - xhr.open("POST", presignedPost.url); - xhr.send(formData); - } catch (error) { - complete({ - status: "failed", - errorMessage: - error instanceof Error - ? error.message - : "The upload could not be started." - }); - } + xhr.open("POST", presignedPost.url); + xhr.send(formData); }); } } satisfies Thunks; diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index 5f3889bb7..f63601ede 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -236,6 +236,9 @@ export const translations: Translations<"de"> = { S3ShareObjectDialogContainer: { "dialog title": "Objekt teilen" }, + S3FileRequestCreationDialogContainer: { + "dialog title": undefined + }, S3SharePrefixDialogContainer: { "dialog title": "Ordner teilen" }, @@ -273,6 +276,7 @@ export const translations: Translations<"de"> = { "new s3 profile": "Neues S3-Profil" }, S3SelectionActionBar: { + "request files": undefined, download: "Herunterladen", delete: "Löschen", "copy s3 uri": "S3-URI kopieren", @@ -383,6 +387,7 @@ export const translations: Translations<"de"> = { "make private": "Privat machen" }, S3ExplorerMainView: { + "request files": undefined, "create prefix dialog title": "Präfix erstellen", "create prefix dialog subtitle": "Erstelle ein neues Präfix im aktuellen S3-Speicherort.", @@ -462,6 +467,24 @@ export const translations: Translations<"de"> = { "validity duration one week": "1 Woche", "selected duration": "die ausgewählte Dauer" }, + S3FileRequestCreationDialog: { + description: undefined, + "link settings": undefined, + "link expires after": undefined, + "link validity aria label": undefined, + "maximum size per file": undefined, + "maximum file size aria label": undefined, + "upload link": undefined, + "generating upload link": undefined, + "copy upload link aria label": undefined, + "generation failed": undefined, + retry: undefined, + "security note": undefined, + "validity duration one hour": undefined, + "validity duration one day": undefined, + "validity duration one week": undefined, + "no limit": undefined + }, S3SharePrefixDialog: { "copy folder URL aria label": "Ordner-URL kopieren", "public sharing note": diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 62a375109..ea660a9a0 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -258,6 +258,7 @@ export const translations: Translations<"en"> = { `You are about to delete ${count} selected item${count > 1 ? "s" : ""}. Deleting a prefix also deletes everything inside it.`, delete: "Delete", share: "Share", + "request files": "Request files", download: "Download", "copy s3 uri": "Copy S3 URI", copied: "Copied", @@ -322,6 +323,26 @@ export const translations: Translations<"en"> = { "validity duration one week": "1 week", "selected duration": "the selected duration" }, + S3FileRequestCreationDialog: { + description: + "Share this link with anyone—even someone without an account on this Onyxia instance—to let them upload files from their computer directly to this folder.", + "link settings": "Link settings", + "link expires after": "Link expires after", + "link validity aria label": "Upload link validity duration", + "maximum size per file": "Maximum size per file", + "maximum file size aria label": "Maximum size per uploaded file", + "upload link": "Upload link", + "generating upload link": "Generating upload link...", + "copy upload link aria label": "Copy upload link", + "generation failed": "The upload link could not be generated.", + retry: "Retry", + "security note": + "Anyone with this link can upload files to this folder until it expires. The link does not give access to view or download existing files.", + "validity duration one hour": "1 hour", + "validity duration one day": "1 day", + "validity duration one week": "1 week", + "no limit": "No limit" + }, S3SharePrefixDialog: { "copy folder URL aria label": "Copy folder URL", "public sharing note": @@ -364,6 +385,9 @@ export const translations: Translations<"en"> = { S3ShareObjectDialogContainer: { "dialog title": "Share object" }, + S3FileRequestCreationDialogContainer: { + "dialog title": "Request files" + }, S3SharePrefixDialogContainer: { "dialog title": "Share folder" }, @@ -409,6 +433,7 @@ export const translations: Translations<"en"> = { "add to bookmarks": "Add to bookmarks", "delete from bookmarks": "Delete from bookmarks", share: "Share", + "request files": "Request files", "make public": "Make public", "make private": "Make private", "one selected": "1 selected", diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index 0a5415505..308315f7a 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -231,6 +231,9 @@ export const translations: Translations<"es"> = { S3ShareObjectDialogContainer: { "dialog title": "Compartir objeto" }, + S3FileRequestCreationDialogContainer: { + "dialog title": undefined + }, S3SharePrefixDialogContainer: { "dialog title": "Compartir carpeta" }, @@ -268,6 +271,7 @@ export const translations: Translations<"es"> = { "new s3 profile": "Nuevo perfil S3" }, S3SelectionActionBar: { + "request files": undefined, download: "Descargar", delete: "Eliminar", "copy s3 uri": "Copiar URI S3", @@ -376,6 +380,7 @@ export const translations: Translations<"es"> = { "make private": "Hacer privado" }, S3ExplorerMainView: { + "request files": undefined, "create prefix dialog title": "Crear prefijo", "create prefix dialog subtitle": "Crea un nuevo prefijo dentro de la ubicación S3 actual.", @@ -454,6 +459,24 @@ export const translations: Translations<"es"> = { "validity duration one week": "1 semana", "selected duration": "la duración seleccionada" }, + S3FileRequestCreationDialog: { + description: undefined, + "link settings": undefined, + "link expires after": undefined, + "link validity aria label": undefined, + "maximum size per file": undefined, + "maximum file size aria label": undefined, + "upload link": undefined, + "generating upload link": undefined, + "copy upload link aria label": undefined, + "generation failed": undefined, + retry: undefined, + "security note": undefined, + "validity duration one hour": undefined, + "validity duration one day": undefined, + "validity duration one week": undefined, + "no limit": undefined + }, S3SharePrefixDialog: { "copy folder URL aria label": "Copiar URL de la carpeta", "public sharing note": diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 3df679a92..cebc434ba 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -228,6 +228,9 @@ export const translations: Translations<"fi"> = { S3ShareObjectDialogContainer: { "dialog title": "Jaa objekti" }, + S3FileRequestCreationDialogContainer: { + "dialog title": undefined + }, S3SharePrefixDialogContainer: { "dialog title": "Jaa kansio" }, @@ -265,6 +268,7 @@ export const translations: Translations<"fi"> = { "new s3 profile": "Uusi S3-profiili" }, S3SelectionActionBar: { + "request files": undefined, download: "Lataa", delete: "Poista", "copy s3 uri": "Kopioi S3-URI", @@ -369,6 +373,7 @@ export const translations: Translations<"fi"> = { "make private": "Tee yksityiseksi" }, S3ExplorerMainView: { + "request files": undefined, "create prefix dialog title": "Luo etuliite", "create prefix dialog subtitle": "Luo uusi etuliite nykyiseen S3-sijaintiin.", "prefix name field label": "Etuliitteen nimi", @@ -446,6 +451,24 @@ export const translations: Translations<"fi"> = { "validity duration one week": "1 viikko", "selected duration": "valittu kesto" }, + S3FileRequestCreationDialog: { + description: undefined, + "link settings": undefined, + "link expires after": undefined, + "link validity aria label": undefined, + "maximum size per file": undefined, + "maximum file size aria label": undefined, + "upload link": undefined, + "generating upload link": undefined, + "copy upload link aria label": undefined, + "generation failed": undefined, + retry: undefined, + "security note": undefined, + "validity duration one hour": undefined, + "validity duration one day": undefined, + "validity duration one week": undefined, + "no limit": undefined + }, S3SharePrefixDialog: { "copy folder URL aria label": "Kopioi kansion URL", "public sharing note": diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 3f1d708dc..65639b7b5 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -237,6 +237,9 @@ export const translations: Translations<"fr"> = { S3ShareObjectDialogContainer: { "dialog title": "Partager l'objet" }, + S3FileRequestCreationDialogContainer: { + "dialog title": "Demander des fichiers" + }, S3SharePrefixDialogContainer: { "dialog title": "Partager le dossier" }, @@ -282,6 +285,7 @@ export const translations: Translations<"fr"> = { "add to bookmarks": "Ajouter aux favoris", "delete from bookmarks": "Supprimer des favoris", share: "Partager", + "request files": "Demander des fichiers", "make public": "Rendre public", "make private": "Rendre privé", "one selected": "1 sélectionné", @@ -395,6 +399,7 @@ export const translations: Translations<"fr"> = { `Vous êtes sur le point de supprimer ${count} élément${count > 1 ? "s" : ""} sélectionné${count > 1 ? "s" : ""}. Supprimer un préfixe supprime aussi tout son contenu.`, delete: "Supprimer", share: "Partager", + "request files": "Demander des fichiers", download: "Télécharger", "copy s3 uri": "Copier l'URI S3", copied: "Copié", @@ -460,6 +465,26 @@ export const translations: Translations<"fr"> = { "validity duration one week": "1 semaine", "selected duration": "la durée sélectionnée" }, + S3FileRequestCreationDialog: { + description: + "Partagez ce lien avec n’importe qui — même une personne sans compte sur cette instance Onyxia — pour lui permettre de téléverser des fichiers depuis son ordinateur directement dans ce dossier.", + "link settings": "Paramètres du lien", + "link expires after": "Expiration du lien", + "link validity aria label": "Durée de validité du lien de téléversement", + "maximum size per file": "Taille maximale par fichier", + "maximum file size aria label": "Taille maximale par fichier téléversé", + "upload link": "Lien de téléversement", + "generating upload link": "Génération du lien de téléversement...", + "copy upload link aria label": "Copier le lien de téléversement", + "generation failed": "Le lien de téléversement n’a pas pu être généré.", + retry: "Réessayer", + "security note": + "Toute personne disposant de ce lien peut téléverser des fichiers dans ce dossier jusqu’à son expiration. Le lien ne permet pas de voir ni de télécharger les fichiers existants.", + "validity duration one hour": "1 heure", + "validity duration one day": "1 jour", + "validity duration one week": "1 semaine", + "no limit": "Aucune limite" + }, S3SharePrefixDialog: { "copy folder URL aria label": "Copier l'URL du dossier", "public sharing note": diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index 66764060f..b7de94cae 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -231,6 +231,9 @@ export const translations: Translations<"it"> = { S3ShareObjectDialogContainer: { "dialog title": "Condividi oggetto" }, + S3FileRequestCreationDialogContainer: { + "dialog title": undefined + }, S3SharePrefixDialogContainer: { "dialog title": "Condividi cartella" }, @@ -268,6 +271,7 @@ export const translations: Translations<"it"> = { "new s3 profile": "Nuovo profilo S3" }, S3SelectionActionBar: { + "request files": undefined, download: "Scarica", delete: "Elimina", "copy s3 uri": "Copia URI S3", @@ -374,6 +378,7 @@ export const translations: Translations<"it"> = { "make private": "Rendi privato" }, S3ExplorerMainView: { + "request files": undefined, "create prefix dialog title": "Crea prefisso", "create prefix dialog subtitle": "Crea un nuovo prefisso nella posizione S3 corrente.", @@ -453,6 +458,24 @@ export const translations: Translations<"it"> = { "validity duration one week": "1 settimana", "selected duration": "la durata selezionata" }, + S3FileRequestCreationDialog: { + description: undefined, + "link settings": undefined, + "link expires after": undefined, + "link validity aria label": undefined, + "maximum size per file": undefined, + "maximum file size aria label": undefined, + "upload link": undefined, + "generating upload link": undefined, + "copy upload link aria label": undefined, + "generation failed": undefined, + retry: undefined, + "security note": undefined, + "validity duration one hour": undefined, + "validity duration one day": undefined, + "validity duration one week": undefined, + "no limit": undefined + }, S3SharePrefixDialog: { "copy folder URL aria label": "Copia URL della cartella", "public sharing note": diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index c13bd3a8f..179c28839 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -232,6 +232,9 @@ export const translations: Translations<"nl"> = { S3ShareObjectDialogContainer: { "dialog title": "Object delen" }, + S3FileRequestCreationDialogContainer: { + "dialog title": undefined + }, S3SharePrefixDialogContainer: { "dialog title": "Map delen" }, @@ -269,6 +272,7 @@ export const translations: Translations<"nl"> = { "new s3 profile": "Nieuw S3-profiel" }, S3SelectionActionBar: { + "request files": undefined, download: "Downloaden", delete: "Verwijderen", "copy s3 uri": "S3-URI kopiëren", @@ -373,6 +377,7 @@ export const translations: Translations<"nl"> = { "make private": "Privé maken" }, S3ExplorerMainView: { + "request files": undefined, "create prefix dialog title": "Prefix aanmaken", "create prefix dialog subtitle": "Maak een nieuwe prefix aan binnen de huidige S3-locatie.", @@ -451,6 +456,24 @@ export const translations: Translations<"nl"> = { "validity duration one week": "1 week", "selected duration": "de geselecteerde duur" }, + S3FileRequestCreationDialog: { + description: undefined, + "link settings": undefined, + "link expires after": undefined, + "link validity aria label": undefined, + "maximum size per file": undefined, + "maximum file size aria label": undefined, + "upload link": undefined, + "generating upload link": undefined, + "copy upload link aria label": undefined, + "generation failed": undefined, + retry: undefined, + "security note": undefined, + "validity duration one hour": undefined, + "validity duration one day": undefined, + "validity duration one week": undefined, + "no limit": undefined + }, S3SharePrefixDialog: { "copy folder URL aria label": "Map-URL kopiëren", "public sharing note": diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 04c7890d5..a508f255c 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -228,6 +228,9 @@ export const translations: Translations<"no"> = { S3ShareObjectDialogContainer: { "dialog title": "Del objekt" }, + S3FileRequestCreationDialogContainer: { + "dialog title": undefined + }, S3SharePrefixDialogContainer: { "dialog title": "Del mappe" }, @@ -265,6 +268,7 @@ export const translations: Translations<"no"> = { "new s3 profile": "Ny S3-profil" }, S3SelectionActionBar: { + "request files": undefined, download: "Last ned", delete: "Slett", "copy s3 uri": "Kopier S3-URI", @@ -370,6 +374,7 @@ export const translations: Translations<"no"> = { "make private": "Gjør privat" }, S3ExplorerMainView: { + "request files": undefined, "create prefix dialog title": "Opprett prefiks", "create prefix dialog subtitle": "Opprett et nytt prefiks i gjeldende S3-plassering.", @@ -449,6 +454,24 @@ export const translations: Translations<"no"> = { "validity duration one week": "1 uke", "selected duration": "den valgte varigheten" }, + S3FileRequestCreationDialog: { + description: undefined, + "link settings": undefined, + "link expires after": undefined, + "link validity aria label": undefined, + "maximum size per file": undefined, + "maximum file size aria label": undefined, + "upload link": undefined, + "generating upload link": undefined, + "copy upload link aria label": undefined, + "generation failed": undefined, + retry: undefined, + "security note": undefined, + "validity duration one hour": undefined, + "validity duration one day": undefined, + "validity duration one week": undefined, + "no limit": undefined + }, S3SharePrefixDialog: { "copy folder URL aria label": "Kopier mappe-URL", "public sharing note": diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index da016cf68..ede095727 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -209,6 +209,9 @@ export const translations: Translations<"zh-CN"> = { S3ShareObjectDialogContainer: { "dialog title": "共享对象" }, + S3FileRequestCreationDialogContainer: { + "dialog title": undefined + }, S3SharePrefixDialogContainer: { "dialog title": "共享文件夹" }, @@ -246,6 +249,7 @@ export const translations: Translations<"zh-CN"> = { "new s3 profile": "新建 S3 配置文件" }, S3SelectionActionBar: { + "request files": undefined, download: "下载", delete: "删除", "copy s3 uri": "复制 S3 URI", @@ -348,6 +352,7 @@ export const translations: Translations<"zh-CN"> = { "make private": "设为私有" }, S3ExplorerMainView: { + "request files": undefined, "create prefix dialog title": "创建前缀", "create prefix dialog subtitle": "在当前 S3 位置内创建一个新前缀。", "prefix name field label": "前缀名称", @@ -420,6 +425,24 @@ export const translations: Translations<"zh-CN"> = { "validity duration one week": "1 周", "selected duration": "所选时长" }, + S3FileRequestCreationDialog: { + description: undefined, + "link settings": undefined, + "link expires after": undefined, + "link validity aria label": undefined, + "maximum size per file": undefined, + "maximum file size aria label": undefined, + "upload link": undefined, + "generating upload link": undefined, + "copy upload link aria label": undefined, + "generation failed": undefined, + retry: undefined, + "security note": undefined, + "validity duration one hour": undefined, + "validity duration one day": undefined, + "validity duration one week": undefined, + "no limit": undefined + }, S3SharePrefixDialog: { "copy folder URL aria label": "复制文件夹 URL", "public sharing note": diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index f7b530251..3a99e4a78 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -28,6 +28,8 @@ export type ComponentKey = | import("ui/pages/s3Explorer/dialogs/S3ShareObjectDialog").I18n | import("ui/shared/codex/S3SharePrefixDialog").I18n | import("ui/pages/s3Explorer/dialogs/S3SharePrefixDialog").I18n + | import("ui/pages/s3Explorer/dialogs/S3FileRequestCreationDialog").I18n + | import("ui/shared/codex/S3FileRequestCreationDialog").I18n | import("ui/pages/s3Explorer/dialogs/S3ProfileDialog").I18n | import("ui/pages/s3Explorer/Page").I18n | import("ui/pages/s3FileRequest/Page").I18n diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index ea3d21901..18b57c6f5 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -102,6 +102,7 @@ function S3Explorer() { evtS3ProfileDialogOpen: new Evt(), evtS3ShareObjectDialogOpen: new Evt(), evtS3SharePrefixDialogOpen: new Evt(), + evtS3FileRequestCreationDialogOpen: new Evt(), evtMaybeAcknowledgeConfigVolatilityDialogOpen: new Evt() }) ); @@ -660,6 +661,13 @@ function S3Explorer() { anonymousProfileName }) } + onRequestFiles={({ s3Uri }) => + dialogProps.evtS3FileRequestCreationDialogOpen.post( + { + s3Uri + } + ) + } onBookmark={ isUserLoggedIn ? toggleBookmarkFromDataView diff --git a/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx index 56e1b1779..fb246c0df 100644 --- a/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx +++ b/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx @@ -32,6 +32,10 @@ import { S3SharePrefixDialog, type S3SharePrefixDialogProps } from "./S3SharePrefixDialog"; +import { + S3FileRequestCreationDialog, + type S3FileRequestCreationDialogProps +} from "./S3FileRequestCreationDialog"; import { MaybeAcknowledgeConfigVolatilityDialog, type MaybeAcknowledgeConfigVolatilityDialogProps @@ -48,6 +52,7 @@ export type S3ExplorerDialogsProps = { evtDisplayErrorDialogOpen: DisplayErrorDialogProps["evtOpen"]; evtS3ShareObjectDialogOpen: S3ShareObjectDialogProps["evtOpen"]; evtS3SharePrefixDialogOpen: S3SharePrefixDialogProps["evtOpen"]; + evtS3FileRequestCreationDialogOpen: S3FileRequestCreationDialogProps["evtOpen"]; evtMaybeAcknowledgeConfigVolatilityDialogOpen: MaybeAcknowledgeConfigVolatilityDialogProps["evtOpen"]; }; @@ -63,6 +68,7 @@ export function S3ExplorerDialogs(props: S3ExplorerDialogsProps) { evtDisplayErrorDialogOpen, evtS3ShareObjectDialogOpen, evtS3SharePrefixDialogOpen, + evtS3FileRequestCreationDialogOpen, evtMaybeAcknowledgeConfigVolatilityDialogOpen } = props; @@ -82,6 +88,7 @@ export function S3ExplorerDialogs(props: S3ExplorerDialogsProps) { + diff --git a/web/src/ui/pages/s3Explorer/dialogs/S3FileRequestCreationDialog.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3FileRequestCreationDialog.tsx new file mode 100644 index 000000000..bad520b15 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/dialogs/S3FileRequestCreationDialog.tsx @@ -0,0 +1,96 @@ +import type { Evt, UnpackEvt } from "evt"; +import { useEvt } from "evt/hooks/useEvt"; +import { useState } from "react"; +import { Dialog } from "onyxia-ui/Dialog"; +import type { S3Uri } from "core/tools/S3Uri"; +import { getCore, getCoreSync, useCoreState } from "core"; +import { withLoader } from "ui/tools/withLoader"; +import { routes } from "ui/routes"; +import { S3FileRequestCreationDialog as S3FileRequestCreationDialog_headless } from "ui/shared/codex/S3FileRequestCreationDialog"; +import { declareComponentKeys, useTranslation } from "ui/i18n"; + +export type S3FileRequestCreationDialogProps = { + evtOpen: Evt<{ + s3Uri: S3Uri.TerminatedByDelimiter; + }>; +}; + +export function S3FileRequestCreationDialog(props: S3FileRequestCreationDialogProps) { + return ; +} + +function S3FileRequestCreationDialogContainer(props: S3FileRequestCreationDialogProps) { + const { evtOpen } = props; + const [state, setState] = useState< + UnpackEvt | undefined + >(undefined); + + useEvt( + ctx => { + evtOpen.attach(ctx, eventData => setState(eventData)); + }, + [evtOpen] + ); + + const { t } = useTranslation({ S3FileRequestCreationDialogContainer }); + + return ( + } + isOpen={state !== undefined} + onClose={() => setState(undefined)} + showCloseButton + /> + ); +} + +const Body = withLoader<{ + s3Uri: S3Uri.TerminatedByDelimiter; +}>({ + loader: async ({ s3Uri }) => { + const core = await getCore(); + + core.functions.s3FileRequestCreationUiController.load({ s3Uri }); + }, + FallbackComponent: () => null, + Component: () => { + const mainView = useCoreState("s3FileRequestCreationUiController", "mainView"); + const { + functions: { s3FileRequestCreationUiController } + } = getCoreSync(); + + const uploadPageUrl = + mainView.presignedPost === undefined + ? undefined + : new URL( + routes.s3FileRequest({ + presignedPost: mainView.presignedPost + }).link.href, + window.location.href + ).href; + + return ( + + ); + } +}); + +const { i18n } = declareComponentKeys<"dialog title">()({ + S3FileRequestCreationDialogContainer +}); +export type I18n = typeof i18n; diff --git a/web/src/ui/pages/s3FileRequest/route.ts b/web/src/ui/pages/s3FileRequest/route.ts index ddf265add..121eb16cb 100644 --- a/web/src/ui/pages/s3FileRequest/route.ts +++ b/web/src/ui/pages/s3FileRequest/route.ts @@ -1,12 +1,9 @@ import { defineRoute, createGroup, param } from "type-route"; import { id } from "tsafe"; import type { ValueSerializer } from "type-route"; +import type { S3Client } from "core/ports/S3Client"; -type PresignedPost = { - url: string; - fields: Record; - expirationTime: number; -}; +type PresignedPost = S3Client.PresignedPost; export const routeDefs = { s3FileRequest: defineRoute( diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx index 93a31d453..984e1f984 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx @@ -171,6 +171,7 @@ const placeholderArgs: S3ExplorerMainViewProps = { onDownload: action("download"), onShareObject: action("shareObject"), onSharePrefix: action("sharePrefix"), + onRequestFiles: action("requestFiles"), onBookmark: action("bookmark"), bookmarkedS3Uris: [], onChangePrefixPolicy: action("changePrefixPolicy"), @@ -236,6 +237,7 @@ function StatefulExplorer( | "onDownload" | "onShareObject" | "onSharePrefix" + | "onRequestFiles" | "onBookmark" | "bookmarkedS3Uris" | "onChangePrefixPolicy" @@ -352,6 +354,9 @@ function StatefulExplorer( onSharePrefix={params => { action("sharePrefix")(params); }} + onRequestFiles={params => { + action("requestFiles")(params); + }} onBookmark={({ s3Uri }) => { action("bookmark")(s3Uri); }} @@ -432,6 +437,7 @@ export const EmptyPrefix: Story = { onDownload: action("download"), onShareObject: action("shareObject"), onSharePrefix: action("sharePrefix"), + onRequestFiles: action("requestFiles"), onBookmark: action("bookmark"), bookmarkedS3Uris: [], onChangePrefixPolicy: action("changePrefixPolicy"), @@ -510,6 +516,7 @@ export const FullyQualifiedObject: Story = { onDownload: action("download"), onShareObject: action("shareObject"), onSharePrefix: action("sharePrefix"), + onRequestFiles: action("requestFiles"), onBookmark: action("bookmark"), bookmarkedS3Uris: [], onChangePrefixPolicy: action("changePrefixPolicy"), @@ -540,6 +547,7 @@ export const AccessDenied: Story = { onDownload: action("download"), onShareObject: action("shareObject"), onSharePrefix: action("sharePrefix"), + onRequestFiles: action("requestFiles"), onBookmark: action("bookmark"), bookmarkedS3Uris: [], onChangePrefixPolicy: action("changePrefixPolicy"), diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx index e1689e15a..409cc7443 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx @@ -87,6 +87,8 @@ export type S3ExplorerMainViewProps = { anonymousProfileName: string; }) => void; + onRequestFiles: (params: { s3Uri: S3Uri.TerminatedByDelimiter }) => void; + onBookmark: ((params: { s3Uri: S3Uri }) => void) | undefined; onDisplayCopyFeedback: (params: { s3Uri: S3Uri }) => void; @@ -145,6 +147,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { onDownload, onShareObject, onSharePrefix, + onRequestFiles, onBookmark, bookmarkedS3Uris, onChangePrefixPolicy, @@ -572,6 +575,16 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { } ); + const requestFilesForPrefix = useConstCallback( + (item: S3ExplorerMainViewProps.Item.PrefixSegment) => { + if (!getIsItemActionAvailable(item)) { + return; + } + + onRequestFiles({ s3Uri: item.s3Uri }); + } + ); + const requestDownloadForItems = useConstCallback( (itemsToDownload: S3ExplorerMainViewProps.Item[]) => { const downloadableItems = itemsToDownload.filter(getIsItemActionAvailable); @@ -663,6 +676,16 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { requestPrefixPolicyChangeForItem(item); }); + const onRequestFilesFactory = useCallbackFactory(([itemKey]: [string]) => { + const item = itemByKey.get(itemKey); + + if (item === undefined || item.type !== "prefix segment") { + return; + } + + requestFilesForPrefix(item); + }); + const onDownloadFactory = useCallbackFactory(([itemKey]: [string]) => { const item = itemByKey.get(itemKey); @@ -833,6 +856,19 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { ) } } + requestFiles={ + selectedPrefixForSingleItemAction === undefined || + !getIsItemActionAvailable( + selectedPrefixForSingleItemAction + ) + ? undefined + : { + callback: () => + requestFilesForPrefix( + selectedPrefixForSingleItemAction + ) + } + } accessPolicy={ selectedPrefixForSingleItemAction === undefined || selectedPrefixPolicyAction === undefined @@ -1156,6 +1192,13 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { ? onShareFactory(itemKey) : undefined } + onRequestFiles={ + item.type === "prefix segment" + ? onRequestFilesFactory( + itemKey + ) + : undefined + } onChangePrefixPolicy={ item.type === "prefix segment" && getPrefixPolicyAction(item) !== @@ -1810,6 +1853,7 @@ const { i18n } = declareComponentKeys< | { K: "delete selection dialog body"; P: { count: number }; R: string } | "delete" | "share" + | "request files" | "download" | "copy s3 uri" | "copied" @@ -2445,6 +2489,7 @@ type ItemRowProps = { onNavigate: () => void; onDelete: () => void; onShare: (() => void) | undefined; + onRequestFiles: (() => void) | undefined; onChangePrefixPolicy: (() => void) | undefined; onDownload: (() => void) | undefined; onBookmark: (() => void) | undefined; @@ -2467,6 +2512,7 @@ const ItemRow = memo(function ItemRow(props: ItemRowProps) { onNavigate, onDelete, onShare, + onRequestFiles, onChangePrefixPolicy, onDownload, onBookmark, @@ -2480,6 +2526,7 @@ const ItemRow = memo(function ItemRow(props: ItemRowProps) { const isItemActionAvailable = getIsItemActionAvailable(item); const isDownloadAvailable = onDownload !== undefined && isItemActionAvailable; const isShareAvailable = onShare !== undefined && isItemActionAvailable; + const isRequestFilesAvailable = onRequestFiles !== undefined && isItemActionAvailable; const prefixPolicyAction = getPrefixPolicyAction(item); const isPrefixPolicyActionAvailable = onChangePrefixPolicy !== undefined && isItemActionAvailable; @@ -2846,6 +2893,32 @@ const ItemRow = memo(function ItemRow(props: ItemRowProps) { )} + {onRequestFiles !== undefined && ( + + + { + event.stopPropagation(); + + if (!isRequestFilesAvailable) { + return; + } + + onRequestFiles(); + }} + /> + + + )} {prefixPolicyAction !== undefined && onChangePrefixPolicy !== undefined && ( void; + changeMaxObjectSize: (params: { + maxObjectSize: S3FileRequestCreationDialogProps.MaxObjectSize; + }) => void; + retryGeneration: () => void; +}; + +export namespace S3FileRequestCreationDialogProps { + export type ValidityDuration = "one hour" | "one day" | "one week"; + + export type MaxObjectSize = "no limit" | "10 MB" | "100 MB" | "1 GB" | "5 GB"; +} +``` + +# General Structure + +The component renders a regular box composed of: + +1. A destination folder summary and explanatory text +2. A link settings section +3. An upload link section +4. A bottom security note + +The parent owns modal chrome, title, close button, URL generation, state updates, +and lifecycle. + +# Rendering Rules + +## Destination Folder + +Display `folderName` with a folder icon, followed by text explaining that the link +can be shared with anyone, including someone without an account, to upload files +to this folder. + +Long folder names must wrap without breaking the layout. + +## Link Settings + +Render two controlled selects: + +- **Link expires after**, bound to `validityDuration` +- **Maximum size per file**, bound to `maxObjectSize` + +The validity selector offers exactly: + +- One hour +- One day +- One week + +Selecting a value invokes: + +```ts +changeValidityDuration({ validityDuration }); +``` + +The maximum file size selector offers exactly: + +- No limit +- 10 MB +- 100 MB +- 1 GB +- 5 GB + +Selecting a value invokes: + +```ts +changeMaxObjectSize({ maxObjectSize }); +``` + +The size limit applies independently to each uploaded file, not to the total size +of all files uploaded through the link. + +## Upload Link States + +The upload link section has three states controlled by `uploadPageUrl` and +`errorMessage`. + +### Pending + +When `errorMessage === undefined` and `uploadPageUrl === undefined`: + +- display a generating-link placeholder +- disable the copy action + +### Ready + +When `errorMessage === undefined` and `uploadPageUrl !== undefined`: + +- display the URL using the standard S3 dialog URL preview +- preserve the complete URL for navigation and copying +- let the user open the URL in a new browser tab +- let the user copy the complete URL +- show the standard copied confirmation after a successful copy + +### Error + +When `errorMessage !== undefined`: + +- replace the URL field with an error alert +- display a retry button +- invoke `retryGeneration()` when the retry button is clicked + +The component does not display the raw `errorMessage`; the prop determines that +the error state is active while the visible message remains localized and safe for +end users. + +## Security Note + +Display a prominent informational note explaining that anyone with the link can +upload files to the destination folder until the link expires. + +# Accessibility + +- Both selects have accessible names describing their setting. +- The generation failure container uses `role="alert"`. +- The copy button has an accessible name. +- The copy button is disabled while no URL is available. +- The URL can receive keyboard focus and opens with safe new-tab attributes. +- Focus states must remain visible on all interactive elements. + +# Layout Rules + +- The component fills the available modal body width and does not impose modal + sizing. +- Settings use two columns when space permits and one column at narrow widths. +- Long folder names and URLs must not cause horizontal overflow. +- Sections are visually separated while remaining part of a single vertical form. +- The optional `className` is merged with the root styles so the parent can size or + position the component. diff --git a/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.tsx b/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.tsx new file mode 100644 index 000000000..18f3f6074 --- /dev/null +++ b/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.tsx @@ -0,0 +1,362 @@ +import FormControl from "@mui/material/FormControl"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; +import { alpha } from "@mui/material/styles"; +import { Button } from "onyxia-ui/Button"; +import { Icon } from "onyxia-ui/Icon"; +import { Text } from "onyxia-ui/Text"; +import { getIconUrlByName } from "lazy-icons"; +import { tss } from "tss"; +import { assert, type Equals } from "tsafe/assert"; +import { declareComponentKeys, useTranslation } from "ui/i18n"; +import { + S3DialogCopyUrlField, + S3DialogItemSummary +} from "ui/shared/codex/S3DialogPrimitives"; + +export type S3FileRequestCreationDialogProps = { + className?: string; + folderName: string; + validityDuration: S3FileRequestCreationDialogProps.ValidityDuration; + maxObjectSize: S3FileRequestCreationDialogProps.MaxObjectSize; + uploadPageUrl: string | undefined; + errorMessage: string | undefined; + changeValidityDuration: (params: { + validityDuration: S3FileRequestCreationDialogProps.ValidityDuration; + }) => void; + changeMaxObjectSize: (params: { + maxObjectSize: S3FileRequestCreationDialogProps.MaxObjectSize; + }) => void; + retryGeneration: () => void; +}; + +export namespace S3FileRequestCreationDialogProps { + export type ValidityDuration = "one hour" | "one day" | "one week"; + + export type MaxObjectSize = "no limit" | "10 MB" | "100 MB" | "1 GB" | "5 GB"; +} + +const validityDurationOptions = ["one hour", "one day", "one week"] as const; +const maxObjectSizeOptions = ["no limit", "10 MB", "100 MB", "1 GB", "5 GB"] as const; + +assert< + Equals< + (typeof validityDurationOptions)[number], + S3FileRequestCreationDialogProps.ValidityDuration + > +>; +assert< + Equals< + (typeof maxObjectSizeOptions)[number], + S3FileRequestCreationDialogProps.MaxObjectSize + > +>; + +export function S3FileRequestCreationDialog(props: S3FileRequestCreationDialogProps) { + const { + className, + folderName, + validityDuration, + maxObjectSize, + uploadPageUrl, + errorMessage, + changeValidityDuration, + changeMaxObjectSize, + retryGeneration + } = props; + + const { t } = useTranslation({ S3FileRequestCreationDialog }); + const { classes, cx } = useStyles(); + + return ( +
+
+ + + {t("description")} + +
+ +
+ {t("link settings")} +
+ + + +
+
+ +
+ {t("upload link")} + {errorMessage === undefined ? ( + + ) : ( +
+
+ + {t("generation failed")} +
+ +
+ )} +
+ +
+ + + {t("security note")} + +
+
+ ); +} + +function isValidityDuration( + value: unknown +): value is S3FileRequestCreationDialogProps.ValidityDuration { + return ( + typeof value === "string" && + (validityDurationOptions as readonly string[]).includes(value) + ); +} + +function isMaxObjectSize( + value: unknown +): value is S3FileRequestCreationDialogProps.MaxObjectSize { + return ( + typeof value === "string" && + (maxObjectSizeOptions as readonly string[]).includes(value) + ); +} + +function formatValidityDuration( + validityDuration: S3FileRequestCreationDialogProps.ValidityDuration, + t: ReturnType["t"] +): string { + switch (validityDuration) { + case "one hour": + return t("validity duration one hour"); + case "one day": + return t("validity duration one day"); + case "one week": + return t("validity duration one week"); + } +} + +function formatMaxObjectSize( + maxObjectSize: S3FileRequestCreationDialogProps.MaxObjectSize, + t: ReturnType["t"] +): string { + return maxObjectSize === "no limit" ? t("no limit") : maxObjectSize; +} + +const useStyles = tss.withName({ S3FileRequestCreationDialog }).create(({ theme }) => ({ + root: { + display: "flex", + flexDirection: "column", + boxSizing: "border-box" + }, + folderSection: { + paddingBottom: theme.spacing(3), + borderBottom: `1px solid ${theme.colors.useCases.surfaces.surface2}` + }, + folderSummary: { + minHeight: 56, + gap: theme.spacing(2.5), + marginBottom: theme.spacing(2.5), + "& > :first-child": { + width: 54, + height: 54, + borderRadius: 10, + border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + backgroundColor: alpha(theme.colors.useCases.surfaces.surface2, 0.38) + }, + "& > :nth-child(2)": { + whiteSpace: "normal", + fontSize: 20, + lineHeight: 1.35, + fontWeight: 500 + } + }, + description: { + color: theme.colors.useCases.typography.textSecondary, + lineHeight: 1.55, + maxWidth: 760 + }, + settingsSection: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(2), + paddingTop: theme.spacing(3), + paddingBottom: theme.spacing(3), + borderBottom: `1px solid ${theme.colors.useCases.surfaces.surface2}` + }, + settingsGrid: { + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: theme.spacing(3), + "@media (max-width: 600px)": { + gridTemplateColumns: "minmax(0, 1fr)" + } + }, + setting: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(1), + minWidth: 0 + }, + settingLabel: { + color: theme.colors.useCases.typography.textSecondary + }, + select: { + minWidth: 0, + "& .MuiInputBase-root": { + minHeight: 54, + borderRadius: 10, + color: theme.colors.useCases.typography.textPrimary, + backgroundColor: alpha(theme.colors.useCases.surfaces.surface2, 0.18) + }, + "& .MuiOutlinedInput-notchedOutline": { + borderColor: theme.colors.useCases.surfaces.surface2 + }, + "& .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline": { + borderColor: alpha(theme.colors.useCases.typography.textFocus, 0.72) + }, + "& .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline": { + borderColor: theme.colors.useCases.typography.textFocus + }, + "& .MuiSelect-select": { + display: "flex", + alignItems: "center", + minHeight: "unset", + paddingTop: theme.spacing(1.5), + paddingBottom: theme.spacing(1.5), + paddingLeft: theme.spacing(2), + ...theme.typography.variants["body 1"].style + }, + "& .MuiSelect-icon": { + color: theme.colors.useCases.typography.textFocus + } + }, + linkSection: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(2), + minWidth: 0, + paddingTop: theme.spacing(3), + paddingBottom: theme.spacing(3) + }, + errorBox: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing(2), + padding: theme.spacing(2), + borderRadius: 10, + backgroundColor: alpha(theme.colors.useCases.alertSeverity.error.main, 0.1) + }, + errorText: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + color: theme.colors.useCases.alertSeverity.error.main + }, + infoSection: { + display: "grid", + gridTemplateColumns: "32px minmax(0, 1fr)", + gap: theme.spacing(2), + alignItems: "start", + paddingTop: theme.spacing(3), + borderTop: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + color: theme.colors.useCases.typography.textFocus + }, + infoText: { + color: theme.colors.useCases.typography.textSecondary, + lineHeight: 1.55, + maxWidth: 760 + } +})); + +const { i18n } = declareComponentKeys< + | "description" + | "link settings" + | "link expires after" + | "link validity aria label" + | "maximum size per file" + | "maximum file size aria label" + | "upload link" + | "generating upload link" + | "copy upload link aria label" + | "generation failed" + | "retry" + | "security note" + | "validity duration one hour" + | "validity duration one day" + | "validity duration one week" + | "no limit" +>()({ S3FileRequestCreationDialog }); +export type I18n = typeof i18n; diff --git a/web/src/ui/shared/codex/S3FileRequestCreationDialog/index.ts b/web/src/ui/shared/codex/S3FileRequestCreationDialog/index.ts new file mode 100644 index 000000000..b3f7784cc --- /dev/null +++ b/web/src/ui/shared/codex/S3FileRequestCreationDialog/index.ts @@ -0,0 +1 @@ +export * from "./S3FileRequestCreationDialog"; diff --git a/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.stories.tsx b/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.stories.tsx index 472e882b1..6a0bc3b90 100644 --- a/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.stories.tsx +++ b/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.stories.tsx @@ -34,6 +34,7 @@ const baseArgs: S3SelectionActionBarProps = { share: { callback: action("share") }, + requestFiles: undefined, accessPolicy: undefined }; @@ -55,6 +56,9 @@ export const SinglePrefix: Story = { ...baseArgs, download: undefined, share: undefined, + requestFiles: { + callback: action("requestFiles") + }, accessPolicy: { callback: action("makePublic"), isPublic: false @@ -71,6 +75,9 @@ export const PublicBookmarkedPrefix: Story = { isBookmarked: true }, share: undefined, + requestFiles: { + callback: action("requestFiles") + }, accessPolicy: { callback: action("makePrivate"), isPublic: true diff --git a/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.tsx b/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.tsx index 36a1c140c..0ae94e144 100644 --- a/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.tsx +++ b/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.tsx @@ -39,6 +39,11 @@ export type S3SelectionActionBarProps = { callback: () => void; } | undefined; + requestFiles: + | { + callback: () => void; + } + | undefined; accessPolicy: | { callback: () => void; @@ -67,6 +72,7 @@ export function S3SelectionActionBar(props: S3SelectionActionBarProps) { copyS3Uri, bookmark, share, + requestFiles, accessPolicy } = props; @@ -184,6 +190,20 @@ export function S3SelectionActionBar(props: S3SelectionActionBarProps) { ), onClick: share.callback }, + requestFiles === undefined + ? undefined + : { + key: "request-files", + label: t("request files"), + icon: ( + + ), + onClick: requestFiles.callback + }, accessPolicy === undefined ? undefined : { @@ -470,6 +490,7 @@ const { i18n } = declareComponentKeys< | "add to bookmarks" | "delete from bookmarks" | "share" + | "request files" | "make public" | "make private" | "one selected" diff --git a/web/yarn.lock b/web/yarn.lock index 594be0b36..85c1fc38e 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -83,7 +83,7 @@ "@smithy/util-utf8" "^2.0.0" tslib "^2.6.2" -"@aws-sdk/client-s3@^3.828.0": +"@aws-sdk/client-s3@3.828.0", "@aws-sdk/client-s3@^3.828.0": version "3.828.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-s3/-/client-s3-3.828.0.tgz#f026b618aa1cdae696a34c47aabb5712606ce0d7" integrity sha512-TvFyrEfJkf9NN3cq5mXCgFv/sPaA8Rm5tEPgV5emuLedeGsORlWmVpdSKqfZ4lSoED1tMfNM6LY4uA9D8/RS5g== @@ -827,6 +827,21 @@ "@smithy/types" "^4.9.0" tslib "^2.6.2" +"@aws-sdk/s3-presigned-post@3.828.0": + version "3.828.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/s3-presigned-post/-/s3-presigned-post-3.828.0.tgz#1bd1b4e9c3d5fb921886f0ef7e3681510848fe21" + integrity sha512-tCL7RehC9BkvzoNozhe28zQD9jeDuhtzWdkyVhRkoAJAQfMjPtcPcVIAH/WO7zY/+FJNJ4Q4EaeL+D7L83+fpg== + dependencies: + "@aws-sdk/client-s3" "3.828.0" + "@aws-sdk/types" "3.821.0" + "@aws-sdk/util-format-url" "3.821.0" + "@smithy/middleware-endpoint" "^4.1.11" + "@smithy/signature-v4" "^5.1.2" + "@smithy/types" "^4.3.1" + "@smithy/util-hex-encoding" "^4.0.0" + "@smithy/util-utf8" "^4.0.0" + tslib "^2.6.2" + "@aws-sdk/s3-request-presigner@^3.828.0": version "3.828.0" resolved "https://registry.yarnpkg.com/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.828.0.tgz#c9684a820d3b9b49d63b1f84a8478005f190762c" From a9abf65a68db9725a3b731874b2cd7e06babfba7 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 28 Aug 2026 17:22:09 +0000 Subject: [PATCH 04/22] Add missing translations --- web/src/ui/i18n/resources/de.tsx | 79 +++++++++++++++------------- web/src/ui/i18n/resources/es.tsx | 79 +++++++++++++++------------- web/src/ui/i18n/resources/fi.tsx | 79 +++++++++++++++------------- web/src/ui/i18n/resources/it.tsx | 79 +++++++++++++++------------- web/src/ui/i18n/resources/nl.tsx | 80 ++++++++++++++++------------- web/src/ui/i18n/resources/no.tsx | 79 +++++++++++++++------------- web/src/ui/i18n/resources/zh-CN.tsx | 78 +++++++++++++++------------- 7 files changed, 294 insertions(+), 259 deletions(-) diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index f63601ede..ee0b7dc9e 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -214,30 +214,33 @@ export const translations: Translations<"de"> = { "download file": "Datei herunterladen" }, S3FileRequest: { - "page title": undefined, - "page description": undefined, - "expires on": undefined, - "link expired": undefined, - "link expired description": undefined, - "drop files": undefined, - "drop files active": undefined, - "drop files hint": undefined, - "choose files": undefined, - "all files uploaded": undefined, - "all files uploaded description": undefined, - "uploads title": undefined, - uploading: undefined, - uploaded: undefined, - "upload failed": undefined, - "cancel upload": undefined, - "retry upload": undefined, - "privacy note": undefined + "page title": "Angeforderte Dateien hochladen", + "page description": + "Jemand hat diesen sicheren Link mit Ihnen geteilt, damit Sie Dateien direkt an den zugehörigen Speicherplatz senden können. Sie benötigen kein Onyxia-Konto.", + "expires on": ({ date }) => `Dieser Link läuft am ${date} ab`, + "link expired": "Dieser Upload-Link ist abgelaufen", + "link expired description": + "Bitten Sie die Person, die den Link mit Ihnen geteilt hat, einen neuen Link zu erstellen.", + "drop files": "Dateien hierher ziehen und ablegen", + "drop files active": "Dateien zum Hochladen ablegen", + "drop files hint": "Der Upload beginnt, sobald Sie die Dateien auswählen.", + "choose files": "Dateien auswählen", + "all files uploaded": "Ihre Dateien wurden gesendet", + "all files uploaded description": + "Sie können diese Seite schließen oder weitere Dateien hinzufügen, solange der Link gültig ist.", + "uploads title": "Ihre Uploads", + uploading: ({ percent }) => `Wird hochgeladen · ${percent} %`, + uploaded: "Hochgeladen", + "upload failed": "Upload fehlgeschlagen", + "cancel upload": "Upload abbrechen", + "retry upload": "Upload wiederholen", + "privacy note": "Nur die von Ihnen ausgewählten Dateien werden gesendet." }, S3ShareObjectDialogContainer: { "dialog title": "Objekt teilen" }, S3FileRequestCreationDialogContainer: { - "dialog title": undefined + "dialog title": "Dateien anfordern" }, S3SharePrefixDialogContainer: { "dialog title": "Ordner teilen" @@ -276,7 +279,7 @@ export const translations: Translations<"de"> = { "new s3 profile": "Neues S3-Profil" }, S3SelectionActionBar: { - "request files": undefined, + "request files": "Dateien anfordern", download: "Herunterladen", delete: "Löschen", "copy s3 uri": "S3-URI kopieren", @@ -387,7 +390,7 @@ export const translations: Translations<"de"> = { "make private": "Privat machen" }, S3ExplorerMainView: { - "request files": undefined, + "request files": "Dateien anfordern", "create prefix dialog title": "Präfix erstellen", "create prefix dialog subtitle": "Erstelle ein neues Präfix im aktuellen S3-Speicherort.", @@ -468,22 +471,24 @@ export const translations: Translations<"de"> = { "selected duration": "die ausgewählte Dauer" }, S3FileRequestCreationDialog: { - description: undefined, - "link settings": undefined, - "link expires after": undefined, - "link validity aria label": undefined, - "maximum size per file": undefined, - "maximum file size aria label": undefined, - "upload link": undefined, - "generating upload link": undefined, - "copy upload link aria label": undefined, - "generation failed": undefined, - retry: undefined, - "security note": undefined, - "validity duration one hour": undefined, - "validity duration one day": undefined, - "validity duration one week": undefined, - "no limit": undefined + description: + "Teilen Sie diesen Link mit beliebigen Personen, auch mit Personen ohne Konto auf dieser Onyxia-Instanz, damit sie Dateien von ihrem Computer direkt in diesen Ordner hochladen können.", + "link settings": "Linkeinstellungen", + "link expires after": "Link läuft ab nach", + "link validity aria label": "Gültigkeitsdauer des Upload-Links", + "maximum size per file": "Maximale Größe pro Datei", + "maximum file size aria label": "Maximale Größe pro hochgeladener Datei", + "upload link": "Upload-Link", + "generating upload link": "Upload-Link wird generiert...", + "copy upload link aria label": "Upload-Link kopieren", + "generation failed": "Der Upload-Link konnte nicht generiert werden.", + retry: "Erneut versuchen", + "security note": + "Jede Person mit diesem Link kann bis zu dessen Ablauf Dateien in diesen Ordner hochladen. Der Link gewährt keinen Zugriff zum Anzeigen oder Herunterladen vorhandener Dateien.", + "validity duration one hour": "1 Stunde", + "validity duration one day": "1 Tag", + "validity duration one week": "1 Woche", + "no limit": "Keine Begrenzung" }, S3SharePrefixDialog: { "copy folder URL aria label": "Ordner-URL kopieren", diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index 308315f7a..73dbe2d93 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -209,30 +209,33 @@ export const translations: Translations<"es"> = { "download file": "Descargar archivo" }, S3FileRequest: { - "page title": undefined, - "page description": undefined, - "expires on": undefined, - "link expired": undefined, - "link expired description": undefined, - "drop files": undefined, - "drop files active": undefined, - "drop files hint": undefined, - "choose files": undefined, - "all files uploaded": undefined, - "all files uploaded description": undefined, - "uploads title": undefined, - uploading: undefined, - uploaded: undefined, - "upload failed": undefined, - "cancel upload": undefined, - "retry upload": undefined, - "privacy note": undefined + "page title": "Subir los archivos solicitados", + "page description": + "Alguien ha compartido este enlace seguro para que puedas enviar archivos directamente a su espacio de almacenamiento. No necesitas una cuenta de Onyxia.", + "expires on": ({ date }) => `Este enlace caduca el ${date}`, + "link expired": "Este enlace de subida ha caducado", + "link expired description": + "Pide a la persona que compartió el enlace que cree uno nuevo.", + "drop files": "Arrastra y suelta tus archivos aquí", + "drop files active": "Suelta los archivos para subirlos", + "drop files hint": "La subida comienza en cuanto seleccionas los archivos.", + "choose files": "Elegir archivos", + "all files uploaded": "Tus archivos se han enviado", + "all files uploaded description": + "Puedes cerrar esta página o añadir más archivos mientras el enlace sea válido.", + "uploads title": "Tus subidas", + uploading: ({ percent }) => `Subiendo · ${percent}%`, + uploaded: "Subido", + "upload failed": "Error al subir", + "cancel upload": "Cancelar subida", + "retry upload": "Reintentar subida", + "privacy note": "Solo se envían mediante este enlace los archivos que elijas." }, S3ShareObjectDialogContainer: { "dialog title": "Compartir objeto" }, S3FileRequestCreationDialogContainer: { - "dialog title": undefined + "dialog title": "Solicitar archivos" }, S3SharePrefixDialogContainer: { "dialog title": "Compartir carpeta" @@ -271,7 +274,7 @@ export const translations: Translations<"es"> = { "new s3 profile": "Nuevo perfil S3" }, S3SelectionActionBar: { - "request files": undefined, + "request files": "Solicitar archivos", download: "Descargar", delete: "Eliminar", "copy s3 uri": "Copiar URI S3", @@ -380,7 +383,7 @@ export const translations: Translations<"es"> = { "make private": "Hacer privado" }, S3ExplorerMainView: { - "request files": undefined, + "request files": "Solicitar archivos", "create prefix dialog title": "Crear prefijo", "create prefix dialog subtitle": "Crea un nuevo prefijo dentro de la ubicación S3 actual.", @@ -460,22 +463,24 @@ export const translations: Translations<"es"> = { "selected duration": "la duración seleccionada" }, S3FileRequestCreationDialog: { - description: undefined, - "link settings": undefined, - "link expires after": undefined, - "link validity aria label": undefined, - "maximum size per file": undefined, - "maximum file size aria label": undefined, - "upload link": undefined, - "generating upload link": undefined, - "copy upload link aria label": undefined, - "generation failed": undefined, - retry: undefined, - "security note": undefined, - "validity duration one hour": undefined, - "validity duration one day": undefined, - "validity duration one week": undefined, - "no limit": undefined + description: + "Comparte este enlace con cualquier persona, incluso con alguien sin cuenta en esta instancia de Onyxia, para que pueda subir archivos desde su ordenador directamente a esta carpeta.", + "link settings": "Configuración del enlace", + "link expires after": "El enlace caduca después de", + "link validity aria label": "Duración de validez del enlace de subida", + "maximum size per file": "Tamaño máximo por archivo", + "maximum file size aria label": "Tamaño máximo por archivo subido", + "upload link": "Enlace de subida", + "generating upload link": "Generando enlace de subida...", + "copy upload link aria label": "Copiar enlace de subida", + "generation failed": "No se ha podido generar el enlace de subida.", + retry: "Reintentar", + "security note": + "Cualquier persona que tenga este enlace puede subir archivos a esta carpeta hasta que caduque. El enlace no permite ver ni descargar los archivos existentes.", + "validity duration one hour": "1 hora", + "validity duration one day": "1 día", + "validity duration one week": "1 semana", + "no limit": "Sin límite" }, S3SharePrefixDialog: { "copy folder URL aria label": "Copiar URL de la carpeta", diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index cebc434ba..544bc4afc 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -206,30 +206,33 @@ export const translations: Translations<"fi"> = { "download file": "lataa tiedosto" }, S3FileRequest: { - "page title": undefined, - "page description": undefined, - "expires on": undefined, - "link expired": undefined, - "link expired description": undefined, - "drop files": undefined, - "drop files active": undefined, - "drop files hint": undefined, - "choose files": undefined, - "all files uploaded": undefined, - "all files uploaded description": undefined, - "uploads title": undefined, - uploading: undefined, - uploaded: undefined, - "upload failed": undefined, - "cancel upload": undefined, - "retry upload": undefined, - "privacy note": undefined + "page title": "Lataa pyydetyt tiedostot", + "page description": + "Joku jakoi tämän suojatun linkin, jotta voit lähettää tiedostoja suoraan hänen tallennustilaansa. Et tarvitse Onyxia-tiliä.", + "expires on": ({ date }) => `Tämä linkki vanhenee ${date}`, + "link expired": "Tämä lähetyslinkki on vanhentunut", + "link expired description": + "Pyydä linkin jakanutta henkilöä luomaan uusi linkki.", + "drop files": "Vedä ja pudota tiedostosi tähän", + "drop files active": "Pudota tiedostot ladataksesi ne", + "drop files hint": "Lataus alkaa heti, kun valitset tiedostot.", + "choose files": "Valitse tiedostot", + "all files uploaded": "Tiedostosi on lähetetty", + "all files uploaded description": + "Voit sulkea tämän sivun tai lisätä tiedostoja niin kauan kuin linkki on voimassa.", + "uploads title": "Lähetyksesi", + uploading: ({ percent }) => `Ladataan · ${percent} %`, + uploaded: "Ladattu", + "upload failed": "Lataus epäonnistui", + "cancel upload": "Peruuta lataus", + "retry upload": "Yritä latausta uudelleen", + "privacy note": "Vain valitsemasi tiedostot lähetetään tämän linkin kautta." }, S3ShareObjectDialogContainer: { "dialog title": "Jaa objekti" }, S3FileRequestCreationDialogContainer: { - "dialog title": undefined + "dialog title": "Pyydä tiedostoja" }, S3SharePrefixDialogContainer: { "dialog title": "Jaa kansio" @@ -268,7 +271,7 @@ export const translations: Translations<"fi"> = { "new s3 profile": "Uusi S3-profiili" }, S3SelectionActionBar: { - "request files": undefined, + "request files": "Pyydä tiedostoja", download: "Lataa", delete: "Poista", "copy s3 uri": "Kopioi S3-URI", @@ -373,7 +376,7 @@ export const translations: Translations<"fi"> = { "make private": "Tee yksityiseksi" }, S3ExplorerMainView: { - "request files": undefined, + "request files": "Pyydä tiedostoja", "create prefix dialog title": "Luo etuliite", "create prefix dialog subtitle": "Luo uusi etuliite nykyiseen S3-sijaintiin.", "prefix name field label": "Etuliitteen nimi", @@ -452,22 +455,24 @@ export const translations: Translations<"fi"> = { "selected duration": "valittu kesto" }, S3FileRequestCreationDialog: { - description: undefined, - "link settings": undefined, - "link expires after": undefined, - "link validity aria label": undefined, - "maximum size per file": undefined, - "maximum file size aria label": undefined, - "upload link": undefined, - "generating upload link": undefined, - "copy upload link aria label": undefined, - "generation failed": undefined, - retry: undefined, - "security note": undefined, - "validity duration one hour": undefined, - "validity duration one day": undefined, - "validity duration one week": undefined, - "no limit": undefined + description: + "Jaa tämä linkki kenelle tahansa, myös henkilölle, jolla ei ole tiliä tässä Onyxia-instanssissa, jotta hän voi ladata tiedostoja tietokoneeltaan suoraan tähän kansioon.", + "link settings": "Linkin asetukset", + "link expires after": "Linkki vanhenee tämän ajan kuluttua", + "link validity aria label": "Lähetyslinkin voimassaoloaika", + "maximum size per file": "Tiedoston enimmäiskoko", + "maximum file size aria label": "Ladattavan tiedoston enimmäiskoko", + "upload link": "Lähetyslinkki", + "generating upload link": "Lähetyslinkkiä luodaan...", + "copy upload link aria label": "Kopioi lähetyslinkki", + "generation failed": "Lähetyslinkkiä ei voitu luoda.", + retry: "Yritä uudelleen", + "security note": + "Kuka tahansa linkin saanut voi ladata tiedostoja tähän kansioon linkin vanhenemiseen asti. Linkki ei anna oikeutta tarkastella tai ladata olemassa olevia tiedostoja.", + "validity duration one hour": "1 tunti", + "validity duration one day": "1 päivä", + "validity duration one week": "1 viikko", + "no limit": "Ei rajoitusta" }, S3SharePrefixDialog: { "copy folder URL aria label": "Kopioi kansion URL", diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index b7de94cae..dc422a7d6 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -209,30 +209,33 @@ export const translations: Translations<"it"> = { "download file": "scarica file" }, S3FileRequest: { - "page title": undefined, - "page description": undefined, - "expires on": undefined, - "link expired": undefined, - "link expired description": undefined, - "drop files": undefined, - "drop files active": undefined, - "drop files hint": undefined, - "choose files": undefined, - "all files uploaded": undefined, - "all files uploaded description": undefined, - "uploads title": undefined, - uploading: undefined, - uploaded: undefined, - "upload failed": undefined, - "cancel upload": undefined, - "retry upload": undefined, - "privacy note": undefined + "page title": "Carica i file richiesti", + "page description": + "Qualcuno ha condiviso questo link sicuro per consentirti di inviare file direttamente al proprio spazio di archiviazione. Non è necessario un account Onyxia.", + "expires on": ({ date }) => `Questo link scade il ${date}`, + "link expired": "Questo link di caricamento è scaduto", + "link expired description": + "Chiedi alla persona che ha condiviso il link di crearne uno nuovo.", + "drop files": "Trascina qui i tuoi file", + "drop files active": "Rilascia i file per caricarli", + "drop files hint": "Il caricamento inizia non appena selezioni i file.", + "choose files": "Scegli i file", + "all files uploaded": "I tuoi file sono stati inviati", + "all files uploaded description": + "Puoi chiudere questa pagina o aggiungere altri file finché il link è valido.", + "uploads title": "I tuoi caricamenti", + uploading: ({ percent }) => `Caricamento · ${percent}%`, + uploaded: "Caricato", + "upload failed": "Caricamento non riuscito", + "cancel upload": "Annulla caricamento", + "retry upload": "Riprova il caricamento", + "privacy note": "Tramite questo link vengono inviati solo i file scelti." }, S3ShareObjectDialogContainer: { "dialog title": "Condividi oggetto" }, S3FileRequestCreationDialogContainer: { - "dialog title": undefined + "dialog title": "Richiedi file" }, S3SharePrefixDialogContainer: { "dialog title": "Condividi cartella" @@ -271,7 +274,7 @@ export const translations: Translations<"it"> = { "new s3 profile": "Nuovo profilo S3" }, S3SelectionActionBar: { - "request files": undefined, + "request files": "Richiedi file", download: "Scarica", delete: "Elimina", "copy s3 uri": "Copia URI S3", @@ -378,7 +381,7 @@ export const translations: Translations<"it"> = { "make private": "Rendi privato" }, S3ExplorerMainView: { - "request files": undefined, + "request files": "Richiedi file", "create prefix dialog title": "Crea prefisso", "create prefix dialog subtitle": "Crea un nuovo prefisso nella posizione S3 corrente.", @@ -459,22 +462,24 @@ export const translations: Translations<"it"> = { "selected duration": "la durata selezionata" }, S3FileRequestCreationDialog: { - description: undefined, - "link settings": undefined, - "link expires after": undefined, - "link validity aria label": undefined, - "maximum size per file": undefined, - "maximum file size aria label": undefined, - "upload link": undefined, - "generating upload link": undefined, - "copy upload link aria label": undefined, - "generation failed": undefined, - retry: undefined, - "security note": undefined, - "validity duration one hour": undefined, - "validity duration one day": undefined, - "validity duration one week": undefined, - "no limit": undefined + description: + "Condividi questo link con chiunque, anche con chi non ha un account su questa istanza Onyxia, per consentire di caricare file dal proprio computer direttamente in questa cartella.", + "link settings": "Impostazioni del link", + "link expires after": "Il link scade dopo", + "link validity aria label": "Durata di validità del link di caricamento", + "maximum size per file": "Dimensione massima per file", + "maximum file size aria label": "Dimensione massima per file caricato", + "upload link": "Link di caricamento", + "generating upload link": "Generazione del link di caricamento...", + "copy upload link aria label": "Copia il link di caricamento", + "generation failed": "Non è stato possibile generare il link di caricamento.", + retry: "Riprova", + "security note": + "Chiunque disponga di questo link può caricare file in questa cartella fino alla scadenza. Il link non consente di visualizzare o scaricare i file esistenti.", + "validity duration one hour": "1 ora", + "validity duration one day": "1 giorno", + "validity duration one week": "1 settimana", + "no limit": "Nessun limite" }, S3SharePrefixDialog: { "copy folder URL aria label": "Copia URL della cartella", diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 179c28839..576205052 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -210,30 +210,34 @@ export const translations: Translations<"nl"> = { "download file": "bestand downloaden" }, S3FileRequest: { - "page title": undefined, - "page description": undefined, - "expires on": undefined, - "link expired": undefined, - "link expired description": undefined, - "drop files": undefined, - "drop files active": undefined, - "drop files hint": undefined, - "choose files": undefined, - "all files uploaded": undefined, - "all files uploaded description": undefined, - "uploads title": undefined, - uploading: undefined, - uploaded: undefined, - "upload failed": undefined, - "cancel upload": undefined, - "retry upload": undefined, - "privacy note": undefined + "page title": "Gevraagde bestanden uploaden", + "page description": + "Iemand heeft deze beveiligde link gedeeld, zodat je bestanden rechtstreeks naar diens opslagruimte kunt sturen. Je hebt geen Onyxia-account nodig.", + "expires on": ({ date }) => `Deze link verloopt op ${date}`, + "link expired": "Deze uploadlink is verlopen", + "link expired description": + "Vraag de persoon die de link met je heeft gedeeld om een nieuwe link te maken.", + "drop files": "Sleep je bestanden hierheen", + "drop files active": "Zet je bestanden neer om ze te uploaden", + "drop files hint": "Het uploaden begint zodra je de bestanden selecteert.", + "choose files": "Bestanden kiezen", + "all files uploaded": "Je bestanden zijn verzonden", + "all files uploaded description": + "Je kunt deze pagina sluiten of meer bestanden toevoegen zolang de link geldig is.", + "uploads title": "Je uploads", + uploading: ({ percent }) => `Uploaden · ${percent}%`, + uploaded: "Geüpload", + "upload failed": "Upload mislukt", + "cancel upload": "Upload annuleren", + "retry upload": "Upload opnieuw proberen", + "privacy note": + "Alleen de bestanden die je kiest, worden via deze link verzonden." }, S3ShareObjectDialogContainer: { "dialog title": "Object delen" }, S3FileRequestCreationDialogContainer: { - "dialog title": undefined + "dialog title": "Bestanden aanvragen" }, S3SharePrefixDialogContainer: { "dialog title": "Map delen" @@ -272,7 +276,7 @@ export const translations: Translations<"nl"> = { "new s3 profile": "Nieuw S3-profiel" }, S3SelectionActionBar: { - "request files": undefined, + "request files": "Bestanden aanvragen", download: "Downloaden", delete: "Verwijderen", "copy s3 uri": "S3-URI kopiëren", @@ -377,7 +381,7 @@ export const translations: Translations<"nl"> = { "make private": "Privé maken" }, S3ExplorerMainView: { - "request files": undefined, + "request files": "Bestanden aanvragen", "create prefix dialog title": "Prefix aanmaken", "create prefix dialog subtitle": "Maak een nieuwe prefix aan binnen de huidige S3-locatie.", @@ -457,22 +461,24 @@ export const translations: Translations<"nl"> = { "selected duration": "de geselecteerde duur" }, S3FileRequestCreationDialog: { - description: undefined, - "link settings": undefined, - "link expires after": undefined, - "link validity aria label": undefined, - "maximum size per file": undefined, - "maximum file size aria label": undefined, - "upload link": undefined, - "generating upload link": undefined, - "copy upload link aria label": undefined, - "generation failed": undefined, - retry: undefined, - "security note": undefined, - "validity duration one hour": undefined, - "validity duration one day": undefined, - "validity duration one week": undefined, - "no limit": undefined + description: + "Deel deze link met iedereen, ook met iemand zonder account op deze Onyxia-instantie, zodat diegene bestanden vanaf een computer rechtstreeks naar deze map kan uploaden.", + "link settings": "Linkinstellingen", + "link expires after": "Link verloopt na", + "link validity aria label": "Geldigheidsduur van de uploadlink", + "maximum size per file": "Maximale grootte per bestand", + "maximum file size aria label": "Maximale grootte per geüpload bestand", + "upload link": "Uploadlink", + "generating upload link": "Uploadlink genereren...", + "copy upload link aria label": "Uploadlink kopiëren", + "generation failed": "De uploadlink kon niet worden gegenereerd.", + retry: "Opnieuw proberen", + "security note": + "Iedereen met deze link kan bestanden naar deze map uploaden totdat de link verloopt. De link geeft geen toegang om bestaande bestanden te bekijken of te downloaden.", + "validity duration one hour": "1 uur", + "validity duration one day": "1 dag", + "validity duration one week": "1 week", + "no limit": "Geen limiet" }, S3SharePrefixDialog: { "copy folder URL aria label": "Map-URL kopiëren", diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index a508f255c..68d84e86f 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -206,30 +206,33 @@ export const translations: Translations<"no"> = { "download file": "last ned fil" }, S3FileRequest: { - "page title": undefined, - "page description": undefined, - "expires on": undefined, - "link expired": undefined, - "link expired description": undefined, - "drop files": undefined, - "drop files active": undefined, - "drop files hint": undefined, - "choose files": undefined, - "all files uploaded": undefined, - "all files uploaded description": undefined, - "uploads title": undefined, - uploading: undefined, - uploaded: undefined, - "upload failed": undefined, - "cancel upload": undefined, - "retry upload": undefined, - "privacy note": undefined + "page title": "Last opp forespurte filer", + "page description": + "Noen har delt denne sikre lenken slik at du kan sende filer direkte til lagringsområdet deres. Du trenger ikke en Onyxia-konto.", + "expires on": ({ date }) => `Denne lenken utløper ${date}`, + "link expired": "Denne opplastingslenken har utløpt", + "link expired description": + "Be personen som delte lenken med deg, om å opprette en ny lenke.", + "drop files": "Dra og slipp filene dine her", + "drop files active": "Slipp filene for å laste dem opp", + "drop files hint": "Opplastingen starter så snart du velger filene.", + "choose files": "Velg filer", + "all files uploaded": "Filene dine er sendt", + "all files uploaded description": + "Du kan lukke denne siden eller legge til flere filer så lenge lenken er gyldig.", + "uploads title": "Opplastingene dine", + uploading: ({ percent }) => `Laster opp · ${percent} %`, + uploaded: "Lastet opp", + "upload failed": "Opplastingen mislyktes", + "cancel upload": "Avbryt opplasting", + "retry upload": "Prøv opplastingen på nytt", + "privacy note": "Bare filene du velger, sendes via denne lenken." }, S3ShareObjectDialogContainer: { "dialog title": "Del objekt" }, S3FileRequestCreationDialogContainer: { - "dialog title": undefined + "dialog title": "Be om filer" }, S3SharePrefixDialogContainer: { "dialog title": "Del mappe" @@ -268,7 +271,7 @@ export const translations: Translations<"no"> = { "new s3 profile": "Ny S3-profil" }, S3SelectionActionBar: { - "request files": undefined, + "request files": "Be om filer", download: "Last ned", delete: "Slett", "copy s3 uri": "Kopier S3-URI", @@ -374,7 +377,7 @@ export const translations: Translations<"no"> = { "make private": "Gjør privat" }, S3ExplorerMainView: { - "request files": undefined, + "request files": "Be om filer", "create prefix dialog title": "Opprett prefiks", "create prefix dialog subtitle": "Opprett et nytt prefiks i gjeldende S3-plassering.", @@ -455,22 +458,24 @@ export const translations: Translations<"no"> = { "selected duration": "den valgte varigheten" }, S3FileRequestCreationDialog: { - description: undefined, - "link settings": undefined, - "link expires after": undefined, - "link validity aria label": undefined, - "maximum size per file": undefined, - "maximum file size aria label": undefined, - "upload link": undefined, - "generating upload link": undefined, - "copy upload link aria label": undefined, - "generation failed": undefined, - retry: undefined, - "security note": undefined, - "validity duration one hour": undefined, - "validity duration one day": undefined, - "validity duration one week": undefined, - "no limit": undefined + description: + "Del denne lenken med hvem som helst, også personer uten konto på denne Onyxia-instansen, slik at de kan laste opp filer fra datamaskinen sin direkte til denne mappen.", + "link settings": "Lenkeinnstillinger", + "link expires after": "Lenken utløper etter", + "link validity aria label": "Opplastingslenkens gyldighet", + "maximum size per file": "Maksimal størrelse per fil", + "maximum file size aria label": "Maksimal størrelse per opplastet fil", + "upload link": "Opplastingslenke", + "generating upload link": "Genererer opplastingslenke...", + "copy upload link aria label": "Kopier opplastingslenke", + "generation failed": "Opplastingslenken kunne ikke genereres.", + retry: "Prøv igjen", + "security note": + "Alle med denne lenken kan laste opp filer til mappen frem til lenken utløper. Lenken gir ikke tilgang til å vise eller laste ned eksisterende filer.", + "validity duration one hour": "1 time", + "validity duration one day": "1 dag", + "validity duration one week": "1 uke", + "no limit": "Ingen grense" }, S3SharePrefixDialog: { "copy folder URL aria label": "Kopier mappe-URL", diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index ede095727..b9df6b7ea 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -187,30 +187,32 @@ export const translations: Translations<"zh-CN"> = { "download file": "下载文件" }, S3FileRequest: { - "page title": undefined, - "page description": undefined, - "expires on": undefined, - "link expired": undefined, - "link expired description": undefined, - "drop files": undefined, - "drop files active": undefined, - "drop files hint": undefined, - "choose files": undefined, - "all files uploaded": undefined, - "all files uploaded description": undefined, - "uploads title": undefined, - uploading: undefined, - uploaded: undefined, - "upload failed": undefined, - "cancel upload": undefined, - "retry upload": undefined, - "privacy note": undefined + "page title": "上传对方请求的文件", + "page description": + "有人分享了这个安全链接,以便你将文件直接发送到对方的存储空间。无需 Onyxia 帐户。", + "expires on": ({ date }) => `此链接将于 ${date} 过期`, + "link expired": "此上传链接已过期", + "link expired description": "请让链接分享者创建一个新链接。", + "drop files": "将文件拖放到此处", + "drop files active": "松开文件即可上传", + "drop files hint": "选择文件后会立即开始上传。", + "choose files": "选择文件", + "all files uploaded": "文件已发送", + "all files uploaded description": + "只要链接仍然有效,你就可以关闭此页面或继续添加文件。", + "uploads title": "你的上传任务", + uploading: ({ percent }) => `正在上传 · ${percent}%`, + uploaded: "已上传", + "upload failed": "上传失败", + "cancel upload": "取消上传", + "retry upload": "重试上传", + "privacy note": "只有你选择的文件会通过此链接发送。" }, S3ShareObjectDialogContainer: { "dialog title": "共享对象" }, S3FileRequestCreationDialogContainer: { - "dialog title": undefined + "dialog title": "请求文件" }, S3SharePrefixDialogContainer: { "dialog title": "共享文件夹" @@ -249,7 +251,7 @@ export const translations: Translations<"zh-CN"> = { "new s3 profile": "新建 S3 配置文件" }, S3SelectionActionBar: { - "request files": undefined, + "request files": "请求文件", download: "下载", delete: "删除", "copy s3 uri": "复制 S3 URI", @@ -352,7 +354,7 @@ export const translations: Translations<"zh-CN"> = { "make private": "设为私有" }, S3ExplorerMainView: { - "request files": undefined, + "request files": "请求文件", "create prefix dialog title": "创建前缀", "create prefix dialog subtitle": "在当前 S3 位置内创建一个新前缀。", "prefix name field label": "前缀名称", @@ -426,22 +428,24 @@ export const translations: Translations<"zh-CN"> = { "selected duration": "所选时长" }, S3FileRequestCreationDialog: { - description: undefined, - "link settings": undefined, - "link expires after": undefined, - "link validity aria label": undefined, - "maximum size per file": undefined, - "maximum file size aria label": undefined, - "upload link": undefined, - "generating upload link": undefined, - "copy upload link aria label": undefined, - "generation failed": undefined, - retry: undefined, - "security note": undefined, - "validity duration one hour": undefined, - "validity duration one day": undefined, - "validity duration one week": undefined, - "no limit": undefined + description: + "将此链接分享给任何人,即使对方没有此 Onyxia 实例的帐户,也可以从计算机将文件直接上传到此文件夹。", + "link settings": "链接设置", + "link expires after": "链接有效期", + "link validity aria label": "上传链接的有效期", + "maximum size per file": "每个文件的最大大小", + "maximum file size aria label": "每个上传文件的最大大小", + "upload link": "上传链接", + "generating upload link": "正在生成上传链接...", + "copy upload link aria label": "复制上传链接", + "generation failed": "无法生成上传链接。", + retry: "重试", + "security note": + "在链接过期之前,任何拥有此链接的人都可以将文件上传到此文件夹。此链接不能用于查看或下载现有文件。", + "validity duration one hour": "1 小时", + "validity duration one day": "1 天", + "validity duration one week": "1 周", + "no limit": "无限制" }, S3SharePrefixDialog: { "copy folder URL aria label": "复制文件夹 URL", From 9890f8d3d6e3e051147963b3b837901a43a9dcbb Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 28 Aug 2026 17:22:42 +0000 Subject: [PATCH 05/22] Release candidate --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index f0a4ccf41..ac027b894 100644 --- a/web/package.json +++ b/web/package.json @@ -2,7 +2,7 @@ "name": "onyxia-web", "homepage": "https://onyxia.sh", "type": "module", - "version": "5.7.2", + "version": "5.8.0-rc.1", "license": "MIT", "scripts": { "postinstall": "yarn install-git-hooks && yarn postinstall:code-gen", From 1f8f0bc2c08b41d0a04772c36ae390842930d0a4 Mon Sep 17 00:00:00 2001 From: actions Date: Fri, 28 Aug 2026 17:27:18 +0000 Subject: [PATCH 06/22] Automatic minor bump of chart version to 11.8.0-rc.1 --- helm-chart/Chart.yaml | 2 +- helm-chart/README.md | 10 +++++----- helm-chart/values.yaml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml index bdb31c8b0..dec7dae43 100644 --- a/helm-chart/Chart.yaml +++ b/helm-chart/Chart.yaml @@ -14,4 +14,4 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. -version: 11.7.2 +version: 11.8.0-rc.1 diff --git a/helm-chart/README.md b/helm-chart/README.md index 4e6bf4dda..b5824a3d5 100644 --- a/helm-chart/README.md +++ b/helm-chart/README.md @@ -22,7 +22,7 @@ ingress: - host: datalab.my-domain.net EOF -helm install onyxia onyxia/onyxia --version "11.7.2" -f onyxia-values.yaml +helm install onyxia onyxia/onyxia --version "11.8.0-rc.1" -f onyxia-values.yaml ``` To expose Onyxia with the Kubernetes Gateway API instead of an Ingress, use an `HTTPRoute`. @@ -40,7 +40,7 @@ httpRoute: - datalab.my-domain.net EOF -helm install onyxia onyxia/onyxia --version "11.7.2" -f onyxia-values.yaml +helm install onyxia onyxia/onyxia --version "11.8.0-rc.1" -f onyxia-values.yaml ``` ### Using the Keycloak Theme (Optional) @@ -62,7 +62,7 @@ extraInitContainers: | args: - -c - | - curl -L -f -S -o /extensions/onyxia.jar https://github.com/InseeFrLab/onyxia/releases/download/v11.7.2/keycloak-theme.jar + curl -L -f -S -o /extensions/onyxia.jar https://github.com/InseeFrLab/onyxia/releases/download/v11.8.0-rc.1/keycloak-theme.jar volumeMounts: - name: extensions mountPath: /extensions @@ -97,7 +97,7 @@ api: ``` - [The REST API (`api`)](https://github.com/InseeFrLab/onyxia-api/blob/v4.12.0/README.md#configuration) -- [The Web Application (`web`)](https://github.com/InseeFrLab/onyxia/blob/web-v5.7.2/web/.env) +- [The Web Application (`web`)](https://github.com/InseeFrLab/onyxia/blob/web-v5.8.0-rc.1/web/.env) Below is a sample `onyxia-values.yaml` file that illustrates where to specify the `api` and `web` configuration parameters. @@ -150,4 +150,4 @@ httpRoute: If you are building your own service catalog for Onyxia ([learn how](https://docs.onyxia.sh/catalog-of-services)). Here are defined the onyxia reserved parameter and the structure of the dynamic context: -[`values.schema.json` `"x-onyxia"` specifications](https://github.com/InseeFrLab/onyxia/blob/web-v5.7.2/web/src/core/ports/OnyxiaApi/XOnyxia.ts) +[`values.schema.json` `"x-onyxia"` specifications](https://github.com/InseeFrLab/onyxia/blob/web-v5.8.0-rc.1/web/src/core/ports/OnyxiaApi/XOnyxia.ts) diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index 2f37411d8..c65aba138 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -43,7 +43,7 @@ web: replicaCount: 1 image: repository: inseefrlab/onyxia-web - tag: 5.7.2 + tag: 5.8.0-rc.1 pullPolicy: IfNotPresent imagePullSecrets: [] From 659b16ff988c0d456e90602db4066f4c6336aa61 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Fri, 28 Aug 2026 19:48:12 +0000 Subject: [PATCH 07/22] Refactor s3 ui items exposed by the core --- .../computeUploadStatusAtPrefix.ts | 4 +- .../s3ExplorerUiController/selectors.ts | 68 ++++++------- web/src/ui/pages/s3Explorer/Page.tsx | 1 + .../S3ExplorerMainView.spec.md | 46 ++++----- .../S3ExplorerMainView.stories.tsx | 48 ++++----- .../S3ExplorerMainView/S3ExplorerMainView.tsx | 99 +++++++------------ 6 files changed, 118 insertions(+), 148 deletions(-) diff --git a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts index b4f599aaa..cb4651133 100644 --- a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts +++ b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts @@ -72,8 +72,8 @@ export function computeUploadStatusAtPrefix(params: { displayName, s3Uri: s3Uri_newItem, isDeleting: false, - policy: { isPublic: false, canBeMadePublic: false }, - profileNameForSharing: undefined, + publicAccessAction: undefined, + shouldShowShareAction: false, uploadProgressPercent: NaN }); } diff --git a/web/src/core/usecases/s3ExplorerUiController/selectors.ts b/web/src/core/usecases/s3ExplorerUiController/selectors.ts index 205cbe8d7..b39440eff 100644 --- a/web/src/core/usecases/s3ExplorerUiController/selectors.ts +++ b/web/src/core/usecases/s3ExplorerUiController/selectors.ts @@ -98,6 +98,8 @@ export type MainView = { | undefined; commandLogsEntries: State.CommandLogsEntry[]; + + profileNameForSharing: string | undefined; }; export namespace MainView { @@ -113,8 +115,8 @@ export namespace MainView { export type PrefixSegment = Common & { type: "prefix segment"; s3Uri: S3Uri.TerminatedByDelimiter; - policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; - profileNameForSharing: string | undefined; + publicAccessAction: "make public" | "make private" | undefined; + shouldShowShareAction: boolean; }; export type Object = Common & { @@ -366,25 +368,16 @@ const items = createSelector( size: item.size }); case "prefix": { - const policy: MainView.Item.PrefixSegment["policy"] = - isAnonymousS3Profile - ? // NOTE: Semantically false but yield the intended result. - { isPublic: false, canBeMadePublic: false } - : getHasPrefixBeMadePublic({ - s3Uri: item.s3Uri, - bucketPoliciesByBucket - }) - ? { - isPublic: true - } - : { - isPublic: false, - canBeMadePublic: - !getIsWithinPrefixThatHasBeenMadePublic({ - s3Uri: item.s3Uri, - bucketPoliciesByBucket - }).isWithinPrefixThatHasBeenMadePublic - }; + const hasBeenMadePublic = getHasPrefixBeMadePublic({ + s3Uri: item.s3Uri, + bucketPoliciesByBucket + }); + + const isWithinPrefixThatHasBeenMadePublic = + getIsWithinPrefixThatHasBeenMadePublic({ + s3Uri: item.s3Uri, + bucketPoliciesByBucket + }).isWithinPrefixThatHasBeenMadePublic; return id({ type: "prefix segment", @@ -398,30 +391,26 @@ const items = createSelector( s3Uri: item.s3Uri, uploadProgressPercent: undefined, isDeleting: false, - profileNameForSharing: (() => { - if (profileName_anonymous === undefined) { - return undefined; - } - + publicAccessAction: (() => { if (isAnonymousS3Profile) { - return profileName_anonymous; + return undefined; } - if (policy.isPublic) { - return profileName_anonymous; + if (hasBeenMadePublic) { + return "make private"; } - // NOTE: Semantically, this is wrong, it's sharable - // if it's within a prefix that has been made public - // but since we already compute that for canBeMadePublic - // we reuse the value here. - if (policy.canBeMadePublic) { + if (isWithinPrefixThatHasBeenMadePublic) { return undefined; } - return profileName_anonymous; + return "make public"; })(), - policy + shouldShowShareAction: + profileName_anonymous !== undefined && + (isAnonymousS3Profile || + hasBeenMadePublic || + isWithinPrefixThatHasBeenMadePublic) }); } default: @@ -795,6 +784,7 @@ const mainView = createSelector( isListing, listedPrefix, commandLogsEntries, + profileName_anonymous, ( profileSelect, bookmarks, @@ -806,7 +796,8 @@ const mainView = createSelector( objectRendering, isListing, listedPrefix, - commandLogsEntries + commandLogsEntries, + profileNameForSharing ): MainView => ({ profileSelect, bookmarks, @@ -818,7 +809,8 @@ const mainView = createSelector( objectRendering, isListing, listedPrefix, - commandLogsEntries + commandLogsEntries, + profileNameForSharing }) ); diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 18b57c6f5..1b0066a36 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -618,6 +618,7 @@ function S3Explorer() { })} isListing={mainView.isListing} listedPrefix={mainView.listedPrefix} + profileNameForSharing={mainView.profileNameForSharing} onNavigateBack={s3ExplorerUiController.navigateBack} onNavigate={({ s3Uri }) => s3ExplorerUiController.listPrefix({ diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md index daa33c20f..8341c5d74 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md @@ -45,6 +45,8 @@ export type S3ExplorerMainViewProps = { } ); + profileNameForSharing: string | undefined; + onNavigate: (params: { s3Uri: S3Uri }) => void; onNavigateBack: () => void; @@ -99,8 +101,8 @@ export namespace S3ExplorerMainViewProps { export type PrefixSegment = Common & { type: "prefix segment"; s3Uri: S3Uri.TerminatedByDelimiter; - policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; - profileNameForSharing: string | undefined; + publicAccessAction: "make public" | "make private" | undefined; + shouldShowShareAction: boolean; }; export type Object = Common & { @@ -223,10 +225,11 @@ for the current selection: - download is available when every selected item is not deleting and does not have an unfinished upload progress state - share is available for one selected object or one prefix whose - `profileNameForSharing` is defined -- make public is available only for one selected private prefix whose - `policy.canBeMadePublic === true` -- make private is available only for one selected public prefix + `shouldShowShareAction` is `true` +- make public is available when the selected prefix has + `publicAccessAction === "make public"` +- make private is available when the selected prefix has + `publicAccessAction === "make private"` - copy S3 path is available only for one selected item - delete is available when at least one item is selected @@ -272,23 +275,19 @@ Typical row actions include: - Actions remain secondary compared to the bulk action bar - Actions are hidden when not relevant for the row type -### Prefix policy - -Prefix public state is read only from the prefix item `policy`. +### Public access action Rules: -- Public prefix: `policy.isPublic === true` -- Private prefix: `policy.isPublic === false` -- Object items do not expose public state in this component -- Public prefixes display a `Public` tag next to the prefix name -- Private prefixes do not display a public tag -- Public prefixes expose a `make private` contextual action with the - `PublicOff` icon -- Private prefixes expose a `make public` contextual action with the `Public` - icon only when `policy.canBeMadePublic === true` -- Private prefixes with `policy.canBeMadePublic === false` do not expose a - policy contextual action +- `publicAccessAction === "make public"` displays the `make public` action with + the `Public` icon. +- `publicAccessAction === "make private"` displays the `make private` action with + the `PublicOff` icon. +- `publicAccessAction === "make private"` also displays the `Public` marker next + to the prefix name and in prefix summaries. +- `publicAccessAction === undefined` does not display a public access action. +- The component does not derive the effective public status of a prefix beyond + these direct display instructions. Clicking `make public` triggers: @@ -311,9 +310,12 @@ onChangePrefixPolicy({ ### Share Share is available as a row action for object rows and prefix rows whose -`profileNameForSharing` is defined, provided that the item is not deleting and does +`shouldShowShareAction` is `true`, provided that the item is not deleting and does not have an unfinished upload progress state. +When a prefix has `shouldShowShareAction === true`, the component asserts that the +root-level `profileNameForSharing` is defined before invoking `onSharePrefix`. + Clicking Share triggers: ```ts @@ -321,7 +323,7 @@ onShareObject({ s3Uri: item.s3Uri }); onSharePrefix({ s3Uri: item.s3Uri, - anonymousProfileName: item.profileNameForSharing + anonymousProfileName: profileNameForSharing }); ``` diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx index 984e1f984..c8c91c47e 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx @@ -20,8 +20,8 @@ type MockNode = s3Uri: S3Uri.TerminatedByDelimiter; uploadProgressPercent: number | undefined; isDeleting: boolean; - policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; - profileNameForSharing: string | undefined; + publicAccessAction: "make public" | "make private" | undefined; + shouldShowShareAction: boolean; } | { type: "object"; @@ -75,24 +75,24 @@ const baseNodes: MockNode[] = [ s3Uri: parsePrefixOrThrow("s3://analytics-data/exports/"), uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: true }, - profileNameForSharing: "anonymous" + publicAccessAction: "make private", + shouldShowShareAction: true }, { type: "prefix segment", s3Uri: parsePrefixOrThrow("s3://analytics-data/raw/"), uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: false, canBeMadePublic: true }, - profileNameForSharing: undefined + publicAccessAction: "make public", + shouldShowShareAction: false }, { type: "prefix segment", s3Uri: parsePrefixOrThrow("s3://analytics-data/tmp/"), uploadProgressPercent: 42, isDeleting: false, - policy: { isPublic: false, canBeMadePublic: false }, - profileNameForSharing: undefined + publicAccessAction: undefined, + shouldShowShareAction: false }, { type: "object", @@ -126,16 +126,16 @@ const nestedNodes: MockNode[] = [ s3Uri: parsePrefixOrThrow("s3://analytics-data/exports/2024/"), uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: false, canBeMadePublic: true }, - profileNameForSharing: undefined + publicAccessAction: "make public", + shouldShowShareAction: false }, { type: "prefix segment", s3Uri: parsePrefixOrThrow("s3://analytics-data/exports/2025/"), uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: true }, - profileNameForSharing: "anonymous" + publicAccessAction: "make private", + shouldShowShareAction: true }, { type: "object", @@ -157,6 +157,7 @@ const nestedNodes: MockNode[] = [ const placeholderArgs: S3ExplorerMainViewProps = { isListing: false, + profileNameForSharing: "anonymous", listedPrefix: { s3Uri: defaultPrefix, isErrored: false, @@ -293,8 +294,8 @@ function StatefulExplorer( }, uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: false, canBeMadePublic: true }, - profileNameForSharing: undefined + publicAccessAction: "make public", + shouldShowShareAction: false } ]); }} @@ -375,13 +376,11 @@ function StatefulExplorer( return { ...node, - policy: + publicAccessAction: policyAction === "make public" - ? { isPublic: true } - : { - isPublic: false, - canBeMadePublic: true - } + ? "make private" + : "make public", + shouldShowShareAction: policyAction === "make public" }; }) ); @@ -397,11 +396,12 @@ function StatefulExplorer( export const Playground: Story = { args: placeholderArgs, - render: ({ className, isListing, isUploadDisabled }) => ( + render: ({ className, isListing, isUploadDisabled, profileNameForSharing }) => ( ) }; @@ -411,11 +411,12 @@ export const ListingInProgress: Story = { ...placeholderArgs, isListing: true }, - render: ({ className, isListing, isUploadDisabled }) => ( + render: ({ className, isListing, isUploadDisabled, profileNameForSharing }) => ( ) }; @@ -423,6 +424,7 @@ export const ListingInProgress: Story = { export const EmptyPrefix: Story = { args: { isListing: false, + profileNameForSharing: "anonymous", listedPrefix: { s3Uri: defaultPrefix, isErrored: false, @@ -502,6 +504,7 @@ function FullyQualifiedObjectExplorer(props: S3ExplorerMainViewProps) { export const FullyQualifiedObject: Story = { args: { isListing: false, + profileNameForSharing: "anonymous", listedPrefix: { s3Uri: fullyQualifiedObject.s3Uri, isErrored: false, @@ -530,6 +533,7 @@ export const FullyQualifiedObject: Story = { export const AccessDenied: Story = { args: { isListing: false, + profileNameForSharing: "anonymous", listedPrefix: { s3Uri: defaultPrefix, isErrored: true, diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx index 409cc7443..799646ba3 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx @@ -62,6 +62,8 @@ export type S3ExplorerMainViewProps = { } ); + profileNameForSharing: string | undefined; + onNavigate: (params: { s3Uri: S3Uri }) => void; onNavigateBack: () => void; @@ -118,8 +120,8 @@ export namespace S3ExplorerMainViewProps { export type PrefixSegment = Common & { type: "prefix segment"; s3Uri: S3Uri.TerminatedByDelimiter; - policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; - profileNameForSharing: string | undefined; + publicAccessAction: "make public" | "make private" | undefined; + shouldShowShareAction: boolean; }; export type Object = Common & { @@ -139,6 +141,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { className, isListing, listedPrefix, + profileNameForSharing, onNavigate, onNavigateBack, onPutObjects, @@ -387,10 +390,10 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { selectedItemForSingleItemAction?.type === "prefix segment" ? selectedItemForSingleItemAction : undefined; - const selectedPrefixPolicyAction = + const selectedPrefixPublicAccessAction = selectedPrefixForSingleItemAction !== undefined && getIsItemActionAvailable(selectedPrefixForSingleItemAction) - ? getPrefixPolicyAction(selectedPrefixForSingleItemAction) + ? selectedPrefixForSingleItemAction.publicAccessAction : undefined; const setSelectionToSingleItem = useConstCallback((itemKey: string) => { @@ -544,13 +547,15 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { }); return; case "prefix segment": - if (item.profileNameForSharing === undefined) { + if (!item.shouldShowShareAction) { return; } + assert(profileNameForSharing !== undefined); + onSharePrefix({ s3Uri: item.s3Uri, - anonymousProfileName: item.profileNameForSharing + anonymousProfileName: profileNameForSharing }); return; } @@ -562,14 +567,17 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { return; } - const action = getPrefixPolicyAction(item); + const { publicAccessAction } = item; - if (action === undefined) { + if (publicAccessAction === undefined) { return; } onChangePrefixPolicy({ - action, + action: + publicAccessAction === "make private" + ? "undo make public" + : "make public", s3Uri: item.s3Uri }); } @@ -846,8 +854,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { ) || (selectedItemForSingleItemAction.type === "prefix segment" && - selectedItemForSingleItemAction.profileNameForSharing === - undefined) + !selectedItemForSingleItemAction.shouldShowShareAction) ? undefined : { callback: () => @@ -871,7 +878,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { } accessPolicy={ selectedPrefixForSingleItemAction === undefined || - selectedPrefixPolicyAction === undefined + selectedPrefixPublicAccessAction === undefined ? undefined : { callback: () => @@ -879,8 +886,8 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { selectedPrefixForSingleItemAction ), isPublic: - selectedPrefixPolicyAction === - "undo make public" + selectedPrefixPublicAccessAction === + "make private" } } /> @@ -1187,8 +1194,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { onDelete={onDeleteFactory(itemKey)} onShare={ item.type === "object" || - item.profileNameForSharing !== - undefined + item.shouldShowShareAction ? onShareFactory(itemKey) : undefined } @@ -1201,7 +1207,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { } onChangePrefixPolicy={ item.type === "prefix segment" && - getPrefixPolicyAction(item) !== + item.publicAccessAction !== undefined ? onChangePrefixPolicyFactory( itemKey @@ -1910,9 +1916,9 @@ export type DeleteDialogState = { items: S3ExplorerMainViewProps.Item[]; }; -type PrefixPolicyAction = Parameters< - S3ExplorerMainViewProps["onChangePrefixPolicy"] ->[0]["action"]; +type PublicAccessAction = NonNullable< + S3ExplorerMainViewProps.Item.PrefixSegment["publicAccessAction"] +>; type ObjectToUpload = Parameters< S3ExplorerMainViewProps["onPutObjects"] @@ -2157,33 +2163,15 @@ function getIsItemActionAvailable(item: S3ExplorerMainViewProps.Item): boolean { return getProgressPercent(item) === undefined; } -function getPrefixPolicyAction( - item: S3ExplorerMainViewProps.Item -): PrefixPolicyAction | undefined { - if (item.type !== "prefix segment") { - return undefined; - } - - if (item.policy.isPublic) { - return "undo make public"; - } - - if (item.policy.canBeMadePublic) { - return "make public"; - } - - return undefined; -} - function getPrefixPolicyActionLabel( - action: PrefixPolicyAction, + action: PublicAccessAction, t: ReturnType["t"] ): string { return action === "make public" ? t("make public") : t("make private"); } function getPrefixPolicyActionIconName( - action: PrefixPolicyAction + action: PublicAccessAction ): "Public" | "PublicOff" { return action === "make public" ? "Public" : "PublicOff"; } @@ -2377,7 +2365,7 @@ export function DeleteSelectionDialog(props: { } isPublic={ item.type === "prefix segment" && - item.policy.isPublic + item.publicAccessAction === "make private" } /> ))} @@ -2527,7 +2515,8 @@ const ItemRow = memo(function ItemRow(props: ItemRowProps) { const isDownloadAvailable = onDownload !== undefined && isItemActionAvailable; const isShareAvailable = onShare !== undefined && isItemActionAvailable; const isRequestFilesAvailable = onRequestFiles !== undefined && isItemActionAvailable; - const prefixPolicyAction = getPrefixPolicyAction(item); + const prefixPolicyAction = + item.type === "prefix segment" ? item.publicAccessAction : undefined; const isPrefixPolicyActionAvailable = onChangePrefixPolicy !== undefined && isItemActionAvailable; const isCopyAvailable = !item.isDeleting; @@ -2538,7 +2527,7 @@ const ItemRow = memo(function ItemRow(props: ItemRowProps) { item.type === "prefix segment" ? t("folder") : t("object"); const itemIconLabel = item.type === "prefix segment" - ? item.policy.isPublic + ? item.publicAccessAction === "make private" ? t("folder is public") : t("folder is private") : itemKindLabelCapitalized; @@ -2656,7 +2645,7 @@ const ItemRow = memo(function ItemRow(props: ItemRowProps) { {item.type === "prefix segment" && - item.policy.isPublic && ( + item.publicAccessAction === "make private" && ( Date: Fri, 28 Aug 2026 20:14:52 +0000 Subject: [PATCH 08/22] Refactor s3 selector --- .../decoupledLogic/bucketPolicies.ts | 2 +- .../decoupledLogic/stateItemToSelectorItem.ts | 92 +++++++++++++++++++ .../s3ExplorerUiController/selectors.ts | 90 +++--------------- 3 files changed, 107 insertions(+), 77 deletions(-) create mode 100644 web/src/core/usecases/s3ExplorerUiController/decoupledLogic/stateItemToSelectorItem.ts diff --git a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicies.ts b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicies.ts index 23f9f8b7f..0df1a04ef 100644 --- a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicies.ts +++ b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicies.ts @@ -8,7 +8,7 @@ export type BucketPolicies = Record; assert>; -type BucketPoliciesByBucket = Record< +export type BucketPoliciesByBucket = Record< string, { bucketPolicies: BucketPolicies | undefined } | undefined >; diff --git a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/stateItemToSelectorItem.ts b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/stateItemToSelectorItem.ts new file mode 100644 index 000000000..f2f28d5f0 --- /dev/null +++ b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/stateItemToSelectorItem.ts @@ -0,0 +1,92 @@ +import type { State } from "../state"; +import { + type BucketPoliciesByBucket, + getHasPrefixBeMadePublic, + getIsWithinPrefixThatHasBeenMadePublic +} from "./bucketPolicies"; +import type { MainView } from "../selectors"; +import { assert, id, type Equals } from "tsafe"; +import memoize from "memoizee"; + +export const stateItemToSelectorItem = (params: { + item: State.ListedPrefix.Item; + isSharingPublicFolderFeatureEnabled: boolean; + bucketPoliciesByBucket: BucketPoliciesByBucket; + isAnonymousS3Profile: boolean; +}): MainView.Item => { + const { + item, + isSharingPublicFolderFeatureEnabled, + bucketPoliciesByBucket, + isAnonymousS3Profile + } = params; + + switch (item.type) { + case "object": + return id({ + type: "object", + displayName: (() => { + const keyBasename = item.s3Uri.keySegments.at(-1); + + assert(keyBasename !== undefined); + + return keyBasename; + })(), + s3Uri: item.s3Uri, + uploadProgressPercent: undefined, + isDeleting: false, + lastModified: item.lastModified, + size: item.size + }); + case "prefix": { + const hasBeenMadePublic = getHasPrefixBeMadePublic({ + s3Uri: item.s3Uri, + bucketPoliciesByBucket + }); + + const getIsWithinPrefixThatHasBeenMadePublic_local = memoize( + () => + getIsWithinPrefixThatHasBeenMadePublic({ + s3Uri: item.s3Uri, + bucketPoliciesByBucket + }).isWithinPrefixThatHasBeenMadePublic + ); + + return id({ + type: "prefix segment", + displayName: (() => { + const lastSegment = item.s3Uri.keySegments.at(-1); + + assert(lastSegment !== undefined); + + return lastSegment; + })(), + s3Uri: item.s3Uri, + uploadProgressPercent: undefined, + isDeleting: false, + publicAccessAction: (() => { + if (isAnonymousS3Profile) { + return undefined; + } + + if (hasBeenMadePublic) { + return "make private"; + } + + if (getIsWithinPrefixThatHasBeenMadePublic_local()) { + return undefined; + } + + return "make public"; + })(), + shouldShowShareAction: + isSharingPublicFolderFeatureEnabled && + (isAnonymousS3Profile || + hasBeenMadePublic || + getIsWithinPrefixThatHasBeenMadePublic_local()) + }); + } + default: + assert>(false); + } +}; diff --git a/web/src/core/usecases/s3ExplorerUiController/selectors.ts b/web/src/core/usecases/s3ExplorerUiController/selectors.ts index b39440eff..8146afed0 100644 --- a/web/src/core/usecases/s3ExplorerUiController/selectors.ts +++ b/web/src/core/usecases/s3ExplorerUiController/selectors.ts @@ -4,15 +4,12 @@ import type { LocalizedString } from "core/ports/OnyxiaApi"; import { type S3Uri, stringifyS3Uri, getIsInside } from "core/tools/S3Uri"; import type { State as RootState } from "core/bootstrap"; import { assert, type Equals } from "tsafe"; -import { id } from "tsafe/id"; import { same } from "evt/tools/inDepth/same"; import { computeUploadStatusAtPrefix } from "./decoupledLogic/computeUploadStatusAtPrefix"; import { name, type State } from "./state"; -import { - getHasPrefixBeMadePublic, - getIsWithinPrefixThatHasBeenMadePublic -} from "./decoupledLogic/bucketPolicies"; +import { getIsWithinPrefixThatHasBeenMadePublic } from "./decoupledLogic/bucketPolicies"; import { type ObjectRendering } from "./decoupledLogic/objectRendering"; +import { stateItemToSelectorItem } from "./decoupledLogic/stateItemToSelectorItem"; export type RouteParams = { profile?: string; @@ -326,14 +323,17 @@ const items = createSelector( : paramsOfCreateS3Client.credentials === undefined; return isAnonymousS3Profile; }), - profileName_anonymous, + createSelector( + profileName_anonymous, + profileName_anonymous => profileName_anonymous !== undefined + ), ( listedPrefix_state, uploads_profile, deletions_profile, bucketPoliciesByBucket, isAnonymousS3Profile, - profileName_anonymous + isSharingPublicFolderFeatureEnabled ): MainView.Item[] | undefined => { if (listedPrefix_state === undefined) { return undefined; @@ -348,75 +348,13 @@ const items = createSelector( uploads: uploads_profile }); - const items_actual: MainView.Item[] = listedPrefix_state.current.items.map( - item => { - switch (item.type) { - case "object": - return id({ - type: "object", - displayName: (() => { - const keyBasename = item.s3Uri.keySegments.at(-1); - - assert(keyBasename !== undefined); - - return keyBasename; - })(), - s3Uri: item.s3Uri, - uploadProgressPercent: undefined, - isDeleting: false, - lastModified: item.lastModified, - size: item.size - }); - case "prefix": { - const hasBeenMadePublic = getHasPrefixBeMadePublic({ - s3Uri: item.s3Uri, - bucketPoliciesByBucket - }); - - const isWithinPrefixThatHasBeenMadePublic = - getIsWithinPrefixThatHasBeenMadePublic({ - s3Uri: item.s3Uri, - bucketPoliciesByBucket - }).isWithinPrefixThatHasBeenMadePublic; - - return id({ - type: "prefix segment", - displayName: (() => { - const lastSegment = item.s3Uri.keySegments.at(-1); - - assert(lastSegment !== undefined); - - return lastSegment; - })(), - s3Uri: item.s3Uri, - uploadProgressPercent: undefined, - isDeleting: false, - publicAccessAction: (() => { - if (isAnonymousS3Profile) { - return undefined; - } - - if (hasBeenMadePublic) { - return "make private"; - } - - if (isWithinPrefixThatHasBeenMadePublic) { - return undefined; - } - - return "make public"; - })(), - shouldShowShareAction: - profileName_anonymous !== undefined && - (isAnonymousS3Profile || - hasBeenMadePublic || - isWithinPrefixThatHasBeenMadePublic) - }); - } - default: - assert>(false); - } - } + const items_actual: MainView.Item[] = listedPrefix_state.current.items.map(item => + stateItemToSelectorItem({ + item, + isSharingPublicFolderFeatureEnabled, + bucketPoliciesByBucket, + isAnonymousS3Profile + }) ); const items: MainView.Item[] = []; From f29bda42d6df949e7517a613af5bd7bcb7486e04 Mon Sep 17 00:00:00 2001 From: garronej Date: Tue, 1 Sep 2026 15:03:41 +0200 Subject: [PATCH 09/22] Add more action in the uri bar --- .../decoupledLogic/bucketPolicies.test.ts | 54 ++++++++ ...licAccessActionAndShouldShowShareAction.ts | 61 ++++++++++ .../decoupledLogic/stateItemToSelectorItem.ts | 92 -------------- .../s3ExplorerUiController/selectors.ts | 115 +++++++++++++++--- web/src/ui/i18n/resources/de.tsx | 3 + web/src/ui/i18n/resources/en.tsx | 3 + web/src/ui/i18n/resources/es.tsx | 3 + web/src/ui/i18n/resources/fi.tsx | 3 + web/src/ui/i18n/resources/fr.tsx | 3 + web/src/ui/i18n/resources/it.tsx | 3 + web/src/ui/i18n/resources/nl.tsx | 3 + web/src/ui/i18n/resources/no.tsx | 3 + web/src/ui/i18n/resources/zh-CN.tsx | 3 + web/src/ui/pages/s3Explorer/Page.tsx | 34 ++++++ .../dialogs/S3SharePrefixDialog.tsx | 3 - .../S3ContextActionButton.stories.tsx | 4 + .../S3SharePrefixDialog.stories.tsx | 8 ++ .../codex/S3UriBar/S3UriBar.stories.tsx | 24 ++++ web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx | 60 +++++++++ 19 files changed, 367 insertions(+), 115 deletions(-) create mode 100644 web/src/core/usecases/s3ExplorerUiController/decoupledLogic/getPublicAccessActionAndShouldShowShareAction.ts delete mode 100644 web/src/core/usecases/s3ExplorerUiController/decoupledLogic/stateItemToSelectorItem.ts diff --git a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicies.test.ts b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicies.test.ts index c3a02b5d8..fca917533 100644 --- a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicies.test.ts +++ b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicies.test.ts @@ -173,6 +173,60 @@ describe("bucketPolicies", () => { ).toStrictEqual({ isWithinPrefixThatHasBeenMadePublic: false }); }); + it("supports making the bucket root public", () => { + const bucketRoot = parsePrefix("s3://mybucket/"); + + const { updatedBucketPolicies } = makePrefixPublic({ + s3Uri: bucketRoot, + bucketPoliciesByBucket: getBucketPoliciesByBucket({ + Version: "2012-10-17", + Statement: [] + }) + }); + + const statements = updatedBucketPolicies.Statement; + + assert(Array.isArray(statements)); + expect(statements).toHaveLength(2); + expect(statements[0]).toMatchObject({ + Sid: "OnyxiaMakePrefixPublicGetObject", + Resource: ["arn:aws:s3:::mybucket/*"] + }); + expect(statements[1]).toMatchObject({ + Sid: "OnyxiaMakePrefixPublicListBucket", + Condition: { + StringLike: { + "s3:prefix": ["*"] + } + } + }); + + const bucketPoliciesByBucket = getBucketPoliciesByBucket(updatedBucketPolicies); + + expect( + getHasPrefixBeMadePublic({ + s3Uri: bucketRoot, + bucketPoliciesByBucket + }) + ).toBe(true); + expect( + getIsWithinPrefixThatHasBeenMadePublic({ + s3Uri: parseObject("s3://mybucket/nested/file.csv"), + bucketPoliciesByBucket + }) + ).toStrictEqual({ + isWithinPrefixThatHasBeenMadePublic: true, + s3Uri_publicPrefix: bucketRoot + }); + + expect( + undoMakePrefixPublic({ + s3Uri: bucketRoot, + bucketPoliciesByBucket + }).updatedBucketPolicies.Statement + ).toStrictEqual([]); + }); + it("returns the public prefix that contains an object", () => { const updatedBucketPolicies = makePrefixPublic({ s3Uri: parsePrefix("s3://mybucket/foo/"), diff --git a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/getPublicAccessActionAndShouldShowShareAction.ts b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/getPublicAccessActionAndShouldShowShareAction.ts new file mode 100644 index 000000000..9f3c90b06 --- /dev/null +++ b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/getPublicAccessActionAndShouldShowShareAction.ts @@ -0,0 +1,61 @@ +import { + type BucketPoliciesByBucket, + getHasPrefixBeMadePublic, + getIsWithinPrefixThatHasBeenMadePublic +} from "./bucketPolicies"; +import memoize from "memoizee"; +import type { S3Uri } from "core/tools/S3Uri"; + +export function getPublicAccessActionAndShouldShowShareAction(params: { + s3Uri: S3Uri.TerminatedByDelimiter; + bucketPoliciesByBucket: BucketPoliciesByBucket; + isSharingPublicFolderFeatureEnabled: boolean; + isAnonymousS3Profile: boolean; +}): { + publicAccessAction: "make private" | "make public" | undefined; + shouldShowShareAction: boolean; +} { + const { + s3Uri, + bucketPoliciesByBucket, + isSharingPublicFolderFeatureEnabled, + isAnonymousS3Profile + } = params; + + const hasBeenMadePublic = getHasPrefixBeMadePublic({ + s3Uri, + bucketPoliciesByBucket + }); + + const getIsWithinPrefixThatHasBeenMadePublic_local = memoize( + () => + getIsWithinPrefixThatHasBeenMadePublic({ + s3Uri, + bucketPoliciesByBucket + }).isWithinPrefixThatHasBeenMadePublic + ); + + const publicAccessAction = (() => { + if (isAnonymousS3Profile) { + return undefined; + } + + if (hasBeenMadePublic) { + return "make private" as const; + } + + if (getIsWithinPrefixThatHasBeenMadePublic_local()) { + return undefined; + } + + return "make public"; + })(); + + const shouldShowShareAction = + isSharingPublicFolderFeatureEnabled && + (isAnonymousS3Profile || + hasBeenMadePublic || + getIsWithinPrefixThatHasBeenMadePublic_local()); + + return { publicAccessAction, shouldShowShareAction }; +} diff --git a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/stateItemToSelectorItem.ts b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/stateItemToSelectorItem.ts deleted file mode 100644 index f2f28d5f0..000000000 --- a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/stateItemToSelectorItem.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { State } from "../state"; -import { - type BucketPoliciesByBucket, - getHasPrefixBeMadePublic, - getIsWithinPrefixThatHasBeenMadePublic -} from "./bucketPolicies"; -import type { MainView } from "../selectors"; -import { assert, id, type Equals } from "tsafe"; -import memoize from "memoizee"; - -export const stateItemToSelectorItem = (params: { - item: State.ListedPrefix.Item; - isSharingPublicFolderFeatureEnabled: boolean; - bucketPoliciesByBucket: BucketPoliciesByBucket; - isAnonymousS3Profile: boolean; -}): MainView.Item => { - const { - item, - isSharingPublicFolderFeatureEnabled, - bucketPoliciesByBucket, - isAnonymousS3Profile - } = params; - - switch (item.type) { - case "object": - return id({ - type: "object", - displayName: (() => { - const keyBasename = item.s3Uri.keySegments.at(-1); - - assert(keyBasename !== undefined); - - return keyBasename; - })(), - s3Uri: item.s3Uri, - uploadProgressPercent: undefined, - isDeleting: false, - lastModified: item.lastModified, - size: item.size - }); - case "prefix": { - const hasBeenMadePublic = getHasPrefixBeMadePublic({ - s3Uri: item.s3Uri, - bucketPoliciesByBucket - }); - - const getIsWithinPrefixThatHasBeenMadePublic_local = memoize( - () => - getIsWithinPrefixThatHasBeenMadePublic({ - s3Uri: item.s3Uri, - bucketPoliciesByBucket - }).isWithinPrefixThatHasBeenMadePublic - ); - - return id({ - type: "prefix segment", - displayName: (() => { - const lastSegment = item.s3Uri.keySegments.at(-1); - - assert(lastSegment !== undefined); - - return lastSegment; - })(), - s3Uri: item.s3Uri, - uploadProgressPercent: undefined, - isDeleting: false, - publicAccessAction: (() => { - if (isAnonymousS3Profile) { - return undefined; - } - - if (hasBeenMadePublic) { - return "make private"; - } - - if (getIsWithinPrefixThatHasBeenMadePublic_local()) { - return undefined; - } - - return "make public"; - })(), - shouldShowShareAction: - isSharingPublicFolderFeatureEnabled && - (isAnonymousS3Profile || - hasBeenMadePublic || - getIsWithinPrefixThatHasBeenMadePublic_local()) - }); - } - default: - assert>(false); - } -}; diff --git a/web/src/core/usecases/s3ExplorerUiController/selectors.ts b/web/src/core/usecases/s3ExplorerUiController/selectors.ts index 8146afed0..1fd7529e6 100644 --- a/web/src/core/usecases/s3ExplorerUiController/selectors.ts +++ b/web/src/core/usecases/s3ExplorerUiController/selectors.ts @@ -3,13 +3,13 @@ import * as s3ProfilesManagement from "core/usecases/s3ProfilesManagement"; import type { LocalizedString } from "core/ports/OnyxiaApi"; import { type S3Uri, stringifyS3Uri, getIsInside } from "core/tools/S3Uri"; import type { State as RootState } from "core/bootstrap"; -import { assert, type Equals } from "tsafe"; +import { assert, type Equals, id } from "tsafe"; import { same } from "evt/tools/inDepth/same"; import { computeUploadStatusAtPrefix } from "./decoupledLogic/computeUploadStatusAtPrefix"; import { name, type State } from "./state"; import { getIsWithinPrefixThatHasBeenMadePublic } from "./decoupledLogic/bucketPolicies"; import { type ObjectRendering } from "./decoupledLogic/objectRendering"; -import { stateItemToSelectorItem } from "./decoupledLogic/stateItemToSelectorItem"; +import { getPublicAccessActionAndShouldShowShareAction } from "./decoupledLogic/getPublicAccessActionAndShouldShowShareAction"; export type RouteParams = { profile?: string; @@ -61,6 +61,8 @@ export type MainView = { isBookmarked: true; isReadonly: boolean; }; + publicAccessAction: "make public" | "make private" | undefined; + shouldShowShareAction: boolean; }; isBackButtonDisabled: boolean; @@ -308,12 +310,9 @@ const deletions_profile = createSelector( } ); -const items = createSelector( - listedPrefix_state, - uploads_profile, - deletions_profile, - createSelector(state, state => state.bucketPoliciesByBucket), - createSelector(s3ProfilesManagement.selectors.ambientS3Profile, s3Profile => { +const isAnonymousS3Profile = createSelector( + s3ProfilesManagement.selectors.ambientS3Profile, + s3Profile => { if (s3Profile === undefined) { return true; } @@ -322,7 +321,15 @@ const items = createSelector( ? false : paramsOfCreateS3Client.credentials === undefined; return isAnonymousS3Profile; - }), + } +); + +const items = createSelector( + listedPrefix_state, + uploads_profile, + deletions_profile, + createSelector(state, state => state.bucketPoliciesByBucket), + isAnonymousS3Profile, createSelector( profileName_anonymous, profileName_anonymous => profileName_anonymous !== undefined @@ -348,13 +355,54 @@ const items = createSelector( uploads: uploads_profile }); - const items_actual: MainView.Item[] = listedPrefix_state.current.items.map(item => - stateItemToSelectorItem({ - item, - isSharingPublicFolderFeatureEnabled, - bucketPoliciesByBucket, - isAnonymousS3Profile - }) + const items_actual: MainView.Item[] = listedPrefix_state.current.items.map( + item => { + switch (item.type) { + case "object": + return id({ + type: "object", + displayName: (() => { + const keyBasename = item.s3Uri.keySegments.at(-1); + + assert(keyBasename !== undefined); + + return keyBasename; + })(), + s3Uri: item.s3Uri, + uploadProgressPercent: undefined, + isDeleting: false, + lastModified: item.lastModified, + size: item.size + }); + case "prefix": { + const { publicAccessAction, shouldShowShareAction } = + getPublicAccessActionAndShouldShowShareAction({ + s3Uri: item.s3Uri, + bucketPoliciesByBucket, + isAnonymousS3Profile, + isSharingPublicFolderFeatureEnabled + }); + + return id({ + type: "prefix segment", + displayName: (() => { + const lastSegment = item.s3Uri.keySegments.at(-1); + + assert(lastSegment !== undefined); + + return lastSegment; + })(), + s3Uri: item.s3Uri, + uploadProgressPercent: undefined, + isDeleting: false, + publicAccessAction, + shouldShowShareAction + }); + } + default: + assert>(false); + } + } ); const items: MainView.Item[] = []; @@ -545,12 +593,21 @@ const uriBar = createSelector( bookmarks, listedPrefix, isListing, + createSelector(state, state => state.bucketPoliciesByBucket), + isAnonymousS3Profile, + createSelector( + profileName_anonymous, + profileName_anonymous => profileName_anonymous !== undefined + ), ( s3Uri, s3Uri_publicPrefix, bookmarks, listedPrefix, - isListing + isListing, + bucketPoliciesByBucket, + isAnonymousS3Profile, + isSharingPublicFolderFeatureEnabled ): MainView["uriBar"] => { const sortHints = ( hints: MainView["uriBar"]["hints"] @@ -585,7 +642,9 @@ const uriBar = createSelector( ), bookmarkStatus: { isBookmarked: false - } + }, + publicAccessAction: undefined, + shouldShowShareAction: false }; } @@ -655,7 +714,9 @@ const uriBar = createSelector( return { s3Uri: { s3Uri, s3Uri_publicPrefix }, hints: sortHints(hints), - bookmarkStatus + bookmarkStatus, + publicAccessAction: undefined, + shouldShowShareAction: false }; } @@ -697,10 +758,24 @@ const uriBar = createSelector( } }); + const { publicAccessAction, shouldShowShareAction } = !s3Uri.isDelimiterTerminated + ? { + publicAccessAction: undefined, + shouldShowShareAction: false + } + : getPublicAccessActionAndShouldShowShareAction({ + s3Uri: s3Uri, + bucketPoliciesByBucket, + isAnonymousS3Profile, + isSharingPublicFolderFeatureEnabled + }); + return { s3Uri: { s3Uri, s3Uri_publicPrefix }, hints: sortHints(hints), - bookmarkStatus + bookmarkStatus, + publicAccessAction, + shouldShowShareAction }; } ); diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index ee0b7dc9e..dcb228cf4 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -1359,6 +1359,9 @@ Fühlen Sie sich frei, Ihre Kubernetes-Bereitstellungen zu erkunden und die Kont "delete from bookmarks": "Aus Lesezeichen entfernen", "pinned storage location": "Angehefteter Speicherort", bookmarked: "Als Lesezeichen gespeichert", + share: "Teilen", + "make public": "Öffentlich machen", + "make private": "Privat machen", "edit s3 uri": "S3-URI bearbeiten", prefix: "Präfix", "admin bookmark": "Admin-Lesezeichen", diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index ea660a9a0..e13429d1a 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -1332,6 +1332,9 @@ Feel free to explore and take charge of your Kubernetes deployments! "delete from bookmarks": "Delete from bookmarks", "pinned storage location": "Pinned storage location", bookmarked: "Bookmarked", + share: "Share", + "make public": "Make public", + "make private": "Make private", "edit s3 uri": "Edit S3 URI", prefix: "Prefix", "admin bookmark": "Admin bookmark", diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index 73dbe2d93..adb195076 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -1346,6 +1346,9 @@ export const translations: Translations<"es"> = { "delete from bookmarks": "Eliminar de marcadores", "pinned storage location": "Ubicación de almacenamiento fijada", bookmarked: "Marcado", + share: "Compartir", + "make public": "Hacer público", + "make private": "Hacer privado", "edit s3 uri": "Editar URI S3", prefix: "Prefijo", "admin bookmark": "Marcador de administración", diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 544bc4afc..e42776d4e 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -1329,6 +1329,9 @@ Tutustu vapaasti ja ota hallintaan Kubernetes-julkaisusi! "delete from bookmarks": "Poista kirjanmerkeistä", "pinned storage location": "Kiinnitetty tallennussijainti", bookmarked: "Kirjanmerkitty", + share: "Jaa", + "make public": "Tee julkiseksi", + "make private": "Tee yksityiseksi", "edit s3 uri": "Muokkaa S3-URIa", prefix: "Etuliite", "admin bookmark": "Ylläpitäjän kirjanmerkki", diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 65639b7b5..c918e65e1 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -1356,6 +1356,9 @@ N'hésitez pas à explorer et à prendre en main vos déploiements Kubernetes ! "delete from bookmarks": "Supprimer des favoris", "pinned storage location": "Emplacement de stockage épinglé", bookmarked: "Dans les favoris", + share: "Partager", + "make public": "Rendre public", + "make private": "Rendre privé", "edit s3 uri": "Modifier l'URI S3", prefix: "Préfixe", "admin bookmark": "Favori administrateur", diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index dc422a7d6..3007f99cd 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -1343,6 +1343,9 @@ Sentiti libero di esplorare e prendere il controllo dei tuoi deployment Kubernet "delete from bookmarks": "Elimina dai segnalibri", "pinned storage location": "Posizione di archiviazione fissata", bookmarked: "Nei segnalibri", + share: "Condividi", + "make public": "Rendi pubblico", + "make private": "Rendi privato", "edit s3 uri": "Modifica URI S3", prefix: "Prefisso", "admin bookmark": "Segnalibro amministratore", diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 576205052..cd1fbb712 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -1346,6 +1346,9 @@ Voel je vrij om te verkennen en de controle over je Kubernetes-implementaties te "delete from bookmarks": "Uit bladwijzers verwijderen", "pinned storage location": "Vastgezette opslaglocatie", bookmarked: "Bladwijzer", + share: "Delen", + "make public": "Openbaar maken", + "make private": "Privé maken", "edit s3 uri": "S3-URI bewerken", prefix: "Prefix", "admin bookmark": "Beheerbladwijzer", diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 68d84e86f..e455fdfa1 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -1337,6 +1337,9 @@ Utforsk gjerne og ta kontroll over tjenestene du kjører på Kubernetes! "delete from bookmarks": "Slett fra bokmerker", "pinned storage location": "Festet lagringssted", bookmarked: "Bokmerket", + share: "Del", + "make public": "Gjør offentlig", + "make private": "Gjør privat", "edit s3 uri": "Rediger S3-URI", prefix: "Prefiks", "admin bookmark": "Admin-bokmerke", diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index b9df6b7ea..d894fb439 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -1268,6 +1268,9 @@ ${ "delete from bookmarks": "从书签中删除", "pinned storage location": "已固定的存储位置", bookmarked: "已添加书签", + share: "共享", + "make public": "公开", + "make private": "设为私有", "edit s3 uri": "编辑 S3 URI", prefix: "前缀", "admin bookmark": "管理员书签", diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 1b0066a36..f83cbf5e2 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -525,6 +525,40 @@ function S3Explorer() { isBookmarked={ mainView.uriBar.bookmarkStatus.isBookmarked } + publicAccessAction={ + mainView.uriBar.publicAccessAction + } + onChangePrefixPolicy={async ({ action, s3Uri }) => { + const dDoProceed = new Deferred(); + + dialogProps.evtMakePrefixPublicDialogOpen.post({ + s3Uri, + action, + resolveDoProceed: dDoProceed.resolve + }); + + if (!(await dDoProceed.pr)) { + return; + } + + s3ExplorerUiController.toggleS3UriPublicPrivatePolicy( + { s3Uri } + ); + }} + shouldShowShareAction={ + mainView.uriBar.shouldShowShareAction + } + onSharePrefix={({ s3Uri }) => { + assert( + mainView.profileNameForSharing !== undefined + ); + + dialogProps.evtS3SharePrefixDialogOpen.post({ + s3Uri, + anonymousProfileName: + mainView.profileNameForSharing + }); + }} evtAction={evtS3UriBarAction} /> () }; @@ -207,6 +211,18 @@ export const NavigationMode: Story = { render: args => }; +export const PrefixActions: Story = { + args: { + ...baseArgs, + s3Uri: parseS3UriBarS3Uri({ + s3Uri: "s3://analytics-data/exports/2024/quarter-1/" + }), + publicAccessAction: "make private", + shouldShowShareAction: true + }, + render: args => +}; + export const CopyActionWithInternalFeedback: Story = { args: { ...baseArgs @@ -633,6 +649,10 @@ function ControlledS3UriBarStory() { ); action("toggleBookmark")(currentS3Uri); }} + publicAccessAction={undefined} + onChangePrefixPolicy={action("changePrefixPolicy")} + shouldShowShareAction={false} + onSharePrefix={action("sharePrefix")} evtAction={evtAction} /> @@ -708,6 +728,10 @@ function UndefinedPrefixLockedEditingStory() { }); }} onToggleBookmark={undefined} + publicAccessAction={undefined} + onChangePrefixPolicy={action("changePrefixPolicy")} + shouldShowShareAction={false} + onSharePrefix={action("sharePrefix")} evtAction={evtAction} /> diff --git a/web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx b/web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx index 3d8d8d4bc..997ed1ab7 100644 --- a/web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx +++ b/web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx @@ -51,6 +51,13 @@ export type S3UriBarProps = { areHintsLoading: boolean; isBookmarked: boolean; onToggleBookmark: ((props: { s3Uri: S3Uri }) => void) | undefined; + publicAccessAction: "make public" | "make private" | undefined; + onChangePrefixPolicy: (params: { + action: "make public" | "undo make public"; + s3Uri: S3Uri.TerminatedByDelimiter; + }) => void; + shouldShowShareAction: boolean; + onSharePrefix: (params: { s3Uri: S3Uri.TerminatedByDelimiter }) => void; evtAction: NonPostableEvt<{ action: "display copy feedback"; s3Uri: S3Uri; @@ -66,6 +73,10 @@ export function S3UriBar(props: S3UriBarProps) { areHintsLoading, isBookmarked, onToggleBookmark, + publicAccessAction, + onChangePrefixPolicy, + shouldShowShareAction, + onSharePrefix, evtAction } = props; @@ -1429,6 +1440,52 @@ export function S3UriBar(props: S3UriBarProps) {
)} + {shouldShowShareAction && ( + +
+ { + event.stopPropagation(); + assert(currentS3Uri !== undefined); + assert(currentS3Uri.isDelimiterTerminated); + onSharePrefix({ s3Uri: currentS3Uri }); + }} + className={classes.actionButton} + /> +
+
+ )} + {publicAccessAction !== undefined && ( + +
+ { + event.stopPropagation(); + assert(currentS3Uri !== undefined); + assert(currentS3Uri.isDelimiterTerminated); + onChangePrefixPolicy({ + action: + publicAccessAction === "make private" + ? "undo make public" + : "make public", + s3Uri: currentS3Uri + }); + }} + className={classes.actionButton} + /> +
+
+ )} {!isUndefinedPrefixMode && (
@@ -2033,6 +2090,9 @@ const { i18n } = declareComponentKeys< | "delete from bookmarks" | "pinned storage location" | "bookmarked" + | "share" + | "make public" + | "make private" | "edit s3 uri" | "prefix" | "admin bookmark" From fe68547f9e91c74e2a7862d1cea5410f84ec2f00 Mon Sep 17 00:00:00 2001 From: garronej Date: Tue, 1 Sep 2026 16:46:12 +0200 Subject: [PATCH 10/22] Securing request files feature --- .../s3ExplorerUiController/selectors.ts | 25 +++++++++++++++++-- .../decoupledLogic/getIsKnownS3HttpUrl.ts | 15 +++++++++++ .../s3FileRequestUiController/thunks.ts | 13 +++++++++- web/src/ui/i18n/resources/de.tsx | 1 + web/src/ui/i18n/resources/en.tsx | 1 + web/src/ui/i18n/resources/es.tsx | 1 + web/src/ui/i18n/resources/fi.tsx | 1 + web/src/ui/i18n/resources/fr.tsx | 1 + web/src/ui/i18n/resources/it.tsx | 1 + web/src/ui/i18n/resources/nl.tsx | 1 + web/src/ui/i18n/resources/no.tsx | 1 + web/src/ui/i18n/resources/zh-CN.tsx | 1 + web/src/ui/pages/s3Explorer/Page.tsx | 14 +++++------ .../S3ContextActionButton.stories.tsx | 1 + .../S3ExplorerMainView.stories.tsx | 13 ++++++++++ .../S3ExplorerMainView/S3ExplorerMainView.tsx | 10 +++++--- .../codex/S3UriBar/S3UriBar.stories.tsx | 6 ++++- web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx | 22 ++++++++++++++++ 18 files changed, 114 insertions(+), 14 deletions(-) create mode 100644 web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getIsKnownS3HttpUrl.ts diff --git a/web/src/core/usecases/s3ExplorerUiController/selectors.ts b/web/src/core/usecases/s3ExplorerUiController/selectors.ts index 1fd7529e6..eb008f638 100644 --- a/web/src/core/usecases/s3ExplorerUiController/selectors.ts +++ b/web/src/core/usecases/s3ExplorerUiController/selectors.ts @@ -10,6 +10,8 @@ import { name, type State } from "./state"; import { getIsWithinPrefixThatHasBeenMadePublic } from "./decoupledLogic/bucketPolicies"; import { type ObjectRendering } from "./decoupledLogic/objectRendering"; import { getPublicAccessActionAndShouldShowShareAction } from "./decoupledLogic/getPublicAccessActionAndShouldShowShareAction"; +import { getRootContext } from "core/rootContext"; +import { getIsKnownS3HttpUrl } from "core/usecases/s3FileRequestUiController/decoupledLogic/getIsKnownS3HttpUrl"; export type RouteParams = { profile?: string; @@ -99,6 +101,8 @@ export type MainView = { commandLogsEntries: State.CommandLogsEntry[]; profileNameForSharing: string | undefined; + + isRequestFilesEnabled: boolean; }; export namespace MainView { @@ -785,6 +789,20 @@ const commandLogsEntries = createSelector( (state): MainView["commandLogsEntries"] => state.commandLogsEntries ); +const isRequestFilesEnabled = createSelector( + s3ProfilesManagement.selectors.ambientS3Profile, + (s3Profile): MainView["isRequestFilesEnabled"] => { + if (s3Profile === undefined) { + return false; + } + + return getIsKnownS3HttpUrl({ + s3HttpUrl: s3Profile.paramsOfCreateS3Client.url, + s3Config: getRootContext().s3Config + }); + } +); + const mainView = createSelector( profileSelect, bookmarks, @@ -798,6 +816,7 @@ const mainView = createSelector( listedPrefix, commandLogsEntries, profileName_anonymous, + isRequestFilesEnabled, ( profileSelect, bookmarks, @@ -810,7 +829,8 @@ const mainView = createSelector( isListing, listedPrefix, commandLogsEntries, - profileNameForSharing + profileNameForSharing, + isRequestFilesEnabled ): MainView => ({ profileSelect, bookmarks, @@ -823,7 +843,8 @@ const mainView = createSelector( isListing, listedPrefix, commandLogsEntries, - profileNameForSharing + profileNameForSharing, + isRequestFilesEnabled }) ); diff --git a/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getIsKnownS3HttpUrl.ts b/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getIsKnownS3HttpUrl.ts new file mode 100644 index 000000000..675dc9401 --- /dev/null +++ b/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getIsKnownS3HttpUrl.ts @@ -0,0 +1,15 @@ +import type { S3Config } from "core/ports/OnyxiaApi/S3Config"; +import { exclude } from "tsafe"; + +export function getIsKnownS3HttpUrl(params: { s3HttpUrl: string; s3Config: S3Config }) { + const { s3HttpUrl, s3Config } = params; + + const knownServerUrls = [ + s3Config.defaultValuesOfCreationForm?.url, + ...s3Config.entries.map(entry => entry.url) + ].filter(exclude(undefined)); + + return ( + knownServerUrls.find(serverUrl => s3HttpUrl.startsWith(serverUrl)) !== undefined + ); +} diff --git a/web/src/core/usecases/s3FileRequestUiController/thunks.ts b/web/src/core/usecases/s3FileRequestUiController/thunks.ts index eb2c30df7..e497885f1 100644 --- a/web/src/core/usecases/s3FileRequestUiController/thunks.ts +++ b/web/src/core/usecases/s3FileRequestUiController/thunks.ts @@ -2,6 +2,7 @@ import type { Thunks } from "core/bootstrap"; import { actions, type PresignedPost } from "./state"; import { privateSelectors } from "./selectors"; import { assert } from "tsafe/assert"; +import { getIsKnownS3HttpUrl } from "./decoupledLogic/getIsKnownS3HttpUrl"; const fileByUploadId = new Map(); const xhrByUploadId = new Map(); @@ -11,7 +12,17 @@ export const thunks = { (params: { presignedPost: PresignedPost }) => (...args) => { const { presignedPost } = params; - const [dispatch] = args; + const [dispatch, , rootContext] = args; + + if ( + !getIsKnownS3HttpUrl({ + s3Config: rootContext.s3Config, + s3HttpUrl: presignedPost.url + }) + ) { + alert("Not allowed"); + throw new Error(); + } for (const xhr of [...xhrByUploadId.values()]) { xhr.abort(); diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index dcb228cf4..ba6423899 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -1360,6 +1360,7 @@ Fühlen Sie sich frei, Ihre Kubernetes-Bereitstellungen zu erkunden und die Kont "pinned storage location": "Angehefteter Speicherort", bookmarked: "Als Lesezeichen gespeichert", share: "Teilen", + "request files": "Dateien anfordern", "make public": "Öffentlich machen", "make private": "Privat machen", "edit s3 uri": "S3-URI bearbeiten", diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index e13429d1a..abb1f2400 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -1333,6 +1333,7 @@ Feel free to explore and take charge of your Kubernetes deployments! "pinned storage location": "Pinned storage location", bookmarked: "Bookmarked", share: "Share", + "request files": "Request files", "make public": "Make public", "make private": "Make private", "edit s3 uri": "Edit S3 URI", diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index adb195076..0b7d7180c 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -1347,6 +1347,7 @@ export const translations: Translations<"es"> = { "pinned storage location": "Ubicación de almacenamiento fijada", bookmarked: "Marcado", share: "Compartir", + "request files": "Solicitar archivos", "make public": "Hacer público", "make private": "Hacer privado", "edit s3 uri": "Editar URI S3", diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index e42776d4e..716193c75 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -1330,6 +1330,7 @@ Tutustu vapaasti ja ota hallintaan Kubernetes-julkaisusi! "pinned storage location": "Kiinnitetty tallennussijainti", bookmarked: "Kirjanmerkitty", share: "Jaa", + "request files": "Pyydä tiedostoja", "make public": "Tee julkiseksi", "make private": "Tee yksityiseksi", "edit s3 uri": "Muokkaa S3-URIa", diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index c918e65e1..b564d4fbd 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -1357,6 +1357,7 @@ N'hésitez pas à explorer et à prendre en main vos déploiements Kubernetes ! "pinned storage location": "Emplacement de stockage épinglé", bookmarked: "Dans les favoris", share: "Partager", + "request files": "Demander des fichiers", "make public": "Rendre public", "make private": "Rendre privé", "edit s3 uri": "Modifier l'URI S3", diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index 3007f99cd..b687abab6 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -1344,6 +1344,7 @@ Sentiti libero di esplorare e prendere il controllo dei tuoi deployment Kubernet "pinned storage location": "Posizione di archiviazione fissata", bookmarked: "Nei segnalibri", share: "Condividi", + "request files": "Richiedi file", "make public": "Rendi pubblico", "make private": "Rendi privato", "edit s3 uri": "Modifica URI S3", diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index cd1fbb712..d4d312d37 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -1347,6 +1347,7 @@ Voel je vrij om te verkennen en de controle over je Kubernetes-implementaties te "pinned storage location": "Vastgezette opslaglocatie", bookmarked: "Bladwijzer", share: "Delen", + "request files": "Bestanden aanvragen", "make public": "Openbaar maken", "make private": "Privé maken", "edit s3 uri": "S3-URI bewerken", diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index e455fdfa1..24038b3bf 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -1338,6 +1338,7 @@ Utforsk gjerne og ta kontroll over tjenestene du kjører på Kubernetes! "pinned storage location": "Festet lagringssted", bookmarked: "Bokmerket", share: "Del", + "request files": "Be om filer", "make public": "Gjør offentlig", "make private": "Gjør privat", "edit s3 uri": "Rediger S3-URI", diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index d894fb439..b81f30307 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -1269,6 +1269,7 @@ ${ "pinned storage location": "已固定的存储位置", bookmarked: "已添加书签", share: "共享", + "request files": "请求文件", "make public": "公开", "make private": "设为私有", "edit s3 uri": "编辑 S3 URI", diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index f83cbf5e2..4e1f47cd2 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -271,6 +271,11 @@ function S3Explorer() { }); }; + const onRequestFiles = mainView.isRequestFilesEnabled + ? ({ s3Uri }: { s3Uri: S3Uri.TerminatedByDelimiter }) => + dialogProps.evtS3FileRequestCreationDialogOpen.post({ s3Uri }) + : undefined; + return ( <> @@ -559,6 +564,7 @@ function S3Explorer() { mainView.profileNameForSharing }); }} + onRequestFiles={onRequestFiles} evtAction={evtS3UriBarAction} /> - dialogProps.evtS3FileRequestCreationDialogOpen.post( - { - s3Uri - } - ) - } + onRequestFiles={onRequestFiles} onBookmark={ isUserLoggedIn ? toggleBookmarkFromDataView diff --git a/web/src/ui/shared/codex/S3ContextActionButton/S3ContextActionButton.stories.tsx b/web/src/ui/shared/codex/S3ContextActionButton/S3ContextActionButton.stories.tsx index 0a0f687d5..fc1e6037f 100644 --- a/web/src/ui/shared/codex/S3ContextActionButton/S3ContextActionButton.stories.tsx +++ b/web/src/ui/shared/codex/S3ContextActionButton/S3ContextActionButton.stories.tsx @@ -83,6 +83,7 @@ const baseArgs: S3UriBarProps = { onChangePrefixPolicy: action("changePrefixPolicy"), shouldShowShareAction: false, onSharePrefix: action("sharePrefix"), + onRequestFiles: undefined, evtAction: Evt.create<{ action: "display copy feedback"; s3Uri: S3Uri; diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx index c8c91c47e..4f905e2d9 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx @@ -421,6 +421,19 @@ export const ListingInProgress: Story = { ) }; +export const RequestFilesDisabled: Story = { + args: { + ...placeholderArgs, + listedPrefix: toListedItems(baseNodes, defaultPrefix), + onRequestFiles: undefined + }, + render: args => ( +
+ +
+ ) +}; + export const EmptyPrefix: Story = { args: { isListing: false, diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx index 799646ba3..21e0882d1 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx @@ -89,7 +89,9 @@ export type S3ExplorerMainViewProps = { anonymousProfileName: string; }) => void; - onRequestFiles: (params: { s3Uri: S3Uri.TerminatedByDelimiter }) => void; + onRequestFiles: + | ((params: { s3Uri: S3Uri.TerminatedByDelimiter }) => void) + | undefined; onBookmark: ((params: { s3Uri: S3Uri }) => void) | undefined; @@ -585,7 +587,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { const requestFilesForPrefix = useConstCallback( (item: S3ExplorerMainViewProps.Item.PrefixSegment) => { - if (!getIsItemActionAvailable(item)) { + if (!getIsItemActionAvailable(item) || onRequestFiles === undefined) { return; } @@ -865,6 +867,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { } requestFiles={ selectedPrefixForSingleItemAction === undefined || + onRequestFiles === undefined || !getIsItemActionAvailable( selectedPrefixForSingleItemAction ) @@ -1199,7 +1202,8 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { : undefined } onRequestFiles={ - item.type === "prefix segment" + item.type === "prefix segment" && + onRequestFiles !== undefined ? onRequestFilesFactory( itemKey ) diff --git a/web/src/ui/shared/codex/S3UriBar/S3UriBar.stories.tsx b/web/src/ui/shared/codex/S3UriBar/S3UriBar.stories.tsx index 2ceef5c10..12a5dbbb7 100644 --- a/web/src/ui/shared/codex/S3UriBar/S3UriBar.stories.tsx +++ b/web/src/ui/shared/codex/S3UriBar/S3UriBar.stories.tsx @@ -201,6 +201,7 @@ const baseArgs: S3UriBarProps = { onChangePrefixPolicy: action("changePrefixPolicy"), shouldShowShareAction: false, onSharePrefix: action("sharePrefix"), + onRequestFiles: undefined, evtAction: Evt.create<{ action: "display copy feedback"; s3Uri: S3Uri }>() }; @@ -218,7 +219,8 @@ export const PrefixActions: Story = { s3Uri: "s3://analytics-data/exports/2024/quarter-1/" }), publicAccessAction: "make private", - shouldShowShareAction: true + shouldShowShareAction: true, + onRequestFiles: action("requestFiles") }, render: args => }; @@ -653,6 +655,7 @@ function ControlledS3UriBarStory() { onChangePrefixPolicy={action("changePrefixPolicy")} shouldShowShareAction={false} onSharePrefix={action("sharePrefix")} + onRequestFiles={undefined} evtAction={evtAction} /> @@ -732,6 +735,7 @@ function UndefinedPrefixLockedEditingStory() { onChangePrefixPolicy={action("changePrefixPolicy")} shouldShowShareAction={false} onSharePrefix={action("sharePrefix")} + onRequestFiles={undefined} evtAction={evtAction} /> diff --git a/web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx b/web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx index 997ed1ab7..b1343eab8 100644 --- a/web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx +++ b/web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx @@ -58,6 +58,9 @@ export type S3UriBarProps = { }) => void; shouldShowShareAction: boolean; onSharePrefix: (params: { s3Uri: S3Uri.TerminatedByDelimiter }) => void; + onRequestFiles: + | ((params: { s3Uri: S3Uri.TerminatedByDelimiter }) => void) + | undefined; evtAction: NonPostableEvt<{ action: "display copy feedback"; s3Uri: S3Uri; @@ -77,6 +80,7 @@ export function S3UriBar(props: S3UriBarProps) { onChangePrefixPolicy, shouldShowShareAction, onSharePrefix, + onRequestFiles, evtAction } = props; @@ -1458,6 +1462,23 @@ export function S3UriBar(props: S3UriBarProps) {
)} + {currentS3Uri?.isDelimiterTerminated && + onRequestFiles !== undefined && ( + +
+ { + event.stopPropagation(); + onRequestFiles({ s3Uri: currentS3Uri }); + }} + className={classes.actionButton} + /> +
+
+ )} {publicAccessAction !== undefined && (
@@ -2091,6 +2112,7 @@ const { i18n } = declareComponentKeys< | "pinned storage location" | "bookmarked" | "share" + | "request files" | "make public" | "make private" | "edit s3 uri" From 74d50eae221259f8b1c96a95ed4430304e7b8e7e Mon Sep 17 00:00:00 2001 From: garronej Date: Tue, 1 Sep 2026 17:04:42 +0200 Subject: [PATCH 11/22] Remove em dashes --- web/src/ui/i18n/resources/en.tsx | 2 +- web/src/ui/i18n/resources/fr.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index abb1f2400..1381476f7 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -325,7 +325,7 @@ export const translations: Translations<"en"> = { }, S3FileRequestCreationDialog: { description: - "Share this link with anyone—even someone without an account on this Onyxia instance—to let them upload files from their computer directly to this folder.", + "Share this link with anyone, even someone without an account on this Onyxia instance, to let them upload files from their computer directly to this folder.", "link settings": "Link settings", "link expires after": "Link expires after", "link validity aria label": "Upload link validity duration", diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index b564d4fbd..4d673c14f 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -467,7 +467,7 @@ export const translations: Translations<"fr"> = { }, S3FileRequestCreationDialog: { description: - "Partagez ce lien avec n’importe qui — même une personne sans compte sur cette instance Onyxia — pour lui permettre de téléverser des fichiers depuis son ordinateur directement dans ce dossier.", + "Partagez ce lien avec n’importe qui, même une personne sans compte sur cette instance Onyxia, pour lui permettre de téléverser des fichiers depuis son ordinateur directement dans ce dossier.", "link settings": "Paramètres du lien", "link expires after": "Expiration du lien", "link validity aria label": "Durée de validité du lien de téléversement", From 952daaa371056533c008fae13859e4357644c050 Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 2 Sep 2026 17:48:16 +0200 Subject: [PATCH 12/22] Enable drag and drop folder in request file upload --- .../s3FileRequestUiController/thunks.ts | 39 +- web/src/ui/i18n/types.ts | 2 +- web/src/ui/pages/s3FileRequest/Page.tsx | 729 +---------------- .../S3ExplorerMainView/S3ExplorerMainView.tsx | 181 +---- .../codex/S3FileRequest/S3FileRequest.spec.md | 109 +++ .../S3FileRequest/S3FileRequest.stories.tsx | 95 +++ .../codex/S3FileRequest/S3FileRequest.tsx | 751 ++++++++++++++++++ .../ui/shared/codex/S3FileRequest/index.ts | 1 + .../getFilesToUploadFromDataTransfer.test.ts | 100 +++ .../codex/getFilesToUploadFromDataTransfer.ts | 149 ++++ 10 files changed, 1267 insertions(+), 889 deletions(-) create mode 100644 web/src/ui/shared/codex/S3FileRequest/S3FileRequest.spec.md create mode 100644 web/src/ui/shared/codex/S3FileRequest/S3FileRequest.stories.tsx create mode 100644 web/src/ui/shared/codex/S3FileRequest/S3FileRequest.tsx create mode 100644 web/src/ui/shared/codex/S3FileRequest/index.ts create mode 100644 web/src/ui/shared/codex/getFilesToUploadFromDataTransfer.test.ts create mode 100644 web/src/ui/shared/codex/getFilesToUploadFromDataTransfer.ts diff --git a/web/src/core/usecases/s3FileRequestUiController/thunks.ts b/web/src/core/usecases/s3FileRequestUiController/thunks.ts index e497885f1..192269360 100644 --- a/web/src/core/usecases/s3FileRequestUiController/thunks.ts +++ b/web/src/core/usecases/s3FileRequestUiController/thunks.ts @@ -4,7 +4,12 @@ import { privateSelectors } from "./selectors"; import { assert } from "tsafe/assert"; import { getIsKnownS3HttpUrl } from "./decoupledLogic/getIsKnownS3HttpUrl"; -const fileByUploadId = new Map(); +type FileToUpload = { + file: File; + relativePathSegments: readonly string[]; +}; + +const fileToUploadByUploadId = new Map(); const xhrByUploadId = new Map(); export const thunks = { @@ -29,24 +34,26 @@ export const thunks = { } xhrByUploadId.clear(); - fileByUploadId.clear(); + fileToUploadByUploadId.clear(); dispatch(actions.loaded({ presignedPost })); }, uploadFiles: - (params: { files: readonly File[] }) => + (params: { files: readonly FileToUpload[] }) => async (...args) => { const { files } = params; const [dispatch] = args; - const uploads = files.map(file => { + const uploads = files.map(fileToUpload => { + const { file, relativePathSegments } = fileToUpload; const uploadId = `${Date.now()}-${Math.random()}`; + const filePath = [...relativePathSegments, file.name].join("/"); - fileByUploadId.set(uploadId, file); + fileToUploadByUploadId.set(uploadId, fileToUpload); return { uploadId, - fileName: file.name, + fileName: filePath, sizeInBytes: file.size }; }); @@ -78,7 +85,7 @@ export const thunks = { assert(upload !== undefined); assert(upload.status === "failed"); - assert(fileByUploadId.has(uploadId)); + assert(fileToUploadByUploadId.has(uploadId)); dispatch(actions.uploadRetried({ uploadId })); @@ -93,8 +100,11 @@ export const privateThunks = { const { uploadId } = params; const [dispatch, getState] = args; - const file = fileByUploadId.get(uploadId); - assert(file !== undefined); + const fileToUpload = fileToUploadByUploadId.get(uploadId); + assert(fileToUpload !== undefined); + + const { file, relativePathSegments } = fileToUpload; + const filePath = [...relativePathSegments, file.name].join("/"); const presignedPost = privateSelectors.presignedPost(getState()); @@ -113,7 +123,12 @@ export const privateThunks = { const formData = new FormData(); for (const [name, value] of Object.entries(presignedPost.fields)) { - formData.append(name, value); + formData.append( + name, + name === "key" + ? value.replace("${filename}", () => filePath) + : value + ); } // S3 requires the file to be the last field in a POST form. @@ -132,7 +147,7 @@ export const privateThunks = { switch (params.status) { case "success": - fileByUploadId.delete(uploadId); + fileToUploadByUploadId.delete(uploadId); dispatch(actions.uploadSucceeded({ uploadId })); break; case "failed": @@ -145,7 +160,7 @@ export const privateThunks = { ); break; case "canceled": - fileByUploadId.delete(uploadId); + fileToUploadByUploadId.delete(uploadId); dispatch(actions.uploadCanceled({ uploadId })); break; } diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index 3a99e4a78..93ad8d44e 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -32,7 +32,7 @@ export type ComponentKey = | import("ui/shared/codex/S3FileRequestCreationDialog").I18n | import("ui/pages/s3Explorer/dialogs/S3ProfileDialog").I18n | import("ui/pages/s3Explorer/Page").I18n - | import("ui/pages/s3FileRequest/Page").I18n + | import("ui/shared/codex/S3FileRequest/S3FileRequest").I18n | import("ui/shared/codex/S3Bookmarks/S3BookmarksBar").I18n | import("ui/shared/codex/S3Bookmarks/S3BookmarksBarItem/S3BookmarksBarItem").S3BookmarkItemI18n | import("ui/shared/codex/S3Bookmarks/S3BookmarksEntryPointItem").I18n diff --git a/web/src/ui/pages/s3FileRequest/Page.tsx b/web/src/ui/pages/s3FileRequest/Page.tsx index 5e63e435e..362273e50 100644 --- a/web/src/ui/pages/s3FileRequest/Page.tsx +++ b/web/src/ui/pages/s3FileRequest/Page.tsx @@ -1,32 +1,15 @@ -import { getRoute } from "ui/routes"; -import { routeGroup } from "./route"; +import { getCore, getCoreSync, useCoreState } from "core"; import { assert } from "tsafe"; +import { getRoute } from "ui/routes"; +import { S3FileRequest } from "ui/shared/codex/S3FileRequest/S3FileRequest"; import { withLoader } from "ui/tools/withLoader"; -import { getCore, getCoreSync, useCoreState } from "core"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ChangeEvent, - type DragEvent -} from "react"; -import { tss } from "tss"; -import { alpha } from "@mui/material/styles"; -import { Icon } from "onyxia-ui/Icon"; -import { IconButton } from "onyxia-ui/IconButton"; -import { Button } from "onyxia-ui/Button"; -import { Text } from "onyxia-ui/Text"; -import { getIconUrlByName } from "lazy-icons"; -import bytes from "bytes"; -import { getS3ObjectIconUrl } from "ui/shared/codex/getS3ObjectIconUrl"; -import { declareComponentKeys, useLang, useTranslation } from "ui/i18n"; +import { routeGroup } from "./route"; const Page = withLoader({ loader, - Component: S3FileRequest + Component: S3FileRequestPage }); + export default Page; async function loader() { @@ -40,10 +23,7 @@ async function loader() { }); } -function S3FileRequest() { - const { classes, cx } = useStyles(); - const { t } = useTranslation({ S3FileRequest }); - const { lang } = useLang(); +function S3FileRequestPage() { const { expirationTime, uploads } = useCoreState( "s3FileRequestUiController", "mainView" @@ -52,690 +32,17 @@ function S3FileRequest() { functions: { s3FileRequestUiController } } = getCoreSync(); - const fileInputRef = useRef(null); - const dragDepthRef = useRef(0); - const [isDragActive, setIsDragActive] = useState(false); - const now = useNowUntil({ expirationTime }); - - const isExpired = !Number.isFinite(expirationTime) || now >= expirationTime; - - const formattedExpirationTime = useMemo(() => { - if (!Number.isFinite(expirationTime)) { - return ""; - } - - return new Intl.DateTimeFormat(lang, { - dateStyle: "medium", - timeStyle: "short" - }).format(new Date(expirationTime)); - }, [expirationTime, lang]); - - useEffect(() => { - if (!isExpired) { - return; - } - - dragDepthRef.current = 0; - setIsDragActive(false); - }, [isExpired]); - - const uploadFiles = useCallback( - (files: readonly File[]) => { - if (isExpired || files.length === 0) { - return; - } - - void s3FileRequestUiController.uploadFiles({ files }); - }, - [isExpired, s3FileRequestUiController] - ); - - const onFileInputChange = (event: ChangeEvent) => { - uploadFiles(Array.from(event.target.files ?? [])); - - // Allow selecting the same file again after the upload has completed. - event.target.value = ""; - }; - - const onDragEnter = (event: DragEvent) => { - if (isExpired || !getHasDraggedFiles(event)) { - return; - } - - event.preventDefault(); - dragDepthRef.current += 1; - setIsDragActive(true); - }; - - const onDragOver = (event: DragEvent) => { - if (isExpired || !getHasDraggedFiles(event)) { - return; - } - - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - }; - - const onDragLeave = (event: DragEvent) => { - if (!getHasDraggedFiles(event)) { - return; - } - - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); - - if (dragDepthRef.current === 0) { - setIsDragActive(false); - } - }; - - const onDrop = (event: DragEvent) => { - if (!getHasDraggedFiles(event)) { - return; - } - - event.preventDefault(); - dragDepthRef.current = 0; - setIsDragActive(false); - - uploadFiles(Array.from(event.dataTransfer.files)); - }; - - const hasUploads = uploads.length !== 0; - const areAllUploadsSuccessful = - hasUploads && uploads.every(upload => upload.status === "success"); - return ( -
-
-
-
- -
- - {t("page title")} - - - {t("page description")} - -
-
- -
- -
-
- {isExpired - ? t("link expired") - : t("expires on", { - date: formattedExpirationTime - })} -
- {isExpired && ( -
- {t("link expired description")} -
- )} -
-
- - {!isExpired && ( -
- - -
- {t(isDragActive ? "drop files active" : "drop files")} -
-
- {t("drop files hint")} -
- -
- )} - - {areAllUploadsSuccessful && ( -
- -
-
- {t("all files uploaded")} -
-
- {t("all files uploaded description")} -
-
-
- )} - - {hasUploads && ( -
-
-
- {t("uploads title")} -
-
- {uploads.length} -
-
-
- {uploads.map(upload => { - const uploadPercent = Math.max( - 0, - Math.min(100, upload.uploadPercent) - ); - - return ( -
-
- -
-
-
-
- {upload.fileName} -
-
- {formatSize(upload.sizeInBytes)} -
-
-
- - {upload.status === "uploading" - ? t("uploading", { - percent: - Math.round( - uploadPercent - ) - }) - : upload.status === "success" - ? t("uploaded") - : t("upload failed")} - - {upload.errorMessage !== - undefined && ( - - {upload.errorMessage} - - )} -
- {upload.status === "uploading" && ( -
-
-
- )} -
- {upload.status === "uploading" ? ( - - s3FileRequestUiController.cancelUpload( - { - uploadId: upload.uploadId - } - ) - } - /> - ) : upload.status === "failed" ? ( - - void s3FileRequestUiController.retryUpload( - { - uploadId: upload.uploadId - } - ) - } - /> - ) : ( -
- -
- )} -
- ); - })} -
-
- )} - -
- - {t("privacy note")} -
-
-
-
+ { + void s3FileRequestUiController.uploadFiles({ files }); + }} + onCancelUpload={s3FileRequestUiController.cancelUpload} + onRetryUpload={({ uploadId }) => { + void s3FileRequestUiController.retryUpload({ uploadId }); + }} + /> ); } - -function useNowUntil(params: { expirationTime: number }): number { - const { expirationTime } = params; - const [now, setNow] = useState(Date.now()); - - useEffect(() => { - if (!Number.isFinite(expirationTime) || now >= expirationTime) { - return; - } - - const timeoutId = window.setTimeout( - () => setNow(Date.now()), - Math.min(30_000, expirationTime - now + 50) - ); - - return () => window.clearTimeout(timeoutId); - }, [expirationTime, now]); - - return now; -} - -function getHasDraggedFiles(event: DragEvent): boolean { - return Array.from(event.dataTransfer.types).includes("Files"); -} - -function formatSize(sizeInBytes: number): string { - return bytes(sizeInBytes) ?? `${sizeInBytes}B`; -} - -const useStyles = tss.withName({ S3FileRequest }).create(({ theme }) => ({ - root: { - height: "100%", - overflow: "auto", - boxSizing: "border-box", - backgroundColor: theme.colors.useCases.surfaces.background, - padding: `${theme.spacing(4)}px ${theme.spacing(3)}px ${theme.spacing(8)}px` - }, - content: { - width: "100%", - maxWidth: 780, - margin: "0 auto" - }, - card: { - display: "flex", - flexDirection: "column", - gap: theme.spacing(3), - padding: theme.spacing(4), - borderRadius: 24, - border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, - backgroundColor: theme.colors.useCases.surfaces.surface1, - boxShadow: theme.shadows[3], - "@media (max-width: 640px)": { - padding: theme.spacing(2.5), - borderRadius: 18 - } - }, - header: { - display: "flex", - alignItems: "flex-start", - gap: theme.spacing(2.5), - "@media (max-width: 520px)": { - flexDirection: "column" - } - }, - heroIcon: { - width: 64, - height: 64, - borderRadius: 18, - flexShrink: 0, - display: "flex", - alignItems: "center", - justifyContent: "center", - color: theme.colors.useCases.typography.textFocus, - backgroundColor: alpha(theme.colors.useCases.typography.textFocus, 0.1) - }, - headerText: { - minWidth: 0, - display: "flex", - flexDirection: "column", - gap: theme.spacing(1) - }, - title: { - margin: 0, - color: theme.colors.useCases.typography.textPrimary - }, - description: { - color: theme.colors.useCases.typography.textSecondary, - lineHeight: 1.6, - maxWidth: 650 - }, - expiration: { - display: "flex", - alignItems: "flex-start", - gap: theme.spacing(1.5), - padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px`, - borderRadius: 12, - color: theme.colors.useCases.typography.textSecondary, - backgroundColor: theme.colors.useCases.surfaces.background, - border: `1px solid ${theme.colors.useCases.surfaces.surface2}` - }, - expirationExpired: { - color: theme.colors.useCases.alertSeverity.error.main, - borderColor: alpha(theme.colors.useCases.alertSeverity.error.main, 0.35), - backgroundColor: theme.colors.useCases.alertSeverity.error.background - }, - expirationTitle: { - ...theme.typography.variants["label 1"].style - }, - expirationDescription: { - ...theme.typography.variants["body 2"].style, - marginTop: theme.spacing(0.5) - }, - dropZone: { - minHeight: 260, - boxSizing: "border-box", - borderRadius: 18, - border: `2px dashed ${alpha(theme.colors.useCases.typography.textFocus, 0.38)}`, - backgroundColor: alpha(theme.colors.useCases.typography.textFocus, 0.035), - display: "flex", - flexDirection: "column", - alignItems: "center", - justifyContent: "center", - textAlign: "center", - gap: theme.spacing(1.25), - padding: theme.spacing(4), - transition: - "border-color 160ms ease, background-color 160ms ease, transform 160ms ease" - }, - dropZoneActive: { - borderColor: theme.colors.useCases.typography.textFocus, - backgroundColor: alpha(theme.colors.useCases.typography.textFocus, 0.1), - transform: "scale(1.006)" - }, - dropZoneIcon: { - width: 58, - height: 58, - borderRadius: 9999, - display: "flex", - alignItems: "center", - justifyContent: "center", - marginBottom: theme.spacing(0.5), - color: theme.colors.useCases.typography.textFocus, - backgroundColor: theme.colors.useCases.surfaces.surface1, - boxShadow: theme.shadows[2] - }, - dropZoneTitle: { - ...theme.typography.variants["section heading"].style, - color: theme.colors.useCases.typography.textPrimary - }, - dropZoneHint: { - ...theme.typography.variants["body 2"].style, - color: theme.colors.useCases.typography.textSecondary, - marginBottom: theme.spacing(1) - }, - successNotice: { - display: "flex", - alignItems: "flex-start", - gap: theme.spacing(1.5), - padding: theme.spacing(2), - borderRadius: 12, - color: theme.colors.useCases.alertSeverity.success.main, - border: `1px solid ${alpha( - theme.colors.useCases.alertSeverity.success.main, - 0.35 - )}`, - backgroundColor: theme.colors.useCases.alertSeverity.success.background - }, - successNoticeTitle: { - ...theme.typography.variants["label 1"].style - }, - successNoticeDescription: { - ...theme.typography.variants["body 2"].style, - marginTop: theme.spacing(0.5) - }, - uploadsSection: { - display: "flex", - flexDirection: "column", - borderRadius: 16, - overflow: "hidden", - border: `1px solid ${theme.colors.useCases.surfaces.surface2}` - }, - uploadsHeader: { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - padding: `${theme.spacing(1.75)}px ${theme.spacing(2)}px`, - backgroundColor: theme.colors.useCases.surfaces.background - }, - uploadsTitle: { - ...theme.typography.variants["label 1"].style, - color: theme.colors.useCases.typography.textPrimary - }, - uploadsCount: { - ...theme.typography.variants["caption"].style, - minWidth: 26, - height: 26, - borderRadius: 9999, - display: "flex", - alignItems: "center", - justifyContent: "center", - color: theme.colors.useCases.typography.textSecondary, - backgroundColor: theme.colors.useCases.surfaces.surface2 - }, - uploadsList: { - display: "flex", - flexDirection: "column" - }, - uploadItem: { - display: "flex", - alignItems: "center", - gap: theme.spacing(1.5), - minWidth: 0, - padding: theme.spacing(2), - backgroundColor: theme.colors.useCases.surfaces.surface1, - "&:not(:last-child)": { - borderBottom: `1px solid ${theme.colors.useCases.surfaces.surface2}` - } - }, - fileIcon: { - width: 42, - height: 42, - borderRadius: 11, - flexShrink: 0, - display: "flex", - alignItems: "center", - justifyContent: "center", - color: theme.colors.useCases.typography.textPrimary, - backgroundColor: theme.colors.useCases.surfaces.surface2 - }, - uploadItemBody: { - minWidth: 0, - flex: 1, - display: "flex", - flexDirection: "column", - gap: theme.spacing(0.75) - }, - fileNameRow: { - minWidth: 0, - display: "flex", - alignItems: "baseline", - gap: theme.spacing(1.5) - }, - fileName: { - ...theme.typography.variants["label 1"].style, - minWidth: 0, - flex: 1, - overflow: "hidden", - whiteSpace: "nowrap", - textOverflow: "ellipsis", - color: theme.colors.useCases.typography.textPrimary - }, - fileSize: { - ...theme.typography.variants["caption"].style, - flexShrink: 0, - color: theme.colors.useCases.typography.textSecondary - }, - statusRow: { - minWidth: 0, - display: "flex", - alignItems: "baseline", - gap: theme.spacing(1) - }, - status: { - ...theme.typography.variants["caption"].style, - flexShrink: 0, - color: theme.colors.useCases.typography.textSecondary - }, - statusSuccess: { - color: theme.colors.useCases.alertSeverity.success.main - }, - statusError: { - color: theme.colors.useCases.alertSeverity.error.main - }, - errorMessage: { - ...theme.typography.variants["caption"].style, - minWidth: 0, - overflow: "hidden", - whiteSpace: "nowrap", - textOverflow: "ellipsis", - color: theme.colors.useCases.typography.textSecondary - }, - progressTrack: { - width: "100%", - height: 4, - overflow: "hidden", - borderRadius: 9999, - backgroundColor: theme.colors.useCases.surfaces.surface3 - }, - progressFill: { - height: "100%", - borderRadius: 9999, - backgroundColor: theme.colors.useCases.typography.textFocus, - transition: "width 160ms ease" - }, - uploadAction: { - flexShrink: 0 - }, - uploadSuccessIcon: { - width: 32, - height: 32, - borderRadius: 9999, - flexShrink: 0, - display: "flex", - alignItems: "center", - justifyContent: "center", - color: theme.colors.useCases.alertSeverity.success.main - }, - privacyNote: { - display: "flex", - alignItems: "center", - justifyContent: "center", - gap: theme.spacing(1), - textAlign: "center", - color: theme.colors.useCases.typography.textSecondary, - ...theme.typography.variants["caption"].style - } -})); - -const { i18n } = declareComponentKeys< - | "page title" - | "page description" - | { K: "expires on"; P: { date: string }; R: string } - | "link expired" - | "link expired description" - | "drop files" - | "drop files active" - | "drop files hint" - | "choose files" - | "all files uploaded" - | "all files uploaded description" - | "uploads title" - | { K: "uploading"; P: { percent: number }; R: string } - | "uploaded" - | "upload failed" - | "cancel upload" - | "retry upload" - | "privacy note" ->()({ S3FileRequest }); -export type I18n = typeof i18n; diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx index 21e0882d1..c3142dd21 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx @@ -44,6 +44,12 @@ import { copyToClipboard } from "ui/tools/copyToClipboard"; import { useFormattedRelativeDate } from "ui/shared/formattedDate"; import { getS3ObjectIconUrl } from "ui/shared/codex/getS3ObjectIconUrl"; import type { S3Client } from "core/ports/S3Client"; +import { + getFilesToUploadFromDataTransfer, + getFilesToUploadFromFiles, + getHasDraggedFiles, + type FileToUpload +} from "ui/shared/codex/getFilesToUploadFromDataTransfer"; export type S3ExplorerMainViewProps = { className?: string; @@ -513,17 +519,17 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { const items = Array.from(event.dataTransfer.items); const files = Array.from(event.dataTransfer.files); - const objectsToUpload = await getObjectsToUploadFromDroppedItems({ + const filesToUpload = await getFilesToUploadFromDataTransfer({ items, files }); - if (objectsToUpload.length === 0) { + if (filesToUpload.length === 0) { return; } onPutObjects({ - files: objectsToUpload + files: filesToUpload.map(getObjectToUpload) }); }); @@ -1928,38 +1934,6 @@ type ObjectToUpload = Parameters< S3ExplorerMainViewProps["onPutObjects"] >[0]["files"][number]; -type DataTransferItemWithWebkitGetAsEntry = DataTransferItem & { - webkitGetAsEntry?: () => FileSystemEntryLike | null; -}; - -type FileSystemEntryLike = { - readonly isFile: boolean; - readonly isDirectory: boolean; - readonly name: string; -}; - -type FileSystemFileEntryLike = FileSystemEntryLike & { - readonly isFile: true; - readonly isDirectory: false; - file: ( - successCallback: (file: File) => void, - errorCallback?: (error: DOMException) => void - ) => void; -}; - -type FileSystemDirectoryEntryLike = FileSystemEntryLike & { - readonly isFile: false; - readonly isDirectory: true; - createReader: () => FileSystemDirectoryReaderLike; -}; - -type FileSystemDirectoryReaderLike = { - readEntries: ( - successCallback: (entries: FileSystemEntryLike[]) => void, - errorCallback?: (error: DOMException) => void - ) => void; -}; - function getItemKey(item: S3ExplorerMainViewProps.Item): string { return stringifyS3Uri(item.s3Uri); } @@ -2009,138 +1983,15 @@ function tryApplyPendingPreSelection(params: { } function getObjectsToUploadFromFiles(files: readonly File[]): ObjectToUpload[] { - return files.map(file => { - const relativePathSegments = file.webkitRelativePath - .split("/") - .filter(Boolean) - .slice(0, -1); - - return { - relativePathSegments, - fileBasename: file.name, - blob: file as Blob - }; - }); + return getFilesToUploadFromFiles(files).map(getObjectToUpload); } -function getFileSystemEntry(item: DataTransferItem): FileSystemEntryLike | null { - return (item as DataTransferItemWithWebkitGetAsEntry).webkitGetAsEntry?.() ?? null; -} - -function getHasDraggedFiles(dataTransfer: DataTransfer): boolean { - if (dataTransfer.items.length !== 0) { - return Array.from(dataTransfer.items).some(item => item.kind === "file"); - } - - return dataTransfer.types.includes("Files"); -} - -function readFileEntry(entry: FileSystemFileEntryLike): Promise { - return new Promise((resolve, reject) => entry.file(resolve, error => reject(error))); -} - -function readDirectoryEntries( - entry: FileSystemDirectoryEntryLike -): Promise { - const reader = entry.createReader(); - const entries: FileSystemEntryLike[] = []; - - return new Promise((resolve, reject) => { - const readNextBatch = () => { - reader.readEntries( - batch => { - if (batch.length === 0) { - resolve(entries); - return; - } - - entries.push(...batch); - readNextBatch(); - }, - error => reject(error) - ); - }; - - readNextBatch(); - }); -} - -async function getObjectsToUploadFromFileSystemEntry(params: { - entry: FileSystemEntryLike; - relativePathSegments: string[]; -}): Promise { - const { entry, relativePathSegments } = params; - - if (entry.isFile) { - const file = await readFileEntry(entry as FileSystemFileEntryLike); - - return [ - { - relativePathSegments: [...relativePathSegments], - fileBasename: file.name, - blob: file - } - ]; - } - - const directoryEntry = entry as FileSystemDirectoryEntryLike; - const childEntries = await readDirectoryEntries(directoryEntry); - const childRelativePathSegments = [...relativePathSegments, directoryEntry.name]; - - return ( - await Promise.all( - childEntries.map(childEntry => - getObjectsToUploadFromFileSystemEntry({ - entry: childEntry, - relativePathSegments: childRelativePathSegments - }) - ) - ) - ).flat(); -} - -async function getObjectsToUploadFromDroppedItems(params: { - items: readonly DataTransferItem[]; - files: readonly File[]; -}): Promise { - const { items, files } = params; - const fileItems = items.filter( - (item): item is DataTransferItem => item.kind === "file" - ); - const itemsWithEntries = fileItems.map(item => ({ - item, - entry: getFileSystemEntry(item) - })); - const hasFileSystemEntrySupport = itemsWithEntries.some( - ({ entry }) => entry !== null - ); - - const droppedObjects = ( - await Promise.all( - itemsWithEntries.map(async ({ item, entry }) => { - if (entry !== null) { - return getObjectsToUploadFromFileSystemEntry({ - entry, - relativePathSegments: [] - }); - } - - const file = item.getAsFile(); - - if (file === null) { - return []; - } - - return getObjectsToUploadFromFiles([file]); - }) - ) - ).flat(); - - if (hasFileSystemEntrySupport) { - return droppedObjects; - } - - return getObjectsToUploadFromFiles(files); +function getObjectToUpload(fileToUpload: FileToUpload): ObjectToUpload { + return { + relativePathSegments: fileToUpload.relativePathSegments, + fileBasename: fileToUpload.file.name, + blob: fileToUpload.file + }; } function getFormattedSize(size: number): string { diff --git a/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.spec.md b/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.spec.md new file mode 100644 index 000000000..528bd1ea7 --- /dev/null +++ b/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.spec.md @@ -0,0 +1,109 @@ +# Intent + +`S3FileRequest` is the self-contained presentation component for a public S3 file-request page. It lets a visitor select files or drop files and directories, displays the request expiry, and renders controlled upload progress. + +The component does not load a request, know about routes, read application core state, or execute upload thunks. All durable state and side effects are supplied through props. + +# Props contract + +```ts +type FileToUpload = { + file: File; + relativePathSegments: string[]; +}; + +type S3FileRequestProps = { + className?: string; + expirationTime: number; + uploads: readonly { + uploadId: string; + fileName: string; + sizeInBytes: number; + status: "uploading" | "success" | "failed"; + uploadPercent: number; + errorMessage: string | undefined; + }[]; + onUploadFiles: (params: { files: readonly FileToUpload[] }) => void; + onCancelUpload: (params: { uploadId: string }) => void; + onRetryUpload: (params: { uploadId: string }) => void; +}; +``` + +`fileName` is the display path relative to the requested S3 prefix. For a folder upload it includes directory segments, for example `project/docs/readme.md`. + +# Ownership boundary + +The component owns only transient view behavior: + +- hidden file input activation +- drag depth and active-drop styling +- recursive extraction of files from a dropped directory +- expiry clock updates and localized date formatting + +The caller owns: + +- request loading and validation +- upload state and progress +- network requests +- cancellation and retry effects +- error-message creation + +# File and folder selection + +- The file picker accepts multiple files. +- Selecting the same file again must work, so the input value is reset after every selection. +- A drop is accepted only when it contains file-kind data. +- When the browser exposes `webkitGetAsEntry`, dropped directory entries are traversed recursively. +- Every file from a dropped directory includes the top-level dropped folder in `relativePathSegments`. +- All batches returned by a directory reader are consumed; a directory reader may return only part of a large directory per call. +- When entry traversal is unavailable, the component falls back to `DataTransfer.files` and `File.webkitRelativePath`. +- Empty directories do not emit upload intents because S3 stores objects rather than directories. +- A single drop invokes `onUploadFiles` once with the complete flattened set of files. + +Example payload for a dropped `project` directory: + +```ts +onUploadFiles({ + files: [ + { + file: readmeFile, + relativePathSegments: ["project", "docs"] + }, + { + file: logoFile, + relativePathSegments: ["project", "assets"] + } + ] +}); +``` + +# Expiration + +- An invalid or elapsed `expirationTime` is treated as expired. +- Before expiry, the localized expiration date and upload drop zone are shown. +- After expiry, the drop zone is removed and an explanatory expired state is shown. +- If expiry occurs while dragging, the active drag state is cleared. +- No upload intent is emitted after expiry. + +# Upload rendering + +- Upload progress is clamped to the range 0–100 for display. +- Uploading rows show progress and call `onCancelUpload` from their action. +- Failed rows show their error and call `onRetryUpload` from their action. +- Successful rows show a success marker. +- When every displayed upload is successful, an aggregate success notice is shown. +- Folder paths are preserved in the displayed filename and tooltip. + +# Accessibility + +- The page title is an `h1`. +- Decorative icons are hidden from assistive technology. +- Upload progress uses `role="progressbar"` with min, max, and current values. +- Cancel and retry controls have translated accessible labels. +- The aggregate success message uses `role="status"`. + +# Layout + +- The root accepts `className`, with the caller class taking precedence through class composition. +- The content is centered in a responsive card. +- Long paths and errors are truncated visually while their full value remains available as a title. diff --git a/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.stories.tsx b/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.stories.tsx new file mode 100644 index 000000000..50b7edde3 --- /dev/null +++ b/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.stories.tsx @@ -0,0 +1,95 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { action } from "@storybook/addon-actions"; +import { S3FileRequest, type S3FileRequestProps } from "./S3FileRequest"; + +const meta = { + title: "Shared/S3FileRequest", + component: S3FileRequest +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const baseArgs: S3FileRequestProps = { + expirationTime: Date.now() + 24 * 60 * 60 * 1_000, + uploads: [], + onUploadFiles: action("uploadFiles"), + onCancelUpload: action("cancelUpload"), + onRetryUpload: action("retryUpload") +}; + +export const Ready: Story = { + args: baseArgs +}; + +export const FolderUploading: Story = { + args: { + ...baseArgs, + uploads: [ + { + uploadId: "upload-1", + fileName: "holiday/photos/beach.jpg", + sizeInBytes: 3_145_728, + status: "uploading", + uploadPercent: 64, + errorMessage: undefined + }, + { + uploadId: "upload-2", + fileName: "holiday/notes.txt", + sizeInBytes: 1_284, + status: "uploading", + uploadPercent: 18, + errorMessage: undefined + } + ] + } +}; + +export const MixedResults: Story = { + args: { + ...baseArgs, + uploads: [ + { + uploadId: "upload-1", + fileName: "report.pdf", + sizeInBytes: 2_450_000, + status: "success", + uploadPercent: 100, + errorMessage: undefined + }, + { + uploadId: "upload-2", + fileName: "archive/data.csv", + sizeInBytes: 8_900_000, + status: "failed", + uploadPercent: 37, + errorMessage: "The upload failed." + } + ] + } +}; + +export const AllUploaded: Story = { + args: { + ...baseArgs, + uploads: [ + { + uploadId: "upload-1", + fileName: "project/readme.md", + sizeInBytes: 4_096, + status: "success", + uploadPercent: 100, + errorMessage: undefined + } + ] + } +}; + +export const Expired: Story = { + args: { + ...baseArgs, + expirationTime: Date.now() - 60_000 + } +}; diff --git a/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.tsx b/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.tsx new file mode 100644 index 000000000..d179696bc --- /dev/null +++ b/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.tsx @@ -0,0 +1,751 @@ +import { + useEffect, + useMemo, + useReducer, + useRef, + type ChangeEvent, + type DragEvent +} from "react"; +import { tss } from "tss"; +import { alpha } from "@mui/material/styles"; +import { Icon } from "onyxia-ui/Icon"; +import { IconButton } from "onyxia-ui/IconButton"; +import { Button } from "onyxia-ui/Button"; +import { Text } from "onyxia-ui/Text"; +import { getIconUrlByName } from "lazy-icons"; +import bytes from "bytes"; +import { getS3ObjectIconUrl } from "ui/shared/codex/getS3ObjectIconUrl"; +import { declareComponentKeys, useLang, useTranslation } from "ui/i18n"; +import { + getFilesToUploadFromDataTransfer, + getFilesToUploadFromFiles, + getHasDraggedFiles +} from "ui/shared/codex/getFilesToUploadFromDataTransfer"; + +export type S3FileRequestProps = { + className?: string; + expirationTime: number; + uploads: readonly S3FileRequestProps.Upload[]; + onUploadFiles: (params: { + files: readonly S3FileRequestProps.FileToUpload[]; + }) => void; + onCancelUpload: (params: { uploadId: string }) => void; + onRetryUpload: (params: { uploadId: string }) => void; +}; + +export namespace S3FileRequestProps { + export type FileToUpload = { + file: File; + relativePathSegments: string[]; + }; + + export type Upload = { + uploadId: string; + fileName: string; + sizeInBytes: number; + status: "uploading" | "success" | "failed"; + uploadPercent: number; + errorMessage: string | undefined; + }; +} + +export function S3FileRequest(props: S3FileRequestProps) { + const { + className, + expirationTime, + uploads, + onUploadFiles, + onCancelUpload, + onRetryUpload + } = props; + const { classes, cx } = useStyles(); + const { t } = useTranslation({ S3FileRequest }); + const { lang } = useLang(); + + const fileInputRef = useRef(null); + const dragDepthRef = useRef(0); + const [isDragActive, setIsDragActive] = useReducer( + (_isDragActive: boolean, nextIsDragActive: boolean) => nextIsDragActive, + false + ); + const now = useNowUntil({ expirationTime }); + + const isExpired = !Number.isFinite(expirationTime) || now >= expirationTime; + + const formattedExpirationTime = useMemo(() => { + if (!Number.isFinite(expirationTime)) { + return ""; + } + + return new Intl.DateTimeFormat(lang, { + dateStyle: "medium", + timeStyle: "short" + }).format(new Date(expirationTime)); + }, [expirationTime, lang]); + + useEffect(() => { + if (!isExpired) { + return; + } + + dragDepthRef.current = 0; + setIsDragActive(false); + }, [isExpired]); + + const uploadFiles = (files: readonly S3FileRequestProps.FileToUpload[]) => { + if (isExpired || files.length === 0) { + return; + } + + onUploadFiles({ files }); + }; + + const onFileInputChange = (event: ChangeEvent) => { + uploadFiles(getFilesToUploadFromFiles(Array.from(event.target.files ?? []))); + + // Allow selecting the same file again after the upload has completed. + event.target.value = ""; + }; + + const onDragEnter = (event: DragEvent) => { + if (isExpired || !getHasDraggedFiles(event.dataTransfer)) { + return; + } + + event.preventDefault(); + dragDepthRef.current += 1; + setIsDragActive(true); + }; + + const onDragOver = (event: DragEvent) => { + if (isExpired || !getHasDraggedFiles(event.dataTransfer)) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }; + + const onDragLeave = (event: DragEvent) => { + if (!getHasDraggedFiles(event.dataTransfer)) { + return; + } + + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + + if (dragDepthRef.current === 0) { + setIsDragActive(false); + } + }; + + const onDrop = async (event: DragEvent) => { + if (!getHasDraggedFiles(event.dataTransfer)) { + return; + } + + event.preventDefault(); + dragDepthRef.current = 0; + setIsDragActive(false); + + const items = Array.from(event.dataTransfer.items); + const files = Array.from(event.dataTransfer.files); + + uploadFiles( + await getFilesToUploadFromDataTransfer({ + items, + files + }) + ); + }; + + const hasUploads = uploads.length !== 0; + const areAllUploadsSuccessful = + hasUploads && uploads.every(upload => upload.status === "success"); + + return ( +
+
+
+
+ +
+ + {t("page title")} + + + {t("page description")} + +
+
+ +
+ +
+
+ {isExpired + ? t("link expired") + : t("expires on", { + date: formattedExpirationTime + })} +
+ {isExpired && ( +
+ {t("link expired description")} +
+ )} +
+
+ + {!isExpired && ( +
+ + +
+ {t(isDragActive ? "drop files active" : "drop files")} +
+
+ {t("drop files hint")} +
+ +
+ )} + + {areAllUploadsSuccessful && ( +
+ +
+
+ {t("all files uploaded")} +
+
+ {t("all files uploaded description")} +
+
+
+ )} + + {hasUploads && ( +
+
+
+ {t("uploads title")} +
+
+ {uploads.length} +
+
+
+ {uploads.map(upload => { + const uploadPercent = Math.max( + 0, + Math.min(100, upload.uploadPercent) + ); + + return ( +
+
+ +
+
+
+
+ {upload.fileName} +
+
+ {formatSize(upload.sizeInBytes)} +
+
+
+ + {upload.status === "uploading" + ? t("uploading", { + percent: + Math.round( + uploadPercent + ) + }) + : upload.status === "success" + ? t("uploaded") + : t("upload failed")} + + {upload.errorMessage !== + undefined && ( + + {upload.errorMessage} + + )} +
+ {upload.status === "uploading" && ( +
+
+
+ )} +
+ {upload.status === "uploading" ? ( + + onCancelUpload({ + uploadId: upload.uploadId + }) + } + /> + ) : upload.status === "failed" ? ( + + onRetryUpload({ + uploadId: upload.uploadId + }) + } + /> + ) : ( +
+ +
+ )} +
+ ); + })} +
+
+ )} + +
+ + {t("privacy note")} +
+
+
+
+ ); +} + +function useNowUntil(params: { expirationTime: number }): number { + const { expirationTime } = params; + const [now, refreshNow] = useReducer(() => Date.now(), Date.now()); + + useEffect(() => { + if (!Number.isFinite(expirationTime) || now >= expirationTime) { + return; + } + + const timeoutId = window.setTimeout( + refreshNow, + Math.min(30_000, expirationTime - now + 50) + ); + + return () => window.clearTimeout(timeoutId); + }, [expirationTime, now]); + + return now; +} + +function formatSize(sizeInBytes: number): string { + return bytes(sizeInBytes) ?? `${sizeInBytes}B`; +} + +const useStyles = tss.withName({ S3FileRequest }).create(({ theme }) => ({ + root: { + height: "100%", + overflow: "auto", + boxSizing: "border-box", + backgroundColor: theme.colors.useCases.surfaces.background, + padding: `${theme.spacing(4)}px ${theme.spacing(3)}px ${theme.spacing(8)}px` + }, + content: { + width: "100%", + maxWidth: 780, + margin: "0 auto" + }, + card: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(3), + padding: theme.spacing(4), + borderRadius: 24, + border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + backgroundColor: theme.colors.useCases.surfaces.surface1, + boxShadow: theme.shadows[3], + "@media (max-width: 640px)": { + padding: theme.spacing(2.5), + borderRadius: 18 + } + }, + header: { + display: "flex", + alignItems: "flex-start", + gap: theme.spacing(2.5), + "@media (max-width: 520px)": { + flexDirection: "column" + } + }, + heroIcon: { + width: 64, + height: 64, + borderRadius: 18, + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + color: theme.colors.useCases.typography.textFocus, + backgroundColor: alpha(theme.colors.useCases.typography.textFocus, 0.1) + }, + headerText: { + minWidth: 0, + display: "flex", + flexDirection: "column", + gap: theme.spacing(1) + }, + title: { + margin: 0, + color: theme.colors.useCases.typography.textPrimary + }, + description: { + color: theme.colors.useCases.typography.textSecondary, + lineHeight: 1.6, + maxWidth: 650 + }, + expiration: { + display: "flex", + alignItems: "flex-start", + gap: theme.spacing(1.5), + padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px`, + borderRadius: 12, + color: theme.colors.useCases.typography.textSecondary, + backgroundColor: theme.colors.useCases.surfaces.background, + border: `1px solid ${theme.colors.useCases.surfaces.surface2}` + }, + expirationExpired: { + color: theme.colors.useCases.alertSeverity.error.main, + borderColor: alpha(theme.colors.useCases.alertSeverity.error.main, 0.35), + backgroundColor: theme.colors.useCases.alertSeverity.error.background + }, + expirationTitle: { + ...theme.typography.variants["label 1"].style + }, + expirationDescription: { + ...theme.typography.variants["body 2"].style, + marginTop: theme.spacing(0.5) + }, + dropZone: { + minHeight: 260, + boxSizing: "border-box", + borderRadius: 18, + border: `2px dashed ${alpha(theme.colors.useCases.typography.textFocus, 0.38)}`, + backgroundColor: alpha(theme.colors.useCases.typography.textFocus, 0.035), + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + textAlign: "center", + gap: theme.spacing(1.25), + padding: theme.spacing(4), + transition: + "border-color 160ms ease, background-color 160ms ease, transform 160ms ease" + }, + dropZoneActive: { + borderColor: theme.colors.useCases.typography.textFocus, + backgroundColor: alpha(theme.colors.useCases.typography.textFocus, 0.1), + transform: "scale(1.006)" + }, + dropZoneIcon: { + width: 58, + height: 58, + borderRadius: 9999, + display: "flex", + alignItems: "center", + justifyContent: "center", + marginBottom: theme.spacing(0.5), + color: theme.colors.useCases.typography.textFocus, + backgroundColor: theme.colors.useCases.surfaces.surface1, + boxShadow: theme.shadows[2] + }, + dropZoneTitle: { + ...theme.typography.variants["section heading"].style, + color: theme.colors.useCases.typography.textPrimary + }, + dropZoneHint: { + ...theme.typography.variants["body 2"].style, + color: theme.colors.useCases.typography.textSecondary, + marginBottom: theme.spacing(1) + }, + successNotice: { + display: "flex", + alignItems: "flex-start", + gap: theme.spacing(1.5), + padding: theme.spacing(2), + borderRadius: 12, + color: theme.colors.useCases.alertSeverity.success.main, + border: `1px solid ${alpha( + theme.colors.useCases.alertSeverity.success.main, + 0.35 + )}`, + backgroundColor: theme.colors.useCases.alertSeverity.success.background + }, + successNoticeTitle: { + ...theme.typography.variants["label 1"].style + }, + successNoticeDescription: { + ...theme.typography.variants["body 2"].style, + marginTop: theme.spacing(0.5) + }, + uploadsSection: { + display: "flex", + flexDirection: "column", + borderRadius: 16, + overflow: "hidden", + border: `1px solid ${theme.colors.useCases.surfaces.surface2}` + }, + uploadsHeader: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: `${theme.spacing(1.75)}px ${theme.spacing(2)}px`, + backgroundColor: theme.colors.useCases.surfaces.background + }, + uploadsTitle: { + ...theme.typography.variants["label 1"].style, + color: theme.colors.useCases.typography.textPrimary + }, + uploadsCount: { + ...theme.typography.variants["caption"].style, + minWidth: 26, + height: 26, + borderRadius: 9999, + display: "flex", + alignItems: "center", + justifyContent: "center", + color: theme.colors.useCases.typography.textSecondary, + backgroundColor: theme.colors.useCases.surfaces.surface2 + }, + uploadsList: { + display: "flex", + flexDirection: "column" + }, + uploadItem: { + display: "flex", + alignItems: "center", + gap: theme.spacing(1.5), + minWidth: 0, + padding: theme.spacing(2), + backgroundColor: theme.colors.useCases.surfaces.surface1, + "&:not(:last-child)": { + borderBottom: `1px solid ${theme.colors.useCases.surfaces.surface2}` + } + }, + fileIcon: { + width: 42, + height: 42, + borderRadius: 11, + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + color: theme.colors.useCases.typography.textPrimary, + backgroundColor: theme.colors.useCases.surfaces.surface2 + }, + uploadItemBody: { + minWidth: 0, + flex: 1, + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.75) + }, + fileNameRow: { + minWidth: 0, + display: "flex", + alignItems: "baseline", + gap: theme.spacing(1.5) + }, + fileName: { + ...theme.typography.variants["label 1"].style, + minWidth: 0, + flex: 1, + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + color: theme.colors.useCases.typography.textPrimary + }, + fileSize: { + ...theme.typography.variants["caption"].style, + flexShrink: 0, + color: theme.colors.useCases.typography.textSecondary + }, + statusRow: { + minWidth: 0, + display: "flex", + alignItems: "baseline", + gap: theme.spacing(1) + }, + status: { + ...theme.typography.variants["caption"].style, + flexShrink: 0, + color: theme.colors.useCases.typography.textSecondary + }, + statusSuccess: { + color: theme.colors.useCases.alertSeverity.success.main + }, + statusError: { + color: theme.colors.useCases.alertSeverity.error.main + }, + errorMessage: { + ...theme.typography.variants["caption"].style, + minWidth: 0, + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + color: theme.colors.useCases.typography.textSecondary + }, + progressTrack: { + width: "100%", + height: 4, + overflow: "hidden", + borderRadius: 9999, + backgroundColor: theme.colors.useCases.surfaces.surface3 + }, + progressFill: { + height: "100%", + borderRadius: 9999, + backgroundColor: theme.colors.useCases.typography.textFocus, + transition: "width 160ms ease" + }, + uploadAction: { + flexShrink: 0 + }, + uploadSuccessIcon: { + width: 32, + height: 32, + borderRadius: 9999, + flexShrink: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + color: theme.colors.useCases.alertSeverity.success.main + }, + privacyNote: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing(1), + textAlign: "center", + color: theme.colors.useCases.typography.textSecondary, + ...theme.typography.variants["caption"].style + } +})); + +const { i18n } = declareComponentKeys< + | "page title" + | "page description" + | { K: "expires on"; P: { date: string }; R: string } + | "link expired" + | "link expired description" + | "drop files" + | "drop files active" + | "drop files hint" + | "choose files" + | "all files uploaded" + | "all files uploaded description" + | "uploads title" + | { K: "uploading"; P: { percent: number }; R: string } + | "uploaded" + | "upload failed" + | "cancel upload" + | "retry upload" + | "privacy note" +>()({ S3FileRequest }); +export type I18n = typeof i18n; diff --git a/web/src/ui/shared/codex/S3FileRequest/index.ts b/web/src/ui/shared/codex/S3FileRequest/index.ts new file mode 100644 index 000000000..c5566c0f9 --- /dev/null +++ b/web/src/ui/shared/codex/S3FileRequest/index.ts @@ -0,0 +1 @@ +export { S3FileRequest, type S3FileRequestProps, type I18n } from "./S3FileRequest"; diff --git a/web/src/ui/shared/codex/getFilesToUploadFromDataTransfer.test.ts b/web/src/ui/shared/codex/getFilesToUploadFromDataTransfer.test.ts new file mode 100644 index 000000000..619e39153 --- /dev/null +++ b/web/src/ui/shared/codex/getFilesToUploadFromDataTransfer.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { + getFilesToUploadFromDataTransfer, + getFilesToUploadFromFiles +} from "./getFilesToUploadFromDataTransfer"; + +type Entry = { + readonly isFile: boolean; + readonly isDirectory: boolean; + readonly name: string; +}; + +describe("getFilesToUploadFromDataTransfer", () => { + it("recursively reads every directory batch and preserves the root folder", async () => { + const cover = createFile("cover.png"); + const notes = createFile("notes.txt"); + const rootEntry = createDirectoryEntry("project", [ + [ + createFileEntry(cover), + createDirectoryEntry("docs", [[createFileEntry(notes)]]) + ], + [] + ]); + + const files = await getFilesToUploadFromDataTransfer({ + items: [ + { + kind: "file", + webkitGetAsEntry: () => rootEntry + } as unknown as DataTransferItem + ], + files: [] + }); + + expect(files).toEqual([ + { file: cover, relativePathSegments: ["project"] }, + { file: notes, relativePathSegments: ["project", "docs"] } + ]); + }); + + it("falls back to webkitRelativePath when entry traversal is unavailable", async () => { + const file = createFile("notes.txt", "project/docs/notes.txt"); + + const files = await getFilesToUploadFromDataTransfer({ + items: [ + { + kind: "file", + getAsFile: () => file + } as unknown as DataTransferItem + ], + files: [file] + }); + + expect(files).toEqual([{ file, relativePathSegments: ["project", "docs"] }]); + }); +}); + +describe("getFilesToUploadFromFiles", () => { + it("uses an empty relative path for regular file selections", () => { + const file = createFile("notes.txt"); + + expect(getFilesToUploadFromFiles([file])).toEqual([ + { file, relativePathSegments: [] } + ]); + }); +}); + +function createFile(name: string, webkitRelativePath = ""): File { + return { + name, + size: 1, + webkitRelativePath + } as File; +} + +function createFileEntry(file: File): Entry { + return { + isFile: true, + isDirectory: false, + name: file.name, + file: (resolve: (file: File) => void) => resolve(file) + } as Entry; +} + +function createDirectoryEntry(name: string, batches: Entry[][]): Entry { + return { + isFile: false, + isDirectory: true, + name, + createReader: () => { + let batchIndex = 0; + + return { + readEntries: (resolve: (entries: Entry[]) => void) => { + resolve(batches[batchIndex++] ?? []); + } + }; + } + } as Entry; +} diff --git a/web/src/ui/shared/codex/getFilesToUploadFromDataTransfer.ts b/web/src/ui/shared/codex/getFilesToUploadFromDataTransfer.ts new file mode 100644 index 000000000..ac73e31b0 --- /dev/null +++ b/web/src/ui/shared/codex/getFilesToUploadFromDataTransfer.ts @@ -0,0 +1,149 @@ +export type FileToUpload = { + file: File; + relativePathSegments: string[]; +}; + +type DataTransferItemWithWebkitGetAsEntry = DataTransferItem & { + webkitGetAsEntry?: () => FileSystemEntryLike | null; +}; + +type FileSystemEntryLike = { + readonly isFile: boolean; + readonly isDirectory: boolean; + readonly name: string; +}; + +type FileSystemFileEntryLike = FileSystemEntryLike & { + readonly isFile: true; + readonly isDirectory: false; + file: ( + successCallback: (file: File) => void, + errorCallback?: (error: DOMException) => void + ) => void; +}; + +type FileSystemDirectoryEntryLike = FileSystemEntryLike & { + readonly isFile: false; + readonly isDirectory: true; + createReader: () => FileSystemDirectoryReaderLike; +}; + +type FileSystemDirectoryReaderLike = { + readEntries: ( + successCallback: (entries: FileSystemEntryLike[]) => void, + errorCallback?: (error: DOMException) => void + ) => void; +}; + +export function getFilesToUploadFromFiles(files: readonly File[]): FileToUpload[] { + return files.map(file => ({ + file, + relativePathSegments: file.webkitRelativePath + .split("/") + .filter(Boolean) + .slice(0, -1) + })); +} + +export function getHasDraggedFiles(dataTransfer: DataTransfer): boolean { + if (dataTransfer.items.length !== 0) { + return Array.from(dataTransfer.items).some(item => item.kind === "file"); + } + + return dataTransfer.types.includes("Files"); +} + +export async function getFilesToUploadFromDataTransfer(params: { + items: readonly DataTransferItem[]; + files: readonly File[]; +}): Promise { + const { items, files } = params; + const fileItems = items.filter(item => item.kind === "file"); + const itemsWithEntries = fileItems.map(item => ({ + item, + entry: getFileSystemEntry(item) + })); + const hasFileSystemEntrySupport = itemsWithEntries.some( + ({ entry }) => entry !== null + ); + + if (!hasFileSystemEntrySupport) { + return getFilesToUploadFromFiles(files); + } + + return ( + await Promise.all( + itemsWithEntries.map(async ({ item, entry }) => { + if (entry !== null) { + return getFilesToUploadFromFileSystemEntry({ + entry, + relativePathSegments: [] + }); + } + + const file = item.getAsFile(); + + return file === null ? [] : getFilesToUploadFromFiles([file]); + }) + ) + ).flat(); +} + +function getFileSystemEntry(item: DataTransferItem): FileSystemEntryLike | null { + return (item as DataTransferItemWithWebkitGetAsEntry).webkitGetAsEntry?.() ?? null; +} + +function readFileEntry(entry: FileSystemFileEntryLike): Promise { + return new Promise((resolve, reject) => entry.file(resolve, reject)); +} + +function readDirectoryEntries( + entry: FileSystemDirectoryEntryLike +): Promise { + const reader = entry.createReader(); + const entries: FileSystemEntryLike[] = []; + + return new Promise((resolve, reject) => { + const readNextBatch = () => { + reader.readEntries(batch => { + if (batch.length === 0) { + resolve(entries); + return; + } + + entries.push(...batch); + readNextBatch(); + }, reject); + }; + + readNextBatch(); + }); +} + +async function getFilesToUploadFromFileSystemEntry(params: { + entry: FileSystemEntryLike; + relativePathSegments: string[]; +}): Promise { + const { entry, relativePathSegments } = params; + + if (entry.isFile) { + const file = await readFileEntry(entry as FileSystemFileEntryLike); + + return [{ file, relativePathSegments: [...relativePathSegments] }]; + } + + const directoryEntry = entry as FileSystemDirectoryEntryLike; + const childEntries = await readDirectoryEntries(directoryEntry); + const childRelativePathSegments = [...relativePathSegments, directoryEntry.name]; + + return ( + await Promise.all( + childEntries.map(childEntry => + getFilesToUploadFromFileSystemEntry({ + entry: childEntry, + relativePathSegments: childRelativePathSegments + }) + ) + ) + ).flat(); +} From e2e5429994e8cb1b455c3a3f5367e19d3038edaa Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 2 Sep 2026 20:01:54 +0200 Subject: [PATCH 13/22] Remove manipulation of legacy AWS S3 endpoint --- .../decoupledLogic/codeSnippets.ts | 9 ++------- .../s3ProfilesDetailsUiController/selectors.ts | 15 +-------------- 2 files changed, 3 insertions(+), 21 deletions(-) diff --git a/web/src/core/usecases/s3ProfilesDetailsUiController/decoupledLogic/codeSnippets.ts b/web/src/core/usecases/s3ProfilesDetailsUiController/decoupledLogic/codeSnippets.ts index 3351cf3fd..5147e7354 100644 --- a/web/src/core/usecases/s3ProfilesDetailsUiController/decoupledLogic/codeSnippets.ts +++ b/web/src/core/usecases/s3ProfilesDetailsUiController/decoupledLogic/codeSnippets.ts @@ -402,7 +402,6 @@ function getDuckDbSnippet(context: SnippetContext): CodeSnippet { } function getRAwsS3Snippet(context: SnippetContext): CodeSnippet { - const awsS3PackageRegion = getAwsS3PackageRegion(context); const useHttps = toRBoolean(context.endpointScheme === "https"); return { @@ -422,7 +421,7 @@ function getRAwsS3Snippet(context: SnippetContext): CodeSnippet { objects <- get_bucket( bucket = bucket, max = 10, - region = ${JSON.stringify(awsS3PackageRegion)}, + region = ${JSON.stringify(context.region)}, use_https = ${useHttps}, key = "", secret = "", @@ -458,7 +457,7 @@ function getRAwsS3Snippet(context: SnippetContext): CodeSnippet { objects <- get_bucket( bucket = bucket, max = 10, - region = ${JSON.stringify(awsS3PackageRegion)}, + region = ${JSON.stringify(context.region)}, use_https = ${useHttps} ) @@ -626,10 +625,6 @@ function toRBoolean(value: boolean): "TRUE" | "FALSE" { return value ? "TRUE" : "FALSE"; } -function getAwsS3PackageRegion(context: SnippetContext): string { - return context.endpointAuthority === "s3.amazonaws.com" ? context.region : ""; -} - function getMinioClientHostUrl(params: { context: SnippetContext; accessCredentials: { diff --git a/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts b/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts index 33ad685f6..6ababdf9b 100644 --- a/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts +++ b/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts @@ -9,7 +9,6 @@ import { } from "./decoupledLogic/codeSnippets"; import * as s3ProfilesManagement from "core/usecases/s3ProfilesManagement"; import { assert } from "tsafe"; -import { parseUrl } from "core/tools/parseUrl"; const state = (rootState: RootState) => rootState[name]; @@ -43,22 +42,10 @@ const mainView = createSelector( (state, s3Profile, availableProfileNames): MainView => { assert(s3Profile !== undefined); - const { region, host, port } = (() => { - const { host, port = 443 } = parseUrl(s3Profile.paramsOfCreateS3Client.url); - - const region = s3Profile.paramsOfCreateS3Client.region; - - return { region, host, port }; - })(); - - const endpointUrl = `${ - host === "s3.amazonaws.com" ? `s3.${region}.amazonaws.com` : host - }${port === 443 ? "" : `:${port}`}`; - return { availableProfileNames, profileName: s3Profile.profileName, - endpointUrl, + endpointUrl: s3Profile.paramsOfCreateS3Client.url, defaultRegion: s3Profile.paramsOfCreateS3Client.region, isReadonly: (() => { switch (s3Profile.origin) { From caba99459231a4257f29f7fcd21c2e5188118db6 Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 2 Sep 2026 20:17:19 +0200 Subject: [PATCH 14/22] Preset AWS region --- .../s3ProfilesCreationUiController/thunks.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/web/src/core/usecases/s3ProfilesCreationUiController/thunks.ts b/web/src/core/usecases/s3ProfilesCreationUiController/thunks.ts index 2d718df7b..20fecd272 100644 --- a/web/src/core/usecases/s3ProfilesCreationUiController/thunks.ts +++ b/web/src/core/usecases/s3ProfilesCreationUiController/thunks.ts @@ -134,6 +134,34 @@ export const thunks = { const [dispatch, getState] = args; dispatch(actions.formValueChanged({ key, value })); + preset_region: { + if (key !== "url") { + break preset_region; + } + + const url = privateSelectors.formattedFormValuesUrl(getState()); + + assert(url !== null); + + if (url === undefined) { + break preset_region; + } + + const match = new URL(url).hostname.match( + /^s3\.([a-z0-9-]+)\.amazonaws\.com(?:\.cn)?$/ + ); + + if (match === null) { + break preset_region; + } + + const region = match[1]; + + assert(region !== undefined); + + dispatch(actions.formValueChanged({ key: "region", value: region })); + } + preset_pathStyleAccess: { if (key !== "url") { break preset_pathStyleAccess; From c931bace57ccd3a75714a50062ae2cc2ec069a3f Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 2 Sep 2026 21:00:43 +0200 Subject: [PATCH 15/22] Stronger check for request files --- .../s3ExplorerUiController/selectors.ts | 7 +- .../getIsKnownS3HttpUrl.test.ts | 252 ++++++++++++++++++ .../decoupledLogic/getIsKnownS3HttpUrl.ts | 233 +++++++++++++++- .../s3FileRequestUiController/thunks.ts | 8 +- 4 files changed, 483 insertions(+), 17 deletions(-) create mode 100644 web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getIsKnownS3HttpUrl.test.ts diff --git a/web/src/core/usecases/s3ExplorerUiController/selectors.ts b/web/src/core/usecases/s3ExplorerUiController/selectors.ts index eb008f638..bcd23bfdb 100644 --- a/web/src/core/usecases/s3ExplorerUiController/selectors.ts +++ b/web/src/core/usecases/s3ExplorerUiController/selectors.ts @@ -11,7 +11,7 @@ import { getIsWithinPrefixThatHasBeenMadePublic } from "./decoupledLogic/bucketP import { type ObjectRendering } from "./decoupledLogic/objectRendering"; import { getPublicAccessActionAndShouldShowShareAction } from "./decoupledLogic/getPublicAccessActionAndShouldShowShareAction"; import { getRootContext } from "core/rootContext"; -import { getIsKnownS3HttpUrl } from "core/usecases/s3FileRequestUiController/decoupledLogic/getIsKnownS3HttpUrl"; +import { getIsKnownS3ServerUrl } from "core/usecases/s3FileRequestUiController/decoupledLogic/getIsKnownS3HttpUrl"; export type RouteParams = { profile?: string; @@ -796,8 +796,9 @@ const isRequestFilesEnabled = createSelector( return false; } - return getIsKnownS3HttpUrl({ - s3HttpUrl: s3Profile.paramsOfCreateS3Client.url, + return getIsKnownS3ServerUrl({ + s3ServerUrl: s3Profile.paramsOfCreateS3Client.url, + pathStyleAccess: s3Profile.paramsOfCreateS3Client.pathStyleAccess, s3Config: getRootContext().s3Config }); } diff --git a/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getIsKnownS3HttpUrl.test.ts b/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getIsKnownS3HttpUrl.test.ts new file mode 100644 index 000000000..95244944c --- /dev/null +++ b/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getIsKnownS3HttpUrl.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, it } from "vitest"; +import type { S3Config } from "core/ports/OnyxiaApi/S3Config"; +import { getIsKnownS3ServerUrl, parsePresignedPostUrl } from "./getIsKnownS3HttpUrl"; + +type Server = { + url: string; + pathStyleAccess: boolean; +}; + +function createS3Config(params: { + servers?: Server[]; + defaultServer?: Server; +}): S3Config { + const { servers = [], defaultServer } = params; + + return { + entries: servers.map( + ({ url, pathStyleAccess }, index): S3Config.Entry => ({ + url, + pathStyleAccess, + region: undefined, + sts: undefined, + anonymousProfileName: `anonymous-${index}`, + bookmarks: [] + }) + ), + defaultValuesOfCreationForm: + defaultServer === undefined + ? undefined + : { + ...defaultServer, + region: undefined + } + }; +} + +describe("parsePresignedPostUrl", () => { + it.each([ + { + name: "path-style URL", + server: { + url: "https://minio.lab.sspcloud.fr", + pathStyleAccess: true + }, + presignedPostUrl: "https://minio.lab.sspcloud.fr/garronej", + expectedS3ServerUrl: "https://minio.lab.sspcloud.fr", + expectedBucket: "garronej" + }, + { + name: "path-style URL with trailing slashes and query parameters", + server: { + url: "https://minio.lab.sspcloud.fr/", + pathStyleAccess: true + }, + presignedPostUrl: "https://minio.lab.sspcloud.fr/garronej/?x-id=PutObject", + expectedS3ServerUrl: "https://minio.lab.sspcloud.fr", + expectedBucket: "garronej" + }, + { + name: "URL with an explicit default port and an encoded bucket name", + server: { + url: "https://minio.lab.sspcloud.fr:443", + pathStyleAccess: true + }, + presignedPostUrl: "https://minio.lab.sspcloud.fr/%67arronej", + expectedS3ServerUrl: "https://minio.lab.sspcloud.fr", + expectedBucket: "garronej" + }, + { + name: "virtual-hosted-style URL", + server: { + url: "https://minio.lab.sspcloud.fr", + pathStyleAccess: false + }, + presignedPostUrl: "https://garronej.minio.lab.sspcloud.fr", + expectedS3ServerUrl: "https://minio.lab.sspcloud.fr", + expectedBucket: "garronej" + }, + { + name: "virtual-hosted-style URL with a dotted bucket", + server: { + url: "https://minio.lab.sspcloud.fr/", + pathStyleAccess: false + }, + presignedPostUrl: "https://my.bucket.minio.lab.sspcloud.fr/", + expectedS3ServerUrl: "https://minio.lab.sspcloud.fr", + expectedBucket: "my.bucket" + }, + { + name: "HTTP path-style URL with a non-default port", + server: { + url: "http://localhost:9000", + pathStyleAccess: true + }, + presignedPostUrl: "http://localhost:9000/local-bucket", + expectedS3ServerUrl: "http://localhost:9000", + expectedBucket: "local-bucket" + }, + { + name: "IPv6 path-style URL", + server: { + url: "http://[2001:db8::1]:9000", + pathStyleAccess: true + }, + presignedPostUrl: "http://[2001:db8::1]:9000/ipv6-bucket", + expectedS3ServerUrl: "http://[2001:db8::1]:9000", + expectedBucket: "ipv6-bucket" + }, + { + name: "path-style endpoint with a path prefix", + server: { + url: "https://gateway.example.com/object-storage/tenant-a/", + pathStyleAccess: true + }, + presignedPostUrl: + "https://gateway.example.com/object-storage/tenant-a/data-bucket/", + expectedS3ServerUrl: "https://gateway.example.com/object-storage/tenant-a", + expectedBucket: "data-bucket" + }, + { + name: "virtual-hosted-style endpoint with a path prefix", + server: { + url: "https://gateway.example.com/object-storage/tenant-a/", + pathStyleAccess: false + }, + presignedPostUrl: + "https://data-bucket.gateway.example.com/object-storage/tenant-a/", + expectedS3ServerUrl: "https://gateway.example.com/object-storage/tenant-a", + expectedBucket: "data-bucket" + }, + { + name: "AWS regional virtual-hosted-style URL", + server: { + url: "https://s3.us-east-1.amazonaws.com", + pathStyleAccess: false + }, + presignedPostUrl: + "https://garronej.s3.us-east-1.amazonaws.com/?x-id=PutObject", + expectedS3ServerUrl: "https://s3.us-east-1.amazonaws.com", + expectedBucket: "garronej" + } + ])("parses a known $name", testCase => { + const result = parsePresignedPostUrl({ + s3Config: createS3Config({ defaultServer: testCase.server }), + presignedPost_url: testCase.presignedPostUrl + }); + + expect(result).toEqual({ + isKnownS3Server: true, + s3ServerUrl: testCase.expectedS3ServerUrl, + bucket: testCase.expectedBucket + }); + }); + + it("uses the most specific matching server hostname", () => { + const result = parsePresignedPostUrl({ + s3Config: createS3Config({ + servers: [ + { url: "https://example.com", pathStyleAccess: false }, + { url: "https://s3.example.com", pathStyleAccess: false } + ] + }), + presignedPost_url: "https://bucket.s3.example.com" + }); + + expect(result).toMatchObject({ + isKnownS3Server: true, + s3ServerUrl: "https://s3.example.com", + bucket: "bucket" + }); + }); + + it.each([ + ["an unknown server", "https://unknown.example.com/bucket"], + ["a hostname-prefix attack", "https://minio.lab.sspcloud.fr.evil.test/bucket"], + ["a user-info hostname attack", "https://minio.lab.sspcloud.fr@evil.test/bucket"], + ["credentials on a known host", "https://user@minio.lab.sspcloud.fr/bucket"], + ["a protocol mismatch", "http://minio.lab.sspcloud.fr/bucket"], + ["a port mismatch", "https://minio.lab.sspcloud.fr:8443/bucket"], + ["the server URL without a bucket", "https://minio.lab.sspcloud.fr"], + ["an object path after the bucket", "https://minio.lab.sspcloud.fr/bucket/key"], + ["an encoded slash in the bucket", "https://minio.lab.sspcloud.fr/a%2Fb"], + ["a malformed URL", "not a URL"] + ])("rejects %s", (_name, presignedPostUrl) => { + const result = parsePresignedPostUrl({ + s3Config: createS3Config({ + defaultServer: { + url: "https://minio.lab.sspcloud.fr", + pathStyleAccess: true + } + }), + presignedPost_url: presignedPostUrl + }); + + expect(result).toEqual({ isKnownS3Server: false }); + }); + + it("rejects a URL whose addressing style differs from the configured style", () => { + const config = createS3Config({ + defaultServer: { + url: "https://minio.lab.sspcloud.fr", + pathStyleAccess: false + } + }); + + expect( + parsePresignedPostUrl({ + s3Config: config, + presignedPost_url: "https://minio.lab.sspcloud.fr/garronej" + }) + ).toEqual({ isKnownS3Server: false }); + }); +}); + +describe("getIsKnownS3ServerUrl", () => { + const s3Config = createS3Config({ + defaultServer: { + url: "https://minio.lab.sspcloud.fr/", + pathStyleAccess: true + } + }); + + it("recognizes the configured endpoint independently of a trailing slash", () => { + expect( + getIsKnownS3ServerUrl({ + s3Config, + s3ServerUrl: "https://minio.lab.sspcloud.fr", + pathStyleAccess: true + }) + ).toBe(true); + }); + + it("does not use vulnerable string-prefix matching", () => { + expect( + getIsKnownS3ServerUrl({ + s3Config, + s3ServerUrl: "https://minio.lab.sspcloud.fr.evil.test", + pathStyleAccess: true + }) + ).toBe(false); + }); + + it("requires the addressing style to match the configured server", () => { + expect( + getIsKnownS3ServerUrl({ + s3Config, + s3ServerUrl: "https://minio.lab.sspcloud.fr", + pathStyleAccess: false + }) + ).toBe(false); + }); +}); diff --git a/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getIsKnownS3HttpUrl.ts b/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getIsKnownS3HttpUrl.ts index 675dc9401..c5fbf6827 100644 --- a/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getIsKnownS3HttpUrl.ts +++ b/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getIsKnownS3HttpUrl.ts @@ -1,15 +1,228 @@ import type { S3Config } from "core/ports/OnyxiaApi/S3Config"; -import { exclude } from "tsafe"; -export function getIsKnownS3HttpUrl(params: { s3HttpUrl: string; s3Config: S3Config }) { - const { s3HttpUrl, s3Config } = params; +export function parsePresignedPostUrl(params: { + s3Config: S3Config; + presignedPost_url: string; +}): + | { isKnownS3Server: false } + | { isKnownS3Server: true; s3ServerUrl: string; bucket: string } { + const { s3Config, presignedPost_url } = params; - const knownServerUrls = [ - s3Config.defaultValuesOfCreationForm?.url, - ...s3Config.entries.map(entry => entry.url) - ].filter(exclude(undefined)); + const presignedPostUrl = parseHttpUrl(presignedPost_url); - return ( - knownServerUrls.find(serverUrl => s3HttpUrl.startsWith(serverUrl)) !== undefined - ); + if (presignedPostUrl === undefined || hasCredentials(presignedPostUrl)) { + return { isKnownS3Server: false }; + } + + const parsedKnownS3Servers = getKnownS3Servers(s3Config) + .map(({ pathStyleAccess, url }) => { + const parsedUrl = parseHttpUrl(url); + + if ( + parsedUrl === undefined || + hasCredentials(parsedUrl) || + parsedUrl.search !== "" || + parsedUrl.hash !== "" + ) { + return undefined; + } + + const endpointPathname = removeTrailingSlashes(parsedUrl.pathname); + + return { + pathStyleAccess, + parsedUrl, + endpointPathname, + s3ServerUrl: `${parsedUrl.origin}${endpointPathname}` + }; + }) + .filter(server => server !== undefined) + .sort( + (a, b) => + b.parsedUrl.hostname.length - a.parsedUrl.hostname.length || + b.endpointPathname.length - a.endpointPathname.length + ); + + for (const knownS3Server of parsedKnownS3Servers) { + const bucket = knownS3Server.pathStyleAccess + ? getPathStyleBucket({ + presignedPostUrl, + serverUrl: knownS3Server.parsedUrl, + endpointPathname: knownS3Server.endpointPathname + }) + : getVirtualHostedStyleBucket({ + presignedPostUrl, + serverUrl: knownS3Server.parsedUrl, + endpointPathname: knownS3Server.endpointPathname + }); + + if (bucket === undefined) { + continue; + } + + return { + isKnownS3Server: true, + s3ServerUrl: knownS3Server.s3ServerUrl, + bucket + }; + } + + return { isKnownS3Server: false }; +} + +export function getIsKnownS3ServerUrl(params: { + s3Config: S3Config; + s3ServerUrl: string; + pathStyleAccess: boolean; +}): boolean { + const { s3Config, s3ServerUrl, pathStyleAccess } = params; + + const candidateUrl = parseHttpUrl(s3ServerUrl); + + if ( + candidateUrl === undefined || + hasCredentials(candidateUrl) || + candidateUrl.search !== "" || + candidateUrl.hash !== "" + ) { + return false; + } + + const candidateEndpoint = `${candidateUrl.origin}${removeTrailingSlashes( + candidateUrl.pathname + )}`; + + return getKnownS3Servers(s3Config).some(server => { + if (server.pathStyleAccess !== pathStyleAccess) { + return false; + } + + const knownS3Server = parseHttpUrl(server.url); + + if ( + knownS3Server === undefined || + hasCredentials(knownS3Server) || + knownS3Server.search !== "" || + knownS3Server.hash !== "" + ) { + return false; + } + + return ( + `${knownS3Server.origin}${removeTrailingSlashes(knownS3Server.pathname)}` === + candidateEndpoint + ); + }); +} + +function getKnownS3Servers( + s3Config: S3Config +): { pathStyleAccess: boolean; url: string }[] { + return [ + ...s3Config.entries, + ...(s3Config.defaultValuesOfCreationForm === undefined + ? [] + : [ + { + pathStyleAccess: + s3Config.defaultValuesOfCreationForm.pathStyleAccess, + url: s3Config.defaultValuesOfCreationForm.url + } + ]) + ]; +} + +function getPathStyleBucket(params: { + presignedPostUrl: URL; + serverUrl: URL; + endpointPathname: string; +}): string | undefined { + const { presignedPostUrl, serverUrl, endpointPathname } = params; + + if (!haveSameConnectionTarget(presignedPostUrl, serverUrl)) { + return undefined; + } + + const bucketPathPrefix = `${endpointPathname}/`; + + if (!presignedPostUrl.pathname.startsWith(bucketPathPrefix)) { + return undefined; + } + + const bucketPath = presignedPostUrl.pathname.slice(bucketPathPrefix.length); + const match = bucketPath.match(/^([^/]+)\/?$/); + + if (match === null) { + return undefined; + } + + return decodeBucket(match[1]); +} + +function getVirtualHostedStyleBucket(params: { + presignedPostUrl: URL; + serverUrl: URL; + endpointPathname: string; +}): string | undefined { + const { presignedPostUrl, serverUrl, endpointPathname } = params; + + if ( + presignedPostUrl.protocol !== serverUrl.protocol || + presignedPostUrl.port !== serverUrl.port || + removeTrailingSlashes(presignedPostUrl.pathname) !== endpointPathname + ) { + return undefined; + } + + const serverHostnameSuffix = `.${serverUrl.hostname}`; + + if (!presignedPostUrl.hostname.endsWith(serverHostnameSuffix)) { + return undefined; + } + + const bucket = presignedPostUrl.hostname.slice(0, -serverHostnameSuffix.length); + + return bucket === "" ? undefined : bucket; +} + +function parseHttpUrl(value: string): URL | undefined { + let url: URL; + + try { + url = new URL(value); + } catch { + return undefined; + } + + return url.protocol === "http:" || url.protocol === "https:" ? url : undefined; +} + +function haveSameConnectionTarget(a: URL, b: URL): boolean { + return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port; +} + +function hasCredentials(url: URL): boolean { + return url.username !== "" || url.password !== ""; +} + +function removeTrailingSlashes(pathname: string): string { + return pathname.replace(/\/+$/, ""); +} + +function decodeBucket(encodedBucket: string | undefined): string | undefined { + if (encodedBucket === undefined) { + return undefined; + } + + let bucket: string; + + try { + bucket = decodeURIComponent(encodedBucket); + } catch { + return undefined; + } + + return bucket === "" || bucket.includes("/") || bucket.includes("\\") + ? undefined + : bucket; } diff --git a/web/src/core/usecases/s3FileRequestUiController/thunks.ts b/web/src/core/usecases/s3FileRequestUiController/thunks.ts index 192269360..b3599bdf0 100644 --- a/web/src/core/usecases/s3FileRequestUiController/thunks.ts +++ b/web/src/core/usecases/s3FileRequestUiController/thunks.ts @@ -2,7 +2,7 @@ import type { Thunks } from "core/bootstrap"; import { actions, type PresignedPost } from "./state"; import { privateSelectors } from "./selectors"; import { assert } from "tsafe/assert"; -import { getIsKnownS3HttpUrl } from "./decoupledLogic/getIsKnownS3HttpUrl"; +import { parsePresignedPostUrl } from "./decoupledLogic/getIsKnownS3HttpUrl"; type FileToUpload = { file: File; @@ -20,10 +20,10 @@ export const thunks = { const [dispatch, , rootContext] = args; if ( - !getIsKnownS3HttpUrl({ + !parsePresignedPostUrl({ s3Config: rootContext.s3Config, - s3HttpUrl: presignedPost.url - }) + presignedPost_url: presignedPost.url + }).isKnownS3Server ) { alert("Not allowed"); throw new Error(); From 0744d4d9c8d27a6e54506b6162677fa790b613b5 Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 2 Sep 2026 21:34:01 +0200 Subject: [PATCH 16/22] Know where requested files are uploaded --- .../getPresignedPostKeyPrefix.test.ts | 50 ++++++++++++++++ .../getPresignedPostKeyPrefix.ts | 53 ++++++++++++++++ .../s3FileRequestUiController/selectors.ts | 12 +++- .../s3FileRequestUiController/state.ts | 19 +++++- .../s3FileRequestUiController/thunks.ts | 29 ++++++--- web/src/ui/i18n/resources/de.tsx | 2 + web/src/ui/i18n/resources/en.tsx | 2 + web/src/ui/i18n/resources/es.tsx | 2 + web/src/ui/i18n/resources/fi.tsx | 2 + web/src/ui/i18n/resources/fr.tsx | 2 + web/src/ui/i18n/resources/it.tsx | 2 + web/src/ui/i18n/resources/nl.tsx | 2 + web/src/ui/i18n/resources/no.tsx | 2 + web/src/ui/i18n/resources/zh-CN.tsx | 2 + web/src/ui/pages/s3FileRequest/Page.tsx | 4 +- .../S3FileRequest/S3FileRequest.stories.tsx | 2 + .../codex/S3FileRequest/S3FileRequest.tsx | 60 +++++++++++++++++++ 17 files changed, 236 insertions(+), 11 deletions(-) create mode 100644 web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getPresignedPostKeyPrefix.test.ts create mode 100644 web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getPresignedPostKeyPrefix.ts diff --git a/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getPresignedPostKeyPrefix.test.ts b/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getPresignedPostKeyPrefix.test.ts new file mode 100644 index 000000000..81c904062 --- /dev/null +++ b/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getPresignedPostKeyPrefix.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { getPresignedPostKeyPrefix } from "./getPresignedPostKeyPrefix"; + +function encodePolicy(policy: unknown): string { + return btoa( + Array.from(new TextEncoder().encode(JSON.stringify(policy)), byte => + String.fromCharCode(byte) + ).join("") + ); +} + +describe("getPresignedPostKeyPrefix", () => { + it("extracts the key prefix from an S3 POST policy", () => { + expect( + getPresignedPostKeyPrefix({ + presignedPost_fields: { + Policy: encodePolicy({ + expiration: "2026-09-03T00:00:00Z", + conditions: [ + { bucket: "garronej" }, + ["starts-with", "$key", "requested-files/été/"] + ] + }) + } + }) + ).toBe("requested-files/été/"); + }); + + const fieldsWithoutValidKeyPrefix: Record[] = [ + {}, + { Policy: "not-base64" }, + { Policy: encodePolicy({ conditions: {} }) }, + { + Policy: encodePolicy({ + conditions: [["content-length-range", 0, 1_000]] + }) + } + ]; + + it.each(fieldsWithoutValidKeyPrefix)( + "returns undefined when no valid key-prefix condition exists", + fields => { + expect( + getPresignedPostKeyPrefix({ + presignedPost_fields: fields + }) + ).toBeUndefined(); + } + ); +}); diff --git a/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getPresignedPostKeyPrefix.ts b/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getPresignedPostKeyPrefix.ts new file mode 100644 index 000000000..c3bc75032 --- /dev/null +++ b/web/src/core/usecases/s3FileRequestUiController/decoupledLogic/getPresignedPostKeyPrefix.ts @@ -0,0 +1,53 @@ +export function getPresignedPostKeyPrefix(params: { + presignedPost_fields: Record; +}): string | undefined { + const { presignedPost_fields } = params; + + const encodedPolicy = + presignedPost_fields["Policy"] ?? presignedPost_fields["policy"]; + + if (encodedPolicy === undefined) { + return undefined; + } + + let decodedPolicy: unknown; + + try { + const bytes = Uint8Array.from(atob(encodedPolicy), character => + character.charCodeAt(0) + ); + + decodedPolicy = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return undefined; + } + + if ( + typeof decodedPolicy !== "object" || + decodedPolicy === null || + !("conditions" in decodedPolicy) || + !Array.isArray(decodedPolicy.conditions) + ) { + return undefined; + } + + for (const condition of decodedPolicy.conditions) { + if (!Array.isArray(condition)) { + continue; + } + + const [operator, field, prefix] = condition; + + if ( + operator !== "starts-with" || + field !== "$key" || + typeof prefix !== "string" + ) { + continue; + } + + return prefix; + } + + return undefined; +} diff --git a/web/src/core/usecases/s3FileRequestUiController/selectors.ts b/web/src/core/usecases/s3FileRequestUiController/selectors.ts index c91112ded..ff8ce3b93 100644 --- a/web/src/core/usecases/s3FileRequestUiController/selectors.ts +++ b/web/src/core/usecases/s3FileRequestUiController/selectors.ts @@ -6,19 +6,29 @@ const state = (rootState: RootState): State => rootState[name]; const presignedPost = createSelector(state, state => state.presignedPost); +const s3ServerUrl = createSelector(state, state => state.s3ServerUrl); + +const s3UriStr = createSelector(state, state => state.s3UriStr); + const uploads = createSelector(state, state => state.uploads); export type MainView = { expirationTime: number; + s3ServerUrl: string; + s3UriStr: string; uploads: State.Upload[]; isUploading: boolean; }; const mainView = createSelector( presignedPost, + s3ServerUrl, + s3UriStr, uploads, - (presignedPost, uploads): MainView => ({ + (presignedPost, s3ServerUrl, s3UriStr, uploads): MainView => ({ expirationTime: presignedPost.expirationTime, + s3ServerUrl, + s3UriStr, uploads, isUploading: uploads.some(upload => upload.status === "uploading") }) diff --git a/web/src/core/usecases/s3FileRequestUiController/state.ts b/web/src/core/usecases/s3FileRequestUiController/state.ts index 0cfdc7b8c..5b6a5246c 100644 --- a/web/src/core/usecases/s3FileRequestUiController/state.ts +++ b/web/src/core/usecases/s3FileRequestUiController/state.ts @@ -10,6 +10,8 @@ export type PresignedPost = S3Client.PresignedPost; export type State = { presignedPost: PresignedPost; + s3ServerUrl: string; + s3UriStr: string; uploads: State.Upload[]; }; @@ -30,11 +32,24 @@ export const { reducer, actions } = createUsecaseActions({ name, initialState: createObjectThatThrowsIfAccessed(), reducers: { - loaded: (_state, { payload }: { payload: { presignedPost: PresignedPost } }) => { - const { presignedPost } = payload; + loaded: ( + _state, + { + payload + }: { + payload: { + presignedPost: PresignedPost; + s3ServerUrl: string; + s3UriStr: string; + }; + } + ) => { + const { presignedPost, s3ServerUrl, s3UriStr } = payload; return id({ presignedPost, + s3ServerUrl, + s3UriStr, uploads: [] }); }, diff --git a/web/src/core/usecases/s3FileRequestUiController/thunks.ts b/web/src/core/usecases/s3FileRequestUiController/thunks.ts index b3599bdf0..8109ba046 100644 --- a/web/src/core/usecases/s3FileRequestUiController/thunks.ts +++ b/web/src/core/usecases/s3FileRequestUiController/thunks.ts @@ -3,6 +3,7 @@ import { actions, type PresignedPost } from "./state"; import { privateSelectors } from "./selectors"; import { assert } from "tsafe/assert"; import { parsePresignedPostUrl } from "./decoupledLogic/getIsKnownS3HttpUrl"; +import { getPresignedPostKeyPrefix } from "./decoupledLogic/getPresignedPostKeyPrefix"; type FileToUpload = { file: File; @@ -19,16 +20,24 @@ export const thunks = { const { presignedPost } = params; const [dispatch, , rootContext] = args; - if ( - !parsePresignedPostUrl({ - s3Config: rootContext.s3Config, - presignedPost_url: presignedPost.url - }).isKnownS3Server - ) { + const parsedPresignedPostUrl = parsePresignedPostUrl({ + s3Config: rootContext.s3Config, + presignedPost_url: presignedPost.url + }); + + if (!parsedPresignedPostUrl.isKnownS3Server) { alert("Not allowed"); throw new Error(); } + const { s3ServerUrl, bucket } = parsedPresignedPostUrl; + + const keyPrefix = getPresignedPostKeyPrefix({ + presignedPost_fields: presignedPost.fields + }); + + const s3UriStr = `s3://${bucket}/${keyPrefix === undefined ? "" : keyPrefix}`; + for (const xhr of [...xhrByUploadId.values()]) { xhr.abort(); } @@ -36,7 +45,13 @@ export const thunks = { xhrByUploadId.clear(); fileToUploadByUploadId.clear(); - dispatch(actions.loaded({ presignedPost })); + dispatch( + actions.loaded({ + presignedPost, + s3ServerUrl, + s3UriStr + }) + ); }, uploadFiles: (params: { files: readonly FileToUpload[] }) => diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index ba6423899..4d04007ec 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -217,6 +217,8 @@ export const translations: Translations<"de"> = { "page title": "Angeforderte Dateien hochladen", "page description": "Jemand hat diesen sicheren Link mit Ihnen geteilt, damit Sie Dateien direkt an den zugehörigen Speicherplatz senden können. Sie benötigen kein Onyxia-Konto.", + "s3 server destination": "Sie laden auf diesen S3-Server hoch:", + "s3 location destination": "An diesen Speicherort:", "expires on": ({ date }) => `Dieser Link läuft am ${date} ab`, "link expired": "Dieser Upload-Link ist abgelaufen", "link expired description": diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 1381476f7..befa4223a 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -363,6 +363,8 @@ export const translations: Translations<"en"> = { "page title": "Upload requested files", "page description": "Someone shared this secure link so you can send files directly to their storage space. You do not need an Onyxia account.", + "s3 server destination": "You are uploading on this S3 server:", + "s3 location destination": "At this location:", "expires on": ({ date }) => `This link expires on ${date}`, "link expired": "This upload link has expired", "link expired description": diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index 0b7d7180c..b17291684 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -212,6 +212,8 @@ export const translations: Translations<"es"> = { "page title": "Subir los archivos solicitados", "page description": "Alguien ha compartido este enlace seguro para que puedas enviar archivos directamente a su espacio de almacenamiento. No necesitas una cuenta de Onyxia.", + "s3 server destination": "Estás subiendo archivos a este servidor S3:", + "s3 location destination": "En esta ubicación:", "expires on": ({ date }) => `Este enlace caduca el ${date}`, "link expired": "Este enlace de subida ha caducado", "link expired description": diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 716193c75..cbe5d0c52 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -209,6 +209,8 @@ export const translations: Translations<"fi"> = { "page title": "Lataa pyydetyt tiedostot", "page description": "Joku jakoi tämän suojatun linkin, jotta voit lähettää tiedostoja suoraan hänen tallennustilaansa. Et tarvitse Onyxia-tiliä.", + "s3 server destination": "Lähetät tiedostoja tälle S3-palvelimelle:", + "s3 location destination": "Tähän sijaintiin:", "expires on": ({ date }) => `Tämä linkki vanhenee ${date}`, "link expired": "Tämä lähetyslinkki on vanhentunut", "link expired description": diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 4d673c14f..d722a1298 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -215,6 +215,8 @@ export const translations: Translations<"fr"> = { "page title": "Envoyer les fichiers demandés", "page description": "Une personne a partagé ce lien sécurisé afin que vous puissiez envoyer des fichiers directement dans son espace de stockage. Aucun compte Onyxia n’est nécessaire.", + "s3 server destination": "Vous envoyez des fichiers sur ce serveur S3 :", + "s3 location destination": "À cet emplacement :", "expires on": ({ date }) => `Ce lien expire le ${date}`, "link expired": "Ce lien d’envoi a expiré", "link expired description": diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index b687abab6..6301c84ec 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -212,6 +212,8 @@ export const translations: Translations<"it"> = { "page title": "Carica i file richiesti", "page description": "Qualcuno ha condiviso questo link sicuro per consentirti di inviare file direttamente al proprio spazio di archiviazione. Non è necessario un account Onyxia.", + "s3 server destination": "Stai caricando su questo server S3:", + "s3 location destination": "In questa posizione:", "expires on": ({ date }) => `Questo link scade il ${date}`, "link expired": "Questo link di caricamento è scaduto", "link expired description": diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index d4d312d37..031237f13 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -213,6 +213,8 @@ export const translations: Translations<"nl"> = { "page title": "Gevraagde bestanden uploaden", "page description": "Iemand heeft deze beveiligde link gedeeld, zodat je bestanden rechtstreeks naar diens opslagruimte kunt sturen. Je hebt geen Onyxia-account nodig.", + "s3 server destination": "Je uploadt naar deze S3-server:", + "s3 location destination": "Op deze locatie:", "expires on": ({ date }) => `Deze link verloopt op ${date}`, "link expired": "Deze uploadlink is verlopen", "link expired description": diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 24038b3bf..5c30d8fa0 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -209,6 +209,8 @@ export const translations: Translations<"no"> = { "page title": "Last opp forespurte filer", "page description": "Noen har delt denne sikre lenken slik at du kan sende filer direkte til lagringsområdet deres. Du trenger ikke en Onyxia-konto.", + "s3 server destination": "Du laster opp til denne S3-serveren:", + "s3 location destination": "På denne plasseringen:", "expires on": ({ date }) => `Denne lenken utløper ${date}`, "link expired": "Denne opplastingslenken har utløpt", "link expired description": diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index b81f30307..2c1c64372 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -190,6 +190,8 @@ export const translations: Translations<"zh-CN"> = { "page title": "上传对方请求的文件", "page description": "有人分享了这个安全链接,以便你将文件直接发送到对方的存储空间。无需 Onyxia 帐户。", + "s3 server destination": "你正在上传到此 S3 服务器:", + "s3 location destination": "目标位置:", "expires on": ({ date }) => `此链接将于 ${date} 过期`, "link expired": "此上传链接已过期", "link expired description": "请让链接分享者创建一个新链接。", diff --git a/web/src/ui/pages/s3FileRequest/Page.tsx b/web/src/ui/pages/s3FileRequest/Page.tsx index 362273e50..4ec3a7339 100644 --- a/web/src/ui/pages/s3FileRequest/Page.tsx +++ b/web/src/ui/pages/s3FileRequest/Page.tsx @@ -24,7 +24,7 @@ async function loader() { } function S3FileRequestPage() { - const { expirationTime, uploads } = useCoreState( + const { expirationTime, s3ServerUrl, s3UriStr, uploads } = useCoreState( "s3FileRequestUiController", "mainView" ); @@ -35,6 +35,8 @@ function S3FileRequestPage() { return ( { void s3FileRequestUiController.uploadFiles({ files }); diff --git a/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.stories.tsx b/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.stories.tsx index 50b7edde3..a32c4c3b0 100644 --- a/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.stories.tsx +++ b/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.stories.tsx @@ -13,6 +13,8 @@ type Story = StoryObj; const baseArgs: S3FileRequestProps = { expirationTime: Date.now() + 24 * 60 * 60 * 1_000, + s3ServerUrl: "https://minio.lab.sspcloud.fr", + s3UriStr: "s3://garronej/requested-files/", uploads: [], onUploadFiles: action("uploadFiles"), onCancelUpload: action("cancelUpload"), diff --git a/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.tsx b/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.tsx index d179696bc..fcbc60422 100644 --- a/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.tsx +++ b/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.tsx @@ -25,6 +25,8 @@ import { export type S3FileRequestProps = { className?: string; expirationTime: number; + s3ServerUrl: string; + s3UriStr: string; uploads: readonly S3FileRequestProps.Upload[]; onUploadFiles: (params: { files: readonly S3FileRequestProps.FileToUpload[]; @@ -53,6 +55,8 @@ export function S3FileRequest(props: S3FileRequestProps) { const { className, expirationTime, + s3ServerUrl, + s3UriStr, uploads, onUploadFiles, onCancelUpload, @@ -184,6 +188,28 @@ export function S3FileRequest(props: S3FileRequestProps) {
+
+
+ + {t("s3 server destination")} + + + {s3ServerUrl} + +
+
+ + {t("s3 location destination")} + + + {s3UriStr} + +
+
+
({ lineHeight: 1.6, maxWidth: 650 }, + destination: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(1.25), + padding: `${theme.spacing(2)}px ${theme.spacing(2.5)}px`, + borderRadius: 12, + border: `1px solid ${alpha(theme.colors.useCases.typography.textFocus, 0.25)}`, + backgroundColor: alpha(theme.colors.useCases.typography.textFocus, 0.05) + }, + destinationRow: { + minWidth: 0, + display: "flex", + alignItems: "baseline", + gap: theme.spacing(1), + "@media (max-width: 640px)": { + flexDirection: "column", + alignItems: "stretch", + gap: theme.spacing(0.5) + } + }, + destinationLabel: { + ...theme.typography.variants["label 1"].style, + flexShrink: 0, + color: theme.colors.useCases.typography.textPrimary + }, + destinationValue: { + ...theme.typography.variants["body 2"].style, + minWidth: 0, + overflowWrap: "anywhere", + color: theme.colors.useCases.typography.textFocus, + fontFamily: "monospace" + }, expiration: { display: "flex", alignItems: "flex-start", @@ -731,6 +789,8 @@ const useStyles = tss.withName({ S3FileRequest }).create(({ theme }) => ({ const { i18n } = declareComponentKeys< | "page title" | "page description" + | "s3 server destination" + | "s3 location destination" | { K: "expires on"; P: { date: string }; R: string } | "link expired" | "link expired description" From ff3934814a99b92f5e093bc994dab4d6553b2c1d Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 2 Sep 2026 21:36:35 +0200 Subject: [PATCH 17/22] Remove gimmicky text --- web/src/ui/i18n/resources/de.tsx | 3 +-- web/src/ui/i18n/resources/en.tsx | 3 +-- web/src/ui/i18n/resources/es.tsx | 3 +-- web/src/ui/i18n/resources/fi.tsx | 3 +-- web/src/ui/i18n/resources/fr.tsx | 3 +-- web/src/ui/i18n/resources/it.tsx | 3 +-- web/src/ui/i18n/resources/nl.tsx | 4 +--- web/src/ui/i18n/resources/no.tsx | 3 +-- web/src/ui/i18n/resources/zh-CN.tsx | 3 +-- .../shared/codex/S3FileRequest/S3FileRequest.tsx | 15 --------------- 10 files changed, 9 insertions(+), 34 deletions(-) diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index 4d04007ec..393b603a9 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -235,8 +235,7 @@ export const translations: Translations<"de"> = { uploaded: "Hochgeladen", "upload failed": "Upload fehlgeschlagen", "cancel upload": "Upload abbrechen", - "retry upload": "Upload wiederholen", - "privacy note": "Nur die von Ihnen ausgewählten Dateien werden gesendet." + "retry upload": "Upload wiederholen" }, S3ShareObjectDialogContainer: { "dialog title": "Objekt teilen" diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index befa4223a..5b4826940 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -381,8 +381,7 @@ export const translations: Translations<"en"> = { uploaded: "Uploaded", "upload failed": "Upload failed", "cancel upload": "Cancel upload", - "retry upload": "Retry upload", - "privacy note": "Only the files you choose are sent through this link." + "retry upload": "Retry upload" }, S3ShareObjectDialogContainer: { "dialog title": "Share object" diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index b17291684..ecfbcd2e0 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -230,8 +230,7 @@ export const translations: Translations<"es"> = { uploaded: "Subido", "upload failed": "Error al subir", "cancel upload": "Cancelar subida", - "retry upload": "Reintentar subida", - "privacy note": "Solo se envían mediante este enlace los archivos que elijas." + "retry upload": "Reintentar subida" }, S3ShareObjectDialogContainer: { "dialog title": "Compartir objeto" diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index cbe5d0c52..1160700d7 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -227,8 +227,7 @@ export const translations: Translations<"fi"> = { uploaded: "Ladattu", "upload failed": "Lataus epäonnistui", "cancel upload": "Peruuta lataus", - "retry upload": "Yritä latausta uudelleen", - "privacy note": "Vain valitsemasi tiedostot lähetetään tämän linkin kautta." + "retry upload": "Yritä latausta uudelleen" }, S3ShareObjectDialogContainer: { "dialog title": "Jaa objekti" diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index d722a1298..af9988a48 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -233,8 +233,7 @@ export const translations: Translations<"fr"> = { uploaded: "Envoyé", "upload failed": "Échec de l’envoi", "cancel upload": "Annuler l’envoi", - "retry upload": "Réessayer", - "privacy note": "Seuls les fichiers que vous choisissez sont envoyés via ce lien." + "retry upload": "Réessayer" }, S3ShareObjectDialogContainer: { "dialog title": "Partager l'objet" diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index 6301c84ec..4b23b7b38 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -230,8 +230,7 @@ export const translations: Translations<"it"> = { uploaded: "Caricato", "upload failed": "Caricamento non riuscito", "cancel upload": "Annulla caricamento", - "retry upload": "Riprova il caricamento", - "privacy note": "Tramite questo link vengono inviati solo i file scelti." + "retry upload": "Riprova il caricamento" }, S3ShareObjectDialogContainer: { "dialog title": "Condividi oggetto" diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 031237f13..2287d7fa8 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -231,9 +231,7 @@ export const translations: Translations<"nl"> = { uploaded: "Geüpload", "upload failed": "Upload mislukt", "cancel upload": "Upload annuleren", - "retry upload": "Upload opnieuw proberen", - "privacy note": - "Alleen de bestanden die je kiest, worden via deze link verzonden." + "retry upload": "Upload opnieuw proberen" }, S3ShareObjectDialogContainer: { "dialog title": "Object delen" diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 5c30d8fa0..d95340863 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -227,8 +227,7 @@ export const translations: Translations<"no"> = { uploaded: "Lastet opp", "upload failed": "Opplastingen mislyktes", "cancel upload": "Avbryt opplasting", - "retry upload": "Prøv opplastingen på nytt", - "privacy note": "Bare filene du velger, sendes via denne lenken." + "retry upload": "Prøv opplastingen på nytt" }, S3ShareObjectDialogContainer: { "dialog title": "Del objekt" diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index 2c1c64372..5751ec827 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -207,8 +207,7 @@ export const translations: Translations<"zh-CN"> = { uploaded: "已上传", "upload failed": "上传失败", "cancel upload": "取消上传", - "retry upload": "重试上传", - "privacy note": "只有你选择的文件会通过此链接发送。" + "retry upload": "重试上传" }, S3ShareObjectDialogContainer: { "dialog title": "共享对象" diff --git a/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.tsx b/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.tsx index fcbc60422..acddff684 100644 --- a/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.tsx +++ b/web/src/ui/shared/codex/S3FileRequest/S3FileRequest.tsx @@ -430,11 +430,6 @@ export function S3FileRequest(props: S3FileRequestProps) {
)} - -
- - {t("privacy note")} -
@@ -774,15 +769,6 @@ const useStyles = tss.withName({ S3FileRequest }).create(({ theme }) => ({ alignItems: "center", justifyContent: "center", color: theme.colors.useCases.alertSeverity.success.main - }, - privacyNote: { - display: "flex", - alignItems: "center", - justifyContent: "center", - gap: theme.spacing(1), - textAlign: "center", - color: theme.colors.useCases.typography.textSecondary, - ...theme.typography.variants["caption"].style } })); @@ -806,6 +792,5 @@ const { i18n } = declareComponentKeys< | "upload failed" | "cancel upload" | "retry upload" - | "privacy note" >()({ S3FileRequest }); export type I18n = typeof i18n; From a9f731df274607c6f7791ec0275355efa0e6e80d Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 2 Sep 2026 21:49:32 +0200 Subject: [PATCH 18/22] Remove unessesary text --- web/src/ui/i18n/resources/de.tsx | 2 +- web/src/ui/i18n/resources/en.tsx | 2 +- web/src/ui/i18n/resources/es.tsx | 2 +- web/src/ui/i18n/resources/fi.tsx | 2 +- web/src/ui/i18n/resources/fr.tsx | 2 +- web/src/ui/i18n/resources/it.tsx | 2 +- web/src/ui/i18n/resources/nl.tsx | 2 +- web/src/ui/i18n/resources/no.tsx | 2 +- web/src/ui/i18n/resources/zh-CN.tsx | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index 393b603a9..4fff5994b 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -216,7 +216,7 @@ export const translations: Translations<"de"> = { S3FileRequest: { "page title": "Angeforderte Dateien hochladen", "page description": - "Jemand hat diesen sicheren Link mit Ihnen geteilt, damit Sie Dateien direkt an den zugehörigen Speicherplatz senden können. Sie benötigen kein Onyxia-Konto.", + "Jemand hat diesen sicheren Link mit Ihnen geteilt, damit Sie Dateien direkt an den zugehörigen Speicherplatz senden können.", "s3 server destination": "Sie laden auf diesen S3-Server hoch:", "s3 location destination": "An diesen Speicherort:", "expires on": ({ date }) => `Dieser Link läuft am ${date} ab`, diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 5b4826940..19abac2f4 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -362,7 +362,7 @@ export const translations: Translations<"en"> = { S3FileRequest: { "page title": "Upload requested files", "page description": - "Someone shared this secure link so you can send files directly to their storage space. You do not need an Onyxia account.", + "Someone shared this secure link so you can send files directly to their storage space.", "s3 server destination": "You are uploading on this S3 server:", "s3 location destination": "At this location:", "expires on": ({ date }) => `This link expires on ${date}`, diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index ecfbcd2e0..4f71de21f 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -211,7 +211,7 @@ export const translations: Translations<"es"> = { S3FileRequest: { "page title": "Subir los archivos solicitados", "page description": - "Alguien ha compartido este enlace seguro para que puedas enviar archivos directamente a su espacio de almacenamiento. No necesitas una cuenta de Onyxia.", + "Alguien ha compartido este enlace seguro para que puedas enviar archivos directamente a su espacio de almacenamiento.", "s3 server destination": "Estás subiendo archivos a este servidor S3:", "s3 location destination": "En esta ubicación:", "expires on": ({ date }) => `Este enlace caduca el ${date}`, diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 1160700d7..a01e5339a 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -208,7 +208,7 @@ export const translations: Translations<"fi"> = { S3FileRequest: { "page title": "Lataa pyydetyt tiedostot", "page description": - "Joku jakoi tämän suojatun linkin, jotta voit lähettää tiedostoja suoraan hänen tallennustilaansa. Et tarvitse Onyxia-tiliä.", + "Joku jakoi tämän suojatun linkin, jotta voit lähettää tiedostoja suoraan hänen tallennustilaansa.", "s3 server destination": "Lähetät tiedostoja tälle S3-palvelimelle:", "s3 location destination": "Tähän sijaintiin:", "expires on": ({ date }) => `Tämä linkki vanhenee ${date}`, diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index af9988a48..13ba2389f 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -214,7 +214,7 @@ export const translations: Translations<"fr"> = { S3FileRequest: { "page title": "Envoyer les fichiers demandés", "page description": - "Une personne a partagé ce lien sécurisé afin que vous puissiez envoyer des fichiers directement dans son espace de stockage. Aucun compte Onyxia n’est nécessaire.", + "Une personne a partagé ce lien sécurisé afin que vous puissiez envoyer des fichiers directement dans son espace de stockage.", "s3 server destination": "Vous envoyez des fichiers sur ce serveur S3 :", "s3 location destination": "À cet emplacement :", "expires on": ({ date }) => `Ce lien expire le ${date}`, diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index 4b23b7b38..e8942b7aa 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -211,7 +211,7 @@ export const translations: Translations<"it"> = { S3FileRequest: { "page title": "Carica i file richiesti", "page description": - "Qualcuno ha condiviso questo link sicuro per consentirti di inviare file direttamente al proprio spazio di archiviazione. Non è necessario un account Onyxia.", + "Qualcuno ha condiviso questo link sicuro per consentirti di inviare file direttamente al proprio spazio di archiviazione.", "s3 server destination": "Stai caricando su questo server S3:", "s3 location destination": "In questa posizione:", "expires on": ({ date }) => `Questo link scade il ${date}`, diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 2287d7fa8..e57e2fccb 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -212,7 +212,7 @@ export const translations: Translations<"nl"> = { S3FileRequest: { "page title": "Gevraagde bestanden uploaden", "page description": - "Iemand heeft deze beveiligde link gedeeld, zodat je bestanden rechtstreeks naar diens opslagruimte kunt sturen. Je hebt geen Onyxia-account nodig.", + "Iemand heeft deze beveiligde link gedeeld, zodat je bestanden rechtstreeks naar diens opslagruimte kunt sturen.", "s3 server destination": "Je uploadt naar deze S3-server:", "s3 location destination": "Op deze locatie:", "expires on": ({ date }) => `Deze link verloopt op ${date}`, diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index d95340863..f8225d931 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -208,7 +208,7 @@ export const translations: Translations<"no"> = { S3FileRequest: { "page title": "Last opp forespurte filer", "page description": - "Noen har delt denne sikre lenken slik at du kan sende filer direkte til lagringsområdet deres. Du trenger ikke en Onyxia-konto.", + "Noen har delt denne sikre lenken slik at du kan sende filer direkte til lagringsområdet deres.", "s3 server destination": "Du laster opp til denne S3-serveren:", "s3 location destination": "På denne plasseringen:", "expires on": ({ date }) => `Denne lenken utløper ${date}`, diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index 5751ec827..4231e7a52 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -189,7 +189,7 @@ export const translations: Translations<"zh-CN"> = { S3FileRequest: { "page title": "上传对方请求的文件", "page description": - "有人分享了这个安全链接,以便你将文件直接发送到对方的存储空间。无需 Onyxia 帐户。", + "有人分享了这个安全链接,以便你将文件直接发送到对方的存储空间。", "s3 server destination": "你正在上传到此 S3 服务器:", "s3 location destination": "目标位置:", "expires on": ({ date }) => `此链接将于 ${date} 过期`, From f28a97392df30fb865a22b18ba67de0f048b8e6a Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 2 Sep 2026 22:31:30 +0200 Subject: [PATCH 19/22] Add warning for request file about file overwrite --- .../selectors.ts | 7 ++- .../state.ts | 2 + .../thunks.ts | 25 +++++++- web/src/ui/i18n/resources/de.tsx | 3 + web/src/ui/i18n/resources/en.tsx | 3 + web/src/ui/i18n/resources/es.tsx | 3 + web/src/ui/i18n/resources/fi.tsx | 3 + web/src/ui/i18n/resources/fr.tsx | 3 + web/src/ui/i18n/resources/it.tsx | 3 + web/src/ui/i18n/resources/nl.tsx | 3 + web/src/ui/i18n/resources/no.tsx | 3 + web/src/ui/i18n/resources/zh-CN.tsx | 3 + web/src/ui/pages/s3Explorer/Page.tsx | 56 ++++++++++-------- .../dialogs/S3FileRequestCreationDialog.tsx | 20 ++++++- .../S3FileRequestCreationDialog.spec.md | 7 +++ .../S3FileRequestCreationDialog.tsx | 58 ++++++++++++++++++- 16 files changed, 168 insertions(+), 34 deletions(-) diff --git a/web/src/core/usecases/s3FileRequestCreationUiController/selectors.ts b/web/src/core/usecases/s3FileRequestCreationUiController/selectors.ts index 9a8ae4945..2b6c85e72 100644 --- a/web/src/core/usecases/s3FileRequestCreationUiController/selectors.ts +++ b/web/src/core/usecases/s3FileRequestCreationUiController/selectors.ts @@ -10,6 +10,7 @@ export type MainView = { maxObjectSize: State.MaxObjectSize; presignedPost: State["presignedPost"]; errorMessage: string | undefined; + isEmptyPrefix: boolean; }; const mainView = createSelector( @@ -19,13 +20,15 @@ const mainView = createSelector( validityDuration, maxObjectSize, presignedPost, - errorMessage + errorMessage, + isEmptyPrefix }): MainView => ({ folderName: s3Uri.keySegments.at(-1) ?? s3Uri.bucket, validityDuration, maxObjectSize, presignedPost, - errorMessage + errorMessage, + isEmptyPrefix }) ); diff --git a/web/src/core/usecases/s3FileRequestCreationUiController/state.ts b/web/src/core/usecases/s3FileRequestCreationUiController/state.ts index ac4ae7f16..0b024cc97 100644 --- a/web/src/core/usecases/s3FileRequestCreationUiController/state.ts +++ b/web/src/core/usecases/s3FileRequestCreationUiController/state.ts @@ -16,6 +16,7 @@ export type State = { generationId: number | undefined; presignedPost: PresignedPost | undefined; errorMessage: string | undefined; + isEmptyPrefix: boolean; }; export namespace State { @@ -38,6 +39,7 @@ export const { reducer, actions } = createUsecaseActions({ payload: { s3Uri: S3Uri.TerminatedByDelimiter; profileName: string; + isEmptyPrefix: boolean; }; } ) => diff --git a/web/src/core/usecases/s3FileRequestCreationUiController/thunks.ts b/web/src/core/usecases/s3FileRequestCreationUiController/thunks.ts index 97e131828..5a3939828 100644 --- a/web/src/core/usecases/s3FileRequestCreationUiController/thunks.ts +++ b/web/src/core/usecases/s3FileRequestCreationUiController/thunks.ts @@ -10,17 +10,36 @@ let nextGenerationId = 0; export const thunks = { load: (params: { s3Uri: S3Uri.TerminatedByDelimiter }) => - (...args) => { + async (...args) => { + const { s3Uri } = params; + const [dispatch, getState] = args; const s3Profile = s3ProfilesManagement.selectors.ambientS3Profile(getState()); assert(s3Profile !== undefined); + const s3Client = await dispatch( + s3ProfilesManagement.protectedThunks.getS3Client({ + profileName: s3Profile.profileName + }) + ); + + const isEmptyPrefix = await (async () => { + const result = await s3Client.listObjects({ s3Uri }); + + if (!result.isSuccess) { + return false; + } + + return result.objects.length === 0 && result.prefixes.length === 0; + })(); + dispatch( actions.loaded({ - s3Uri: params.s3Uri, - profileName: s3Profile.profileName + s3Uri, + profileName: s3Profile.profileName, + isEmptyPrefix }) ); }, diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index 4fff5994b..62c278613 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -474,6 +474,9 @@ export const translations: Translations<"de"> = { S3FileRequestCreationDialog: { description: "Teilen Sie diesen Link mit beliebigen Personen, auch mit Personen ohne Konto auf dieser Onyxia-Instanz, damit sie Dateien von ihrem Computer direkt in diesen Ordner hochladen können.", + "overwrite warning": + "Über diesen Link hochgeladene Dateien werden direkt in diesem Ordner gespeichert. Wenn eine hochgeladene Datei denselben Namen und Pfad wie eine vorhandene Datei hat, wird die vorhandene Datei ersetzt.", + "create empty folder instead": "Stattdessen einen leeren Ordner erstellen", "link settings": "Linkeinstellungen", "link expires after": "Link läuft ab nach", "link validity aria label": "Gültigkeitsdauer des Upload-Links", diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 19abac2f4..ea7f4dcec 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -326,6 +326,9 @@ export const translations: Translations<"en"> = { S3FileRequestCreationDialog: { description: "Share this link with anyone, even someone without an account on this Onyxia instance, to let them upload files from their computer directly to this folder.", + "overwrite warning": + "Files uploaded through this link are saved directly in this folder. If an uploaded file has the same name and path as an existing file, the existing file will be replaced.", + "create empty folder instead": "Create an empty folder instead", "link settings": "Link settings", "link expires after": "Link expires after", "link validity aria label": "Upload link validity duration", diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index 4f71de21f..220b2e46d 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -466,6 +466,9 @@ export const translations: Translations<"es"> = { S3FileRequestCreationDialog: { description: "Comparte este enlace con cualquier persona, incluso con alguien sin cuenta en esta instancia de Onyxia, para que pueda subir archivos desde su ordenador directamente a esta carpeta.", + "overwrite warning": + "Los archivos subidos mediante este enlace se guardan directamente en esta carpeta. Si un archivo subido tiene el mismo nombre y la misma ruta que un archivo existente, se reemplazará el archivo existente.", + "create empty folder instead": "Crear una carpeta vacía en su lugar", "link settings": "Configuración del enlace", "link expires after": "El enlace caduca después de", "link validity aria label": "Duración de validez del enlace de subida", diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index a01e5339a..654d9aadc 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -458,6 +458,9 @@ export const translations: Translations<"fi"> = { S3FileRequestCreationDialog: { description: "Jaa tämä linkki kenelle tahansa, myös henkilölle, jolla ei ole tiliä tässä Onyxia-instanssissa, jotta hän voi ladata tiedostoja tietokoneeltaan suoraan tähän kansioon.", + "overwrite warning": + "Tämän linkin kautta ladatut tiedostot tallennetaan suoraan tähän kansioon. Jos ladatulla tiedostolla on sama nimi ja polku kuin olemassa olevalla tiedostolla, olemassa oleva tiedosto korvataan.", + "create empty folder instead": "Luo sen sijaan tyhjä kansio", "link settings": "Linkin asetukset", "link expires after": "Linkki vanhenee tämän ajan kuluttua", "link validity aria label": "Lähetyslinkin voimassaoloaika", diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 13ba2389f..f68130799 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -469,6 +469,9 @@ export const translations: Translations<"fr"> = { S3FileRequestCreationDialog: { description: "Partagez ce lien avec n’importe qui, même une personne sans compte sur cette instance Onyxia, pour lui permettre de téléverser des fichiers depuis son ordinateur directement dans ce dossier.", + "overwrite warning": + "Les fichiers téléversés via ce lien sont enregistrés directement dans ce dossier. Si un fichier téléversé a le même nom et le même chemin qu’un fichier existant, ce dernier sera remplacé.", + "create empty folder instead": "Créer plutôt un dossier vide", "link settings": "Paramètres du lien", "link expires after": "Expiration du lien", "link validity aria label": "Durée de validité du lien de téléversement", diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index e8942b7aa..aa434e96f 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -465,6 +465,9 @@ export const translations: Translations<"it"> = { S3FileRequestCreationDialog: { description: "Condividi questo link con chiunque, anche con chi non ha un account su questa istanza Onyxia, per consentire di caricare file dal proprio computer direttamente in questa cartella.", + "overwrite warning": + "I file caricati tramite questo link vengono salvati direttamente in questa cartella. Se un file caricato ha lo stesso nome e percorso di un file esistente, il file esistente verrà sostituito.", + "create empty folder instead": "Crea invece una cartella vuota", "link settings": "Impostazioni del link", "link expires after": "Il link scade dopo", "link validity aria label": "Durata di validità del link di caricamento", diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index e57e2fccb..f7d8730d2 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -463,6 +463,9 @@ export const translations: Translations<"nl"> = { S3FileRequestCreationDialog: { description: "Deel deze link met iedereen, ook met iemand zonder account op deze Onyxia-instantie, zodat diegene bestanden vanaf een computer rechtstreeks naar deze map kan uploaden.", + "overwrite warning": + "Bestanden die via deze link worden geüpload, worden rechtstreeks in deze map opgeslagen. Als een geüpload bestand dezelfde naam en hetzelfde pad heeft als een bestaand bestand, wordt het bestaande bestand vervangen.", + "create empty folder instead": "Maak in plaats daarvan een lege map", "link settings": "Linkinstellingen", "link expires after": "Link verloopt na", "link validity aria label": "Geldigheidsduur van de uploadlink", diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index f8225d931..53183b7bf 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -461,6 +461,9 @@ export const translations: Translations<"no"> = { S3FileRequestCreationDialog: { description: "Del denne lenken med hvem som helst, også personer uten konto på denne Onyxia-instansen, slik at de kan laste opp filer fra datamaskinen sin direkte til denne mappen.", + "overwrite warning": + "Filer som lastes opp via denne lenken, lagres direkte i denne mappen. Hvis en opplastet fil har samme navn og bane som en eksisterende fil, blir den eksisterende filen erstattet.", + "create empty folder instead": "Opprett en tom mappe i stedet", "link settings": "Lenkeinnstillinger", "link expires after": "Lenken utløper etter", "link validity aria label": "Opplastingslenkens gyldighet", diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index 4231e7a52..c8af86521 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -431,6 +431,9 @@ export const translations: Translations<"zh-CN"> = { S3FileRequestCreationDialog: { description: "将此链接分享给任何人,即使对方没有此 Onyxia 实例的帐户,也可以从计算机将文件直接上传到此文件夹。", + "overwrite warning": + "通过此链接上传的文件会直接保存到此文件夹。如果上传文件的名称和路径与现有文件相同,现有文件将被替换。", + "create empty folder instead": "改为创建一个空文件夹", "link settings": "链接设置", "link expires after": "链接有效期", "link validity aria label": "上传链接的有效期", diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 4e1f47cd2..6f7a8477a 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -271,9 +271,37 @@ function S3Explorer() { }); }; + const openDirectoryCreationDialog = async () => { + assert(!mainView.directoryCreationButton.isDisabled); + + const dPrefixSegment = new Deferred(); + + dialogProps.evtDirectoryCreationDialogOpen.post({ + exclude: mainView.directoryCreationButton.exclude, + resolveDoProceed: result => { + dPrefixSegment.resolve( + result.doProceed ? result.prefixSegment : undefined + ); + } + }); + + const prefixSegment = await dPrefixSegment.pr; + + if (prefixSegment === undefined) { + return; + } + + s3ExplorerUiController.createDirectory({ prefixSegment }); + }; + const onRequestFiles = mainView.isRequestFilesEnabled ? ({ s3Uri }: { s3Uri: S3Uri.TerminatedByDelimiter }) => - dialogProps.evtS3FileRequestCreationDialogOpen.post({ s3Uri }) + dialogProps.evtS3FileRequestCreationDialogOpen.post({ + s3Uri, + onCreateEmptyFolder: () => { + void openDirectoryCreationDialog(); + } + }) : undefined; return ( @@ -581,30 +609,8 @@ function S3Explorer() { icon={getIconUrlByName("CreateNewFolderOutlined")} label={t("create new folder")} disabled={mainView.directoryCreationButton.isDisabled} - onClick={async () => { - assert( - !mainView.directoryCreationButton.isDisabled - ); - - const dPrefixSegment = new Deferred(); - - dialogProps.evtDirectoryCreationDialogOpen.post({ - exclude: - mainView.directoryCreationButton.exclude, - resolveDoProceed: params => { - if (!params.doProceed) { - return; - } - - dPrefixSegment.resolve( - params.prefixSegment - ); - } - }); - - s3ExplorerUiController.createDirectory({ - prefixSegment: await dPrefixSegment.pr - }); + onClick={() => { + void openDirectoryCreationDialog(); }} /> diff --git a/web/src/ui/pages/s3Explorer/dialogs/S3FileRequestCreationDialog.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3FileRequestCreationDialog.tsx index bad520b15..d5f9636a9 100644 --- a/web/src/ui/pages/s3Explorer/dialogs/S3FileRequestCreationDialog.tsx +++ b/web/src/ui/pages/s3Explorer/dialogs/S3FileRequestCreationDialog.tsx @@ -12,6 +12,7 @@ import { declareComponentKeys, useTranslation } from "ui/i18n"; export type S3FileRequestCreationDialogProps = { evtOpen: Evt<{ s3Uri: S3Uri.TerminatedByDelimiter; + onCreateEmptyFolder: () => void; }>; }; @@ -38,7 +39,17 @@ function S3FileRequestCreationDialogContainer(props: S3FileRequestCreationDialog } + body={ + state === undefined ? undefined : ( + { + setState(undefined); + state.onCreateEmptyFolder(); + }} + /> + ) + } isOpen={state !== undefined} onClose={() => setState(undefined)} showCloseButton @@ -48,14 +59,15 @@ function S3FileRequestCreationDialogContainer(props: S3FileRequestCreationDialog const Body = withLoader<{ s3Uri: S3Uri.TerminatedByDelimiter; + onCreateEmptyFolder: () => void; }>({ loader: async ({ s3Uri }) => { const core = await getCore(); - core.functions.s3FileRequestCreationUiController.load({ s3Uri }); + await core.functions.s3FileRequestCreationUiController.load({ s3Uri }); }, FallbackComponent: () => null, - Component: () => { + Component: ({ onCreateEmptyFolder }) => { const mainView = useCoreState("s3FileRequestCreationUiController", "mainView"); const { functions: { s3FileRequestCreationUiController } @@ -74,6 +86,7 @@ const Body = withLoader<{ return ( ); } diff --git a/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.spec.md b/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.spec.md index 81aeca90f..5d35f6c74 100644 --- a/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.spec.md +++ b/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.spec.md @@ -23,6 +23,7 @@ Its purpose is to: export type S3FileRequestCreationDialogProps = { className?: string; folderName: string; + isEmptyPrefix: boolean; validityDuration: S3FileRequestCreationDialogProps.ValidityDuration; maxObjectSize: S3FileRequestCreationDialogProps.MaxObjectSize; uploadPageUrl: string | undefined; @@ -34,6 +35,7 @@ export type S3FileRequestCreationDialogProps = { maxObjectSize: S3FileRequestCreationDialogProps.MaxObjectSize; }) => void; retryGeneration: () => void; + createEmptyFolder: () => void; }; export namespace S3FileRequestCreationDialogProps { @@ -65,6 +67,11 @@ to this folder. Long folder names must wrap without breaking the layout. +When `isEmptyPrefix` is false, display a warning that files uploaded through the +link will replace existing files with the same name and path. The warning must +offer an accessible button styled as a link that invokes `createEmptyFolder()`. +Do not display the warning when `isEmptyPrefix` is true. + ## Link Settings Render two controlled selects: diff --git a/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.tsx b/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.tsx index 18f3f6074..bb6a5c3be 100644 --- a/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.tsx +++ b/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.tsx @@ -1,4 +1,5 @@ import FormControl from "@mui/material/FormControl"; +import MuiLink from "@mui/material/Link"; import MenuItem from "@mui/material/MenuItem"; import Select from "@mui/material/Select"; import { alpha } from "@mui/material/styles"; @@ -17,6 +18,7 @@ import { export type S3FileRequestCreationDialogProps = { className?: string; folderName: string; + isEmptyPrefix: boolean; validityDuration: S3FileRequestCreationDialogProps.ValidityDuration; maxObjectSize: S3FileRequestCreationDialogProps.MaxObjectSize; uploadPageUrl: string | undefined; @@ -28,6 +30,7 @@ export type S3FileRequestCreationDialogProps = { maxObjectSize: S3FileRequestCreationDialogProps.MaxObjectSize; }) => void; retryGeneration: () => void; + createEmptyFolder: () => void; }; export namespace S3FileRequestCreationDialogProps { @@ -56,13 +59,15 @@ export function S3FileRequestCreationDialog(props: S3FileRequestCreationDialogPr const { className, folderName, + isEmptyPrefix, validityDuration, maxObjectSize, uploadPageUrl, errorMessage, changeValidityDuration, changeMaxObjectSize, - retryGeneration + retryGeneration, + createEmptyFolder } = props; const { t } = useTranslation({ S3FileRequestCreationDialog }); @@ -79,6 +84,24 @@ export function S3FileRequestCreationDialog(props: S3FileRequestCreationDialogPr {t("description")} + {!isEmptyPrefix && ( +
+ +
+ + {t("overwrite warning")} + + + {t("create empty folder instead")} + +
+
+ )}
@@ -247,6 +270,37 @@ const useStyles = tss.withName({ S3FileRequestCreationDialog }).create(({ theme lineHeight: 1.55, maxWidth: 760 }, + overwriteWarning: { + display: "grid", + gridTemplateColumns: "24px minmax(0, 1fr)", + alignItems: "start", + gap: theme.spacing(1.5), + marginTop: theme.spacing(2.5), + padding: theme.spacing(2), + borderRadius: 10, + color: theme.colors.useCases.alertSeverity.warning.main, + border: `1px solid ${alpha( + theme.colors.useCases.alertSeverity.warning.main, + 0.35 + )}`, + backgroundColor: alpha(theme.colors.useCases.alertSeverity.warning.main, 0.08) + }, + overwriteWarningContent: { + minWidth: 0, + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: theme.spacing(1) + }, + overwriteWarningText: { + color: theme.colors.useCases.typography.textPrimary, + lineHeight: 1.5 + }, + createEmptyFolderLink: { + ...theme.typography.variants["label 1"].style, + color: theme.colors.useCases.typography.textFocus, + textAlign: "left" + }, settingsSection: { display: "flex", flexDirection: "column", @@ -343,6 +397,8 @@ const useStyles = tss.withName({ S3FileRequestCreationDialog }).create(({ theme const { i18n } = declareComponentKeys< | "description" + | "overwrite warning" + | "create empty folder instead" | "link settings" | "link expires after" | "link validity aria label" From a4738927152a6c590e64d4881a45c221c4de2f03 Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 2 Sep 2026 22:48:28 +0200 Subject: [PATCH 20/22] More transparency about how upload file works --- web/src/ui/i18n/resources/de.tsx | 10 +- web/src/ui/i18n/resources/en.tsx | 11 +- web/src/ui/i18n/resources/es.tsx | 11 +- web/src/ui/i18n/resources/fi.tsx | 10 +- web/src/ui/i18n/resources/fr.tsx | 11 +- web/src/ui/i18n/resources/it.tsx | 11 +- web/src/ui/i18n/resources/nl.tsx | 10 +- web/src/ui/i18n/resources/no.tsx | 11 +- web/src/ui/i18n/resources/zh-CN.tsx | 9 +- .../S3ExplorerMainView.spec.md | 13 +++ .../S3ExplorerMainView/S3ExplorerMainView.tsx | 107 +++++++++++++++--- 11 files changed, 168 insertions(+), 46 deletions(-) diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index 62c278613..da6dd714f 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -444,9 +444,13 @@ export const translations: Translations<"de"> = { "empty prefix description": "Lade Dateien hoch oder erstelle einen Ordner, um diesen Speicherort zu befüllen.", "empty prefix upload description": - "Lade hier Dateien hoch oder ziehe sie per Drag-and-drop in diesen Bereich.", + "Wähle aus, wie Dateien hier hinzugefügt werden sollen.", "upload files": "Dateien hochladen", - "upload files here": "Dateien hier hochladen", + "upload files from device description": + "Wähle Dateien auf deinem Computer aus, um sie hier hochzuladen.", + "create upload link": "Upload-Link erstellen", + "create upload link description": + "Erstelle einen teilbaren Link, über den eine andere Person hier Dateien hochladen kann.", "drop files here hint": "Lege Dateien irgendwo in diesem Bereich ab, um sie hochzuladen.", "new folder": "Neuer Ordner", @@ -488,7 +492,7 @@ export const translations: Translations<"de"> = { "generation failed": "Der Upload-Link konnte nicht generiert werden.", retry: "Erneut versuchen", "security note": - "Jede Person mit diesem Link kann bis zu dessen Ablauf Dateien in diesen Ordner hochladen. Der Link gewährt keinen Zugriff zum Anzeigen oder Herunterladen vorhandener Dateien.", + "Jede Person mit diesem Link kann bis zu dessen Ablauf Dateien in diesen Ordner hochladen. Der Link gewährt keinen Zugriff zum Anzeigen oder Herunterladen vorhandener Dateien. Eine hochgeladene Datei ersetzt jedoch eine vorhandene Datei, wenn Name und Pfad übereinstimmen.", "validity duration one hour": "1 Stunde", "validity duration one day": "1 Tag", "validity duration one week": "1 Woche", diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index ea7f4dcec..8ce149e46 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -296,10 +296,13 @@ export const translations: Translations<"en"> = { "this prefix is empty": "This prefix is empty", "empty prefix description": "Upload files or create a folder to start populating this location.", - "empty prefix upload description": - "Upload files here or drag and drop them into this area.", + "empty prefix upload description": "Choose how files should be added here.", "upload files": "Upload files", - "upload files here": "Upload files here", + "upload files from device description": + "Select files from your computer to upload here.", + "create upload link": "Create upload link", + "create upload link description": + "Generate a shareable link that lets someone else upload files here.", "drop files here hint": "Drop files anywhere in this area to upload them.", "new folder": "New folder", name: "Name", @@ -340,7 +343,7 @@ export const translations: Translations<"en"> = { "generation failed": "The upload link could not be generated.", retry: "Retry", "security note": - "Anyone with this link can upload files to this folder until it expires. The link does not give access to view or download existing files.", + "Anyone with this link can upload files to this folder until it expires. The link does not allow them to view or download existing files. However, an uploaded file will replace an existing file if it has the same name and path.", "validity duration one hour": "1 hour", "validity duration one day": "1 day", "validity duration one week": "1 week", diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index 220b2e46d..7fe0a72f0 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -435,10 +435,13 @@ export const translations: Translations<"es"> = { "this prefix is empty": "Este prefijo está vacío", "empty prefix description": "Sube archivos o crea una carpeta para empezar a llenar esta ubicación.", - "empty prefix upload description": - "Sube archivos aquí o arrástralos y suéltalos en esta zona.", + "empty prefix upload description": "Elige cómo quieres añadir archivos aquí.", "upload files": "Subir archivos", - "upload files here": "Subir archivos aquí", + "upload files from device description": + "Selecciona archivos de tu ordenador para subirlos aquí.", + "create upload link": "Crear enlace de subida", + "create upload link description": + "Genera un enlace para compartir que permita a otra persona subir archivos aquí.", "drop files here hint": "Suelta archivos en cualquier lugar de esta zona para subirlos.", "new folder": "Nueva carpeta", @@ -480,7 +483,7 @@ export const translations: Translations<"es"> = { "generation failed": "No se ha podido generar el enlace de subida.", retry: "Reintentar", "security note": - "Cualquier persona que tenga este enlace puede subir archivos a esta carpeta hasta que caduque. El enlace no permite ver ni descargar los archivos existentes.", + "Cualquier persona que tenga este enlace puede subir archivos a esta carpeta hasta que caduque. El enlace no permite ver ni descargar los archivos existentes. Sin embargo, un archivo subido reemplazará a uno existente si tiene el mismo nombre y la misma ruta.", "validity duration one hour": "1 hora", "validity duration one day": "1 día", "validity duration one week": "1 semana", diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 654d9aadc..dba0918d3 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -428,9 +428,13 @@ export const translations: Translations<"fi"> = { "empty prefix description": "Lataa tiedostoja tai luo kansio aloittaaksesi tämän sijainnin täyttämisen.", "empty prefix upload description": - "Lataa tiedostoja tähän tai vedä ja pudota ne tälle alueelle.", + "Valitse, miten tiedostoja lisätään tähän sijaintiin.", "upload files": "Lataa tiedostoja", - "upload files here": "Lataa tiedostoja tähän", + "upload files from device description": + "Valitse tietokoneeltasi tiedostot, jotka haluat ladata tähän.", + "create upload link": "Luo latauslinkki", + "create upload link description": + "Luo jaettava linkki, jonka kautta toinen henkilö voi ladata tiedostoja tähän.", "drop files here hint": "Pudota tiedostoja mihin tahansa tälle alueelle ladataksesi ne.", "new folder": "Uusi kansio", @@ -472,7 +476,7 @@ export const translations: Translations<"fi"> = { "generation failed": "Lähetyslinkkiä ei voitu luoda.", retry: "Yritä uudelleen", "security note": - "Kuka tahansa linkin saanut voi ladata tiedostoja tähän kansioon linkin vanhenemiseen asti. Linkki ei anna oikeutta tarkastella tai ladata olemassa olevia tiedostoja.", + "Kuka tahansa linkin saanut voi ladata tiedostoja tähän kansioon linkin vanhenemiseen asti. Linkki ei anna oikeutta tarkastella tai ladata olemassa olevia tiedostoja. Ladattu tiedosto kuitenkin korvaa olemassa olevan tiedoston, jos niillä on sama nimi ja polku.", "validity duration one hour": "1 tunti", "validity duration one day": "1 päivä", "validity duration one week": "1 viikko", diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index f68130799..12b3639a8 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -438,10 +438,13 @@ export const translations: Translations<"fr"> = { "this prefix is empty": "Ce préfixe est vide", "empty prefix description": "Téléversez des fichiers ou créez un dossier pour commencer à remplir cet emplacement.", - "empty prefix upload description": - "Téléversez des fichiers ici ou glissez-déposez-les dans cette zone.", + "empty prefix upload description": "Choisissez comment ajouter des fichiers ici.", "upload files": "Téléverser des fichiers", - "upload files here": "Téléverser des fichiers ici", + "upload files from device description": + "Sélectionnez sur votre ordinateur les fichiers à téléverser ici.", + "create upload link": "Créer un lien de téléversement", + "create upload link description": + "Générez un lien à partager pour permettre à une autre personne de téléverser des fichiers ici.", "drop files here hint": "Déposez des fichiers n'importe où dans cette zone pour les téléverser.", "new folder": "Nouveau dossier", @@ -483,7 +486,7 @@ export const translations: Translations<"fr"> = { "generation failed": "Le lien de téléversement n’a pas pu être généré.", retry: "Réessayer", "security note": - "Toute personne disposant de ce lien peut téléverser des fichiers dans ce dossier jusqu’à son expiration. Le lien ne permet pas de voir ni de télécharger les fichiers existants.", + "Toute personne disposant de ce lien peut téléverser des fichiers dans ce dossier jusqu’à son expiration. Le lien ne permet pas de voir ni de télécharger les fichiers existants. Toutefois, un fichier téléversé remplacera un fichier existant s’il a le même nom et le même chemin.", "validity duration one hour": "1 heure", "validity duration one day": "1 jour", "validity duration one week": "1 semaine", diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index aa434e96f..e83a804c2 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -434,10 +434,13 @@ export const translations: Translations<"it"> = { "this prefix is empty": "Questo prefisso è vuoto", "empty prefix description": "Carica file o crea una cartella per iniziare a popolare questa posizione.", - "empty prefix upload description": - "Carica file qui oppure trascinali e rilasciali in quest'area.", + "empty prefix upload description": "Scegli come aggiungere file qui.", "upload files": "Carica file", - "upload files here": "Carica file qui", + "upload files from device description": + "Seleziona dal computer i file da caricare qui.", + "create upload link": "Crea link di caricamento", + "create upload link description": + "Genera un link condivisibile che consenta a un'altra persona di caricare file qui.", "drop files here hint": "Rilascia file in qualsiasi punto di quest'area per caricarli.", "new folder": "Nuova cartella", @@ -479,7 +482,7 @@ export const translations: Translations<"it"> = { "generation failed": "Non è stato possibile generare il link di caricamento.", retry: "Riprova", "security note": - "Chiunque disponga di questo link può caricare file in questa cartella fino alla scadenza. Il link non consente di visualizzare o scaricare i file esistenti.", + "Chiunque disponga di questo link può caricare file in questa cartella fino alla scadenza. Il link non consente di visualizzare o scaricare i file esistenti. Tuttavia, un file caricato sostituirà un file esistente se ha lo stesso nome e percorso.", "validity duration one hour": "1 ora", "validity duration one day": "1 giorno", "validity duration one week": "1 settimana", diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index f7d8730d2..d0d7a086e 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -434,9 +434,13 @@ export const translations: Translations<"nl"> = { "empty prefix description": "Upload bestanden of maak een map om deze locatie te vullen.", "empty prefix upload description": - "Upload hier bestanden of sleep ze naar dit gebied.", + "Kies hoe bestanden hier moeten worden toegevoegd.", "upload files": "Bestanden uploaden", - "upload files here": "Bestanden hier uploaden", + "upload files from device description": + "Selecteer bestanden op je computer om ze hier te uploaden.", + "create upload link": "Uploadlink maken", + "create upload link description": + "Maak een deelbare link waarmee iemand anders hier bestanden kan uploaden.", "drop files here hint": "Sleep bestanden ergens in dit gebied om ze te uploaden.", "new folder": "Nieuwe map", name: "Naam", @@ -477,7 +481,7 @@ export const translations: Translations<"nl"> = { "generation failed": "De uploadlink kon niet worden gegenereerd.", retry: "Opnieuw proberen", "security note": - "Iedereen met deze link kan bestanden naar deze map uploaden totdat de link verloopt. De link geeft geen toegang om bestaande bestanden te bekijken of te downloaden.", + "Iedereen met deze link kan bestanden naar deze map uploaden totdat de link verloopt. De link geeft geen toegang om bestaande bestanden te bekijken of te downloaden. Een geüpload bestand vervangt echter een bestaand bestand als de naam en het pad hetzelfde zijn.", "validity duration one hour": "1 uur", "validity duration one day": "1 dag", "validity duration one week": "1 week", diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 53183b7bf..d33f4b76e 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -430,10 +430,13 @@ export const translations: Translations<"no"> = { "this prefix is empty": "Dette prefikset er tomt", "empty prefix description": "Last opp filer eller opprett en mappe for å begynne å fylle denne plasseringen.", - "empty prefix upload description": - "Last opp filer her eller dra og slipp dem i dette området.", + "empty prefix upload description": "Velg hvordan filer skal legges til her.", "upload files": "Last opp filer", - "upload files here": "Last opp filer her", + "upload files from device description": + "Velg filer fra datamaskinen din for å laste dem opp her.", + "create upload link": "Opprett opplastingslenke", + "create upload link description": + "Lag en delbar lenke som lar en annen person laste opp filer her.", "drop files here hint": "Slipp filer hvor som helst i dette området for å laste dem opp.", "new folder": "Ny mappe", @@ -475,7 +478,7 @@ export const translations: Translations<"no"> = { "generation failed": "Opplastingslenken kunne ikke genereres.", retry: "Prøv igjen", "security note": - "Alle med denne lenken kan laste opp filer til mappen frem til lenken utløper. Lenken gir ikke tilgang til å vise eller laste ned eksisterende filer.", + "Alle med denne lenken kan laste opp filer til mappen frem til lenken utløper. Lenken gir ikke tilgang til å vise eller laste ned eksisterende filer. En opplastet fil erstatter imidlertid en eksisterende fil hvis de har samme navn og bane.", "validity duration one hour": "1 time", "validity duration one day": "1 dag", "validity duration one week": "1 uke", diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index c8af86521..4301b44ea 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -403,9 +403,12 @@ export const translations: Translations<"zh-CN"> = { `没有键以 "${s3UriStr}" 开头的对象。`, "this prefix is empty": "此前缀为空", "empty prefix description": "上传文件或创建文件夹以开始填充此位置。", - "empty prefix upload description": "在此上传文件,或将文件拖放到此区域。", + "empty prefix upload description": "选择要如何在此处添加文件。", "upload files": "上传文件", - "upload files here": "在此上传文件", + "upload files from device description": "从计算机中选择要上传到此处的文件。", + "create upload link": "创建上传链接", + "create upload link description": + "生成一个可共享的链接,让其他人可以将文件上传到此处。", "drop files here hint": "将文件拖放到此区域中的任意位置即可上传。", "new folder": "新建文件夹", name: "名称", @@ -445,7 +448,7 @@ export const translations: Translations<"zh-CN"> = { "generation failed": "无法生成上传链接。", retry: "重试", "security note": - "在链接过期之前,任何拥有此链接的人都可以将文件上传到此文件夹。此链接不能用于查看或下载现有文件。", + "在链接过期之前,任何拥有此链接的人都可以将文件上传到此文件夹。此链接不能用于查看或下载现有文件。但是,如果上传文件与现有文件的名称和路径相同,现有文件将被替换。", "validity duration one hour": "1 小时", "validity duration one day": "1 天", "validity duration one week": "1 周", diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md index 8341c5d74..67b5c37da 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md @@ -390,6 +390,19 @@ Rules: - The overlay should confirm the import action - The overlay should not repeat the current S3 URI or prefix label +# Empty Prefix + +When the listed prefix is empty, present two distinct ways to add files: + +- upload files from the current device through `onPutObjects` +- create a shareable upload link through `onRequestFiles`, when that callback is + available + +The upload-link action must pass the currently listed prefix to +`onRequestFiles`. Each action should include concise supporting text, while the +back action remains visually secondary. Do not render the upload-link choice +when `onRequestFiles` is undefined. + # Error state When: `listedPrefix.isErrored === true` diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx index c3142dd21..7bcc4953f 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx @@ -1023,16 +1023,71 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) {
{t("empty prefix upload description")}
+
+
+ +
+ {t( + "upload files from device description" + )} +
+
+ {onRequestFiles === undefined ? null : ( +
+ +
+ {t( + "create upload link description" + )} +
+
+ )} +
+
+ {t("drop files here hint")} +
-
-
- {t("drop files here hint")} -
) @@ -1397,7 +1449,32 @@ const useStyles = tss }, emptyStateDropHint: { color: theme.colors.useCases.typography.textSecondary, - marginTop: theme.spacing(0.5) + marginTop: theme.spacing(2), + fontStyle: "italic" + }, + emptyStateChoices: { + width: "min(580px, 100%)", + display: "flex", + alignItems: "flex-start", + justifyContent: "center", + flexWrap: "wrap", + gap: theme.spacing(3), + marginTop: theme.spacing(1) + }, + emptyStateChoice: { + width: "min(270px, 100%)", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: theme.spacing(1) + }, + emptyStateChoiceButton: { + width: "100%" + }, + emptyStateChoiceDescription: { + ...theme.typography.variants["body 2"].style, + color: theme.colors.useCases.typography.textSecondary, + lineHeight: 1.45 }, emptyStateActions: { display: "flex", @@ -1908,7 +1985,9 @@ const { i18n } = declareComponentKeys< | "empty prefix description" | "empty prefix upload description" | "upload files" - | "upload files here" + | "upload files from device description" + | "create upload link" + | "create upload link description" | "drop files here hint" | "new folder" | "name" From ca1d6c2e78733a50994814e16a90afb00bd48059 Mon Sep 17 00:00:00 2001 From: garronej Date: Thu, 3 Sep 2026 11:03:50 +0200 Subject: [PATCH 21/22] Fix the way the share link is rendered --- .../S3DialogPrimitives/S3DialogPrimitives.tsx | 22 +++++++++++++++---- .../S3FileRequestCreationDialog.spec.md | 6 ++++- .../S3FileRequestCreationDialog.tsx | 10 +++++++-- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/web/src/ui/shared/codex/S3DialogPrimitives/S3DialogPrimitives.tsx b/web/src/ui/shared/codex/S3DialogPrimitives/S3DialogPrimitives.tsx index 3e996a9f8..fbee4f30b 100644 --- a/web/src/ui/shared/codex/S3DialogPrimitives/S3DialogPrimitives.tsx +++ b/web/src/ui/shared/codex/S3DialogPrimitives/S3DialogPrimitives.tsx @@ -110,24 +110,31 @@ export function S3DialogCopyUrlField(props: { } export function S3DialogCopyPlainUrlField(props: { - value: string; + value: string | undefined; + pendingText?: string; copyLabel?: string; ariaLabel: string; onCopied?: () => void; + isMultiline?: boolean; }) { - const { classes } = useStyles_S3DialogCopyField(); + const { isMultiline = true } = props; + const { classes, cx } = useStyles_S3DialogCopyField(); return ( ( {value} @@ -539,6 +546,8 @@ const useStyles_S3DialogCopyField = tss display: "flex", alignItems: "center", gap: theme.spacing(1.5), + width: "100%", + maxWidth: "100%", minWidth: 0, minHeight: 52, padding: `${theme.spacing(0.75)}px ${theme.spacing(1.5)}px ${theme.spacing( @@ -651,6 +660,11 @@ const useStyles_S3DialogCopyField = tss borderRadius: 4 } }, + plainUrlPreviewSingleLine: { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" + }, urlLine: { display: "flex", alignItems: "baseline", diff --git a/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.spec.md b/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.spec.md index 5d35f6c74..7c61b2ba6 100644 --- a/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.spec.md +++ b/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.spec.md @@ -57,6 +57,9 @@ The component renders a regular box composed of: The parent owns modal chrome, title, close button, URL generation, state updates, and lifecycle. +The component has a 760px content width and shrinks to fit narrower containers. +Long generated URLs must never increase that width. + # Rendering Rules ## Destination Folder @@ -124,7 +127,8 @@ When `errorMessage === undefined` and `uploadPageUrl === undefined`: When `errorMessage === undefined` and `uploadPageUrl !== undefined`: -- display the URL using the standard S3 dialog URL preview +- display the opaque Onyxia URL as neutral, single-line link text +- truncate the visible text when needed instead of exposing or emphasizing the URL structure - preserve the complete URL for navigation and copying - let the user open the URL in a new browser tab - let the user copy the complete URL diff --git a/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.tsx b/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.tsx index bb6a5c3be..d26b4be94 100644 --- a/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.tsx +++ b/web/src/ui/shared/codex/S3FileRequestCreationDialog/S3FileRequestCreationDialog.tsx @@ -11,7 +11,7 @@ import { tss } from "tss"; import { assert, type Equals } from "tsafe/assert"; import { declareComponentKeys, useTranslation } from "ui/i18n"; import { - S3DialogCopyUrlField, + S3DialogCopyPlainUrlField, S3DialogItemSummary } from "ui/shared/codex/S3DialogPrimitives"; @@ -170,10 +170,11 @@ export function S3FileRequestCreationDialog(props: S3FileRequestCreationDialogPr
{t("upload link")} {errorMessage === undefined ? ( - ) : (
@@ -241,6 +242,8 @@ const useStyles = tss.withName({ S3FileRequestCreationDialog }).create(({ theme root: { display: "flex", flexDirection: "column", + width: 760, + maxWidth: "100%", boxSizing: "border-box" }, folderSection: { @@ -360,7 +363,10 @@ const useStyles = tss.withName({ S3FileRequestCreationDialog }).create(({ theme display: "flex", flexDirection: "column", gap: theme.spacing(2), + width: "100%", + maxWidth: "100%", minWidth: 0, + overflow: "hidden", paddingTop: theme.spacing(3), paddingBottom: theme.spacing(3) }, From 3d70960e3dfac17faf839d21bc25551ced29478e Mon Sep 17 00:00:00 2001 From: garronej Date: Thu, 3 Sep 2026 11:14:49 +0200 Subject: [PATCH 22/22] Rename the route --- web/src/ui/pages/s3FileRequest/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/ui/pages/s3FileRequest/route.ts b/web/src/ui/pages/s3FileRequest/route.ts index 121eb16cb..e062ecd46 100644 --- a/web/src/ui/pages/s3FileRequest/route.ts +++ b/web/src/ui/pages/s3FileRequest/route.ts @@ -15,7 +15,7 @@ export const routeDefs = { }) ) }, - () => `/s3FileRequest` + () => `/upload-files` ) };