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: [] diff --git a/web/package.json b/web/package.json index 5576f025b..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", @@ -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 26fff88fd..3116bef3b 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 `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: { + s3Uri: S3Uri.TerminatedByDelimiter; + validityDurationSecond: number; + maxObjectSizeInBytes: number | undefined; + }) => Promise; + getUnsignedObjectHttpUrl: (params: { s3Uri: S3Uri.NonTerminatedByDelimiter; isForDirectDownload: boolean; @@ -77,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 3aba184a3..eba2db47a 100644 --- a/web/src/core/usecases/index.ts +++ b/web/src/core/usecases/index.ts @@ -24,6 +24,8 @@ 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"; +import * as s3FileRequestCreationUiController from "./s3FileRequestCreationUiController"; export const usecases = { autoLogoutCountdown, @@ -51,5 +53,7 @@ export const usecases = { s3ProfilesManagement, s3ShareObjectUiController, s3ProfilesCreationUiController, - s3ExplorerUiController + s3ExplorerUiController, + s3FileRequestUiController, + s3FileRequestCreationUiController }; 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/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/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 205cbe8d7..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; @@ -98,6 +95,8 @@ export type MainView = { | undefined; commandLogsEntries: State.CommandLogsEntry[]; + + profileNameForSharing: string | undefined; }; export namespace MainView { @@ -113,8 +112,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 & { @@ -324,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; @@ -346,88 +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 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 - }; - - 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, - profileNameForSharing: (() => { - if (profileName_anonymous === undefined) { - return undefined; - } - - if (isAnonymousS3Profile) { - return profileName_anonymous; - } - - if (policy.isPublic) { - return profileName_anonymous; - } - - // 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) { - return undefined; - } - - return profileName_anonymous; - })(), - policy - }); - } - default: - assert>(false); - } - } + const items_actual: MainView.Item[] = listedPrefix_state.current.items.map(item => + stateItemToSelectorItem({ + item, + isSharingPublicFolderFeatureEnabled, + bucketPoliciesByBucket, + isAnonymousS3Profile + }) ); const items: MainView.Item[] = []; @@ -795,6 +722,7 @@ const mainView = createSelector( isListing, listedPrefix, commandLogsEntries, + profileName_anonymous, ( profileSelect, bookmarks, @@ -806,7 +734,8 @@ const mainView = createSelector( objectRendering, isListing, listedPrefix, - commandLogsEntries + commandLogsEntries, + profileNameForSharing ): MainView => ({ profileSelect, bookmarks, @@ -818,7 +747,8 @@ const mainView = createSelector( objectRendering, isListing, listedPrefix, - commandLogsEntries + commandLogsEntries, + profileNameForSharing }) ); 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/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..0cfdc7b8c --- /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 = S3Client.PresignedPost; + +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..eb2c30df7 --- /dev/null +++ b/web/src/core/usecases/s3FileRequestUiController/thunks.ts @@ -0,0 +1,186 @@ +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); + + xhr.open("POST", presignedPost.url); + xhr.send(formData); + }); + } +} satisfies Thunks; diff --git a/web/src/ui/App/GlobalAlert.tsx b/web/src/ui/App/GlobalAlert.tsx index d72a39de9..6e0089bd5 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,12 @@ export const GlobalAlert = memo( const { css, theme } = useStyles(); + const route = useRoute(); + + if (route.name === "s3FileRequest") { + return null; + } + return ( { const { urlToLink } = useUrlToLink(); + if (route.name === "s3FileRequest") { + return null; + } + return ( = { "create new folder": "Neuen Ordner erstellen", "download file": "Datei herunterladen" }, + 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.", + "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": "Dateien anfordern" + }, S3SharePrefixDialogContainer: { "dialog title": "Ordner teilen" }, @@ -253,6 +279,7 @@ export const translations: Translations<"de"> = { "new s3 profile": "Neues S3-Profil" }, S3SelectionActionBar: { + "request files": "Dateien anfordern", download: "Herunterladen", delete: "Löschen", "copy s3 uri": "S3-URI kopieren", @@ -363,6 +390,7 @@ export const translations: Translations<"de"> = { "make private": "Privat machen" }, S3ExplorerMainView: { + "request files": "Dateien anfordern", "create prefix dialog title": "Präfix erstellen", "create prefix dialog subtitle": "Erstelle ein neues Präfix im aktuellen S3-Speicherort.", @@ -442,6 +470,26 @@ export const translations: Translations<"de"> = { "validity duration one week": "1 Woche", "selected duration": "die ausgewählte Dauer" }, + 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.", + "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", "public sharing note": diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 13c79f1e7..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": @@ -338,9 +359,35 @@ 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" }, + S3FileRequestCreationDialogContainer: { + "dialog title": "Request files" + }, S3SharePrefixDialogContainer: { "dialog title": "Share folder" }, @@ -386,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 95126cb78..73dbe2d93 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -208,9 +208,35 @@ export const translations: Translations<"es"> = { "create new folder": "Crear nueva carpeta", "download file": "Descargar archivo" }, + 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.", + "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": "Solicitar archivos" + }, S3SharePrefixDialogContainer: { "dialog title": "Compartir carpeta" }, @@ -248,6 +274,7 @@ export const translations: Translations<"es"> = { "new s3 profile": "Nuevo perfil S3" }, S3SelectionActionBar: { + "request files": "Solicitar archivos", download: "Descargar", delete: "Eliminar", "copy s3 uri": "Copiar URI S3", @@ -356,6 +383,7 @@ export const translations: Translations<"es"> = { "make private": "Hacer privado" }, S3ExplorerMainView: { + "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.", @@ -434,6 +462,26 @@ export const translations: Translations<"es"> = { "validity duration one week": "1 semana", "selected duration": "la duración seleccionada" }, + 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.", + "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", "public sharing note": diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 288690dca..544bc4afc 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -205,9 +205,35 @@ export const translations: Translations<"fi"> = { "create new folder": "Luo uusi kansio", "download file": "lataa tiedosto" }, + 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ä.", + "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": "Pyydä tiedostoja" + }, S3SharePrefixDialogContainer: { "dialog title": "Jaa kansio" }, @@ -245,6 +271,7 @@ export const translations: Translations<"fi"> = { "new s3 profile": "Uusi S3-profiili" }, S3SelectionActionBar: { + "request files": "Pyydä tiedostoja", download: "Lataa", delete: "Poista", "copy s3 uri": "Kopioi S3-URI", @@ -349,6 +376,7 @@ export const translations: Translations<"fi"> = { "make private": "Tee yksityiseksi" }, S3ExplorerMainView: { + "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", @@ -426,6 +454,26 @@ export const translations: Translations<"fi"> = { "validity duration one week": "1 viikko", "selected duration": "valittu kesto" }, + 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.", + "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", "public sharing note": diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 23b526f97..65639b7b5 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -211,9 +211,35 @@ 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" }, + S3FileRequestCreationDialogContainer: { + "dialog title": "Demander des fichiers" + }, S3SharePrefixDialogContainer: { "dialog title": "Partager le dossier" }, @@ -259,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é", @@ -372,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é", @@ -437,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 ddd296668..dc422a7d6 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -208,9 +208,35 @@ export const translations: Translations<"it"> = { "create new folder": "Crea nuova cartella", "download file": "scarica file" }, + 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.", + "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": "Richiedi file" + }, S3SharePrefixDialogContainer: { "dialog title": "Condividi cartella" }, @@ -248,6 +274,7 @@ export const translations: Translations<"it"> = { "new s3 profile": "Nuovo profilo S3" }, S3SelectionActionBar: { + "request files": "Richiedi file", download: "Scarica", delete: "Elimina", "copy s3 uri": "Copia URI S3", @@ -354,6 +381,7 @@ export const translations: Translations<"it"> = { "make private": "Rendi privato" }, S3ExplorerMainView: { + "request files": "Richiedi file", "create prefix dialog title": "Crea prefisso", "create prefix dialog subtitle": "Crea un nuovo prefisso nella posizione S3 corrente.", @@ -433,6 +461,26 @@ export const translations: Translations<"it"> = { "validity duration one week": "1 settimana", "selected duration": "la durata selezionata" }, + 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.", + "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", "public sharing note": diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 48c86bf8c..576205052 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -209,9 +209,36 @@ export const translations: Translations<"nl"> = { "create new folder": "Nieuwe map maken", "download file": "bestand downloaden" }, + 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.", + "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": "Bestanden aanvragen" + }, S3SharePrefixDialogContainer: { "dialog title": "Map delen" }, @@ -249,6 +276,7 @@ export const translations: Translations<"nl"> = { "new s3 profile": "Nieuw S3-profiel" }, S3SelectionActionBar: { + "request files": "Bestanden aanvragen", download: "Downloaden", delete: "Verwijderen", "copy s3 uri": "S3-URI kopiëren", @@ -353,6 +381,7 @@ export const translations: Translations<"nl"> = { "make private": "Privé maken" }, S3ExplorerMainView: { + "request files": "Bestanden aanvragen", "create prefix dialog title": "Prefix aanmaken", "create prefix dialog subtitle": "Maak een nieuwe prefix aan binnen de huidige S3-locatie.", @@ -431,6 +460,26 @@ export const translations: Translations<"nl"> = { "validity duration one week": "1 week", "selected duration": "de geselecteerde duur" }, + 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.", + "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", "public sharing note": diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 8f943ac53..68d84e86f 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -205,9 +205,35 @@ export const translations: Translations<"no"> = { "create new folder": "Opprett ny mappe", "download file": "last ned fil" }, + 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.", + "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": "Be om filer" + }, S3SharePrefixDialogContainer: { "dialog title": "Del mappe" }, @@ -245,6 +271,7 @@ export const translations: Translations<"no"> = { "new s3 profile": "Ny S3-profil" }, S3SelectionActionBar: { + "request files": "Be om filer", download: "Last ned", delete: "Slett", "copy s3 uri": "Kopier S3-URI", @@ -350,6 +377,7 @@ export const translations: Translations<"no"> = { "make private": "Gjør privat" }, S3ExplorerMainView: { + "request files": "Be om filer", "create prefix dialog title": "Opprett prefiks", "create prefix dialog subtitle": "Opprett et nytt prefiks i gjeldende S3-plassering.", @@ -429,6 +457,26 @@ export const translations: Translations<"no"> = { "validity duration one week": "1 uke", "selected duration": "den valgte varigheten" }, + 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.", + "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", "public sharing note": diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index 5e0a19f78..b9df6b7ea 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -186,9 +186,34 @@ export const translations: Translations<"zh-CN"> = { "create new folder": "创建新文件夹", "download file": "下载文件" }, + S3FileRequest: { + "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": "请求文件" + }, S3SharePrefixDialogContainer: { "dialog title": "共享文件夹" }, @@ -226,6 +251,7 @@ export const translations: Translations<"zh-CN"> = { "new s3 profile": "新建 S3 配置文件" }, S3SelectionActionBar: { + "request files": "请求文件", download: "下载", delete: "删除", "copy s3 uri": "复制 S3 URI", @@ -328,6 +354,7 @@ export const translations: Translations<"zh-CN"> = { "make private": "设为私有" }, S3ExplorerMainView: { + "request files": "请求文件", "create prefix dialog title": "创建前缀", "create prefix dialog subtitle": "在当前 S3 位置内创建一个新前缀。", "prefix name field label": "前缀名称", @@ -400,6 +427,26 @@ export const translations: Translations<"zh-CN"> = { "validity duration one week": "1 周", "selected duration": "所选时长" }, + S3FileRequestCreationDialog: { + 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", "public sharing note": diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index cdc38feed..3a99e4a78 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -28,8 +28,11 @@ 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 | 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/index.ts b/web/src/ui/pages/index.ts index 5bea643c7..b5cb43cd9 100644 --- a/web/src/ui/pages/index.ts +++ b/web/src/ui/pages/index.ts @@ -12,8 +12,8 @@ import * as document from "./document"; import * as sqlOlapShell from "./sqlOlapShell"; import * as dataExplorer from "./dataExplorer"; import * as dataCollection from "./dataCollection"; - import * as s3Explorer from "./s3Explorer"; +import * as s3FileRequest from "./s3FileRequest"; export const pages = { account, @@ -28,7 +28,8 @@ export const pages = { sqlOlapShell, dataExplorer, dataCollection, - s3Explorer + s3Explorer, + s3FileRequest }; export const { routeDefs } = mergeRouteDefs({ pages }); diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index ea3d21901..1b0066a36 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() }) ); @@ -617,6 +618,7 @@ function S3Explorer() { })} isListing={mainView.isListing} listedPrefix={mainView.listedPrefix} + profileNameForSharing={mainView.profileNameForSharing} onNavigateBack={s3ExplorerUiController.navigateBack} onNavigate={({ s3Uri }) => s3ExplorerUiController.listPrefix({ @@ -660,6 +662,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/Page.tsx b/web/src/ui/pages/s3FileRequest/Page.tsx new file mode 100644 index 000000000..5e63e435e --- /dev/null +++ b/web/src/ui/pages/s3FileRequest/Page.tsx @@ -0,0 +1,741 @@ +import { getRoute } from "ui/routes"; +import { routeGroup } from "./route"; +import { assert } from "tsafe"; +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"; + +const Page = withLoader({ + loader, + Component: S3FileRequest +}); +export default Page; + +async function loader() { + const route = getRoute(); + assert(routeGroup.has(route)); + + const core = await getCore(); + + core.functions.s3FileRequestUiController.load({ + presignedPost: route.params.presignedPost + }); +} + +function S3FileRequest() { + 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/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..121eb16cb --- /dev/null +++ b/web/src/ui/pages/s3FileRequest/route.ts @@ -0,0 +1,22 @@ +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 = S3Client.PresignedPost; + +export const routeDefs = { + s3FileRequest: defineRoute( + { + presignedPost: param.query.ofType( + id>({ + parse: raw => JSON.parse(raw), + stringify: value => JSON.stringify(value) + }) + ) + }, + () => `/s3FileRequest` + ) +}; + +export const routeGroup = createGroup(routeDefs); 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 93a31d453..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, @@ -171,6 +172,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 +238,7 @@ function StatefulExplorer( | "onDownload" | "onShareObject" | "onSharePrefix" + | "onRequestFiles" | "onBookmark" | "bookmarkedS3Uris" | "onChangePrefixPolicy" @@ -291,8 +294,8 @@ function StatefulExplorer( }, uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: false, canBeMadePublic: true }, - profileNameForSharing: undefined + publicAccessAction: "make public", + shouldShowShareAction: false } ]); }} @@ -352,6 +355,9 @@ function StatefulExplorer( onSharePrefix={params => { action("sharePrefix")(params); }} + onRequestFiles={params => { + action("requestFiles")(params); + }} onBookmark={({ s3Uri }) => { action("bookmark")(s3Uri); }} @@ -370,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" }; }) ); @@ -392,11 +396,12 @@ function StatefulExplorer( export const Playground: Story = { args: placeholderArgs, - render: ({ className, isListing, isUploadDisabled }) => ( + render: ({ className, isListing, isUploadDisabled, profileNameForSharing }) => ( ) }; @@ -406,11 +411,12 @@ export const ListingInProgress: Story = { ...placeholderArgs, isListing: true }, - render: ({ className, isListing, isUploadDisabled }) => ( + render: ({ className, isListing, isUploadDisabled, profileNameForSharing }) => ( ) }; @@ -418,6 +424,7 @@ export const ListingInProgress: Story = { export const EmptyPrefix: Story = { args: { isListing: false, + profileNameForSharing: "anonymous", listedPrefix: { s3Uri: defaultPrefix, isErrored: false, @@ -432,6 +439,7 @@ export const EmptyPrefix: Story = { onDownload: action("download"), onShareObject: action("shareObject"), onSharePrefix: action("sharePrefix"), + onRequestFiles: action("requestFiles"), onBookmark: action("bookmark"), bookmarkedS3Uris: [], onChangePrefixPolicy: action("changePrefixPolicy"), @@ -496,6 +504,7 @@ function FullyQualifiedObjectExplorer(props: S3ExplorerMainViewProps) { export const FullyQualifiedObject: Story = { args: { isListing: false, + profileNameForSharing: "anonymous", listedPrefix: { s3Uri: fullyQualifiedObject.s3Uri, isErrored: false, @@ -510,6 +519,7 @@ export const FullyQualifiedObject: Story = { onDownload: action("download"), onShareObject: action("shareObject"), onSharePrefix: action("sharePrefix"), + onRequestFiles: action("requestFiles"), onBookmark: action("bookmark"), bookmarkedS3Uris: [], onChangePrefixPolicy: action("changePrefixPolicy"), @@ -523,6 +533,7 @@ export const FullyQualifiedObject: Story = { export const AccessDenied: Story = { args: { isListing: false, + profileNameForSharing: "anonymous", listedPrefix: { s3Uri: defaultPrefix, isErrored: true, @@ -540,6 +551,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..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; @@ -87,6 +89,8 @@ export type S3ExplorerMainViewProps = { anonymousProfileName: string; }) => void; + onRequestFiles: (params: { s3Uri: S3Uri.TerminatedByDelimiter }) => void; + onBookmark: ((params: { s3Uri: S3Uri }) => void) | undefined; onDisplayCopyFeedback: (params: { s3Uri: S3Uri }) => void; @@ -116,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 & { @@ -137,6 +141,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { className, isListing, listedPrefix, + profileNameForSharing, onNavigate, onNavigateBack, onPutObjects, @@ -145,6 +150,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { onDownload, onShareObject, onSharePrefix, + onRequestFiles, onBookmark, bookmarkedS3Uris, onChangePrefixPolicy, @@ -384,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) => { @@ -541,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; } @@ -559,19 +567,32 @@ 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 }); } ); + 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 +684,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); @@ -823,8 +854,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { ) || (selectedItemForSingleItemAction.type === "prefix segment" && - selectedItemForSingleItemAction.profileNameForSharing === - undefined) + !selectedItemForSingleItemAction.shouldShowShareAction) ? undefined : { callback: () => @@ -833,9 +863,22 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { ) } } + requestFiles={ + selectedPrefixForSingleItemAction === undefined || + !getIsItemActionAvailable( + selectedPrefixForSingleItemAction + ) + ? undefined + : { + callback: () => + requestFilesForPrefix( + selectedPrefixForSingleItemAction + ) + } + } accessPolicy={ selectedPrefixForSingleItemAction === undefined || - selectedPrefixPolicyAction === undefined + selectedPrefixPublicAccessAction === undefined ? undefined : { callback: () => @@ -843,8 +886,8 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { selectedPrefixForSingleItemAction ), isPublic: - selectedPrefixPolicyAction === - "undo make public" + selectedPrefixPublicAccessAction === + "make private" } } /> @@ -1151,14 +1194,20 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { onDelete={onDeleteFactory(itemKey)} onShare={ item.type === "object" || - item.profileNameForSharing !== - undefined + item.shouldShowShareAction ? onShareFactory(itemKey) : undefined } + onRequestFiles={ + item.type === "prefix segment" + ? onRequestFilesFactory( + itemKey + ) + : undefined + } onChangePrefixPolicy={ item.type === "prefix segment" && - getPrefixPolicyAction(item) !== + item.publicAccessAction !== undefined ? onChangePrefixPolicyFactory( itemKey @@ -1810,6 +1859,7 @@ const { i18n } = declareComponentKeys< | { K: "delete selection dialog body"; P: { count: number }; R: string } | "delete" | "share" + | "request files" | "download" | "copy s3 uri" | "copied" @@ -1866,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"] @@ -2113,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"; } @@ -2333,7 +2365,7 @@ export function DeleteSelectionDialog(props: { } isPublic={ item.type === "prefix segment" && - item.policy.isPublic + item.publicAccessAction === "make private" } /> ))} @@ -2445,6 +2477,7 @@ type ItemRowProps = { onNavigate: () => void; onDelete: () => void; onShare: (() => void) | undefined; + onRequestFiles: (() => void) | undefined; onChangePrefixPolicy: (() => void) | undefined; onDownload: (() => void) | undefined; onBookmark: (() => void) | undefined; @@ -2467,6 +2500,7 @@ const ItemRow = memo(function ItemRow(props: ItemRowProps) { onNavigate, onDelete, onShare, + onRequestFiles, onChangePrefixPolicy, onDownload, onBookmark, @@ -2480,7 +2514,9 @@ const ItemRow = memo(function ItemRow(props: ItemRowProps) { const isItemActionAvailable = getIsItemActionAvailable(item); const isDownloadAvailable = onDownload !== undefined && isItemActionAvailable; const isShareAvailable = onShare !== undefined && isItemActionAvailable; - const prefixPolicyAction = getPrefixPolicyAction(item); + const isRequestFilesAvailable = onRequestFiles !== undefined && isItemActionAvailable; + const prefixPolicyAction = + item.type === "prefix segment" ? item.publicAccessAction : undefined; const isPrefixPolicyActionAvailable = onChangePrefixPolicy !== undefined && isItemActionAvailable; const isCopyAvailable = !item.isDeleting; @@ -2491,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; @@ -2609,7 +2645,7 @@ const ItemRow = memo(function ItemRow(props: ItemRowProps) { {item.type === "prefix segment" && - item.policy.isPublic && ( + item.publicAccessAction === "make private" && ( )} + {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"