From 5454f5333ba48d681164211ce71f990b6a2c371a Mon Sep 17 00:00:00 2001 From: garronej Date: Tue, 21 Oct 2025 21:01:34 +0200 Subject: [PATCH 01/41] Start migration from 's3Config' to S3 Profile --- web/src/core/adapters/onyxiaApi/ApiTypes.ts | 5 +- web/src/core/adapters/onyxiaApi/onyxiaApi.ts | 97 ++++- web/src/core/adapters/s3Client/s3Client.ts | 2 + web/src/core/bootstrap.ts | 1 + .../core/ports/OnyxiaApi/DeploymentRegion.ts | 52 +++ .../_s3Next/s3CredentialsTest/index.ts | 3 + .../_s3Next/s3CredentialsTest/selectors.ts | 6 + .../_s3Next/s3CredentialsTest/state.ts | 97 +++++ .../_s3Next/s3CredentialsTest/thunks.ts | 49 +++ .../s3ProfilesCreationUiController/index.ts | 3 + .../selectors.ts | 345 ++++++++++++++++ .../s3ProfilesCreationUiController/state.ts | 94 +++++ .../s3ProfilesCreationUiController/thunks.ts | 212 ++++++++++ .../resolveTemplatedBookmark.ts | 123 ++++++ .../decoupledLogic/s3Profiles.ts | 198 ++++++++++ ...DefaultS3ProfilesAfterPotentialDeletion.ts | 68 ++++ .../_s3Next/s3ProfilesManagement/index.ts | 4 + .../_s3Next/s3ProfilesManagement/selectors.ts | 57 +++ .../_s3Next/s3ProfilesManagement/state.ts | 39 ++ .../_s3Next/s3ProfilesManagement/thunks.ts | 368 ++++++++++++++++++ web/src/core/usecases/index.ts | 10 +- 21 files changed, 1809 insertions(+), 24 deletions(-) create mode 100644 web/src/core/usecases/_s3Next/s3CredentialsTest/index.ts create mode 100644 web/src/core/usecases/_s3Next/s3CredentialsTest/selectors.ts create mode 100644 web/src/core/usecases/_s3Next/s3CredentialsTest/state.ts create mode 100644 web/src/core/usecases/_s3Next/s3CredentialsTest/thunks.ts create mode 100644 web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/index.ts create mode 100644 web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts create mode 100644 web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/state.ts create mode 100644 web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts create mode 100644 web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts create mode 100644 web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts create mode 100644 web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts create mode 100644 web/src/core/usecases/_s3Next/s3ProfilesManagement/index.ts create mode 100644 web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts create mode 100644 web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts create mode 100644 web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts diff --git a/web/src/core/adapters/onyxiaApi/ApiTypes.ts b/web/src/core/adapters/onyxiaApi/ApiTypes.ts index 1e1464a2d..798acfd8c 100644 --- a/web/src/core/adapters/onyxiaApi/ApiTypes.ts +++ b/web/src/core/adapters/onyxiaApi/ApiTypes.ts @@ -100,6 +100,7 @@ export type ApiTypes = { }; /** Ok to be undefined only if sts is undefined */ + // NOTE: Remove in next major workingDirectory?: | { bucketMode: "shared"; @@ -121,8 +122,8 @@ export type ApiTypes = { | { claimName: undefined } | { claimName: string; - includedClaimPattern: string; - excludedClaimPattern: string; + includedClaimPattern: string | undefined; + excludedClaimPattern: string | undefined; } ))[]; }>; diff --git a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts index 02c9892c2..15e826674 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -21,6 +21,7 @@ import { exclude } from "tsafe/exclude"; import type { ApiTypes } from "./ApiTypes"; import { Evt } from "evt"; import { id } from "tsafe/id"; +import { bucketNameAndObjectNameFromS3Path } from "core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path"; export function createOnyxiaApi(params: { url: string; @@ -289,30 +290,84 @@ export function createOnyxiaApi(params: { }; }) .filter(exclude(undefined)) - .map(s3Config_api => ({ - url: s3Config_api.URL, - pathStyleAccess: - s3Config_api.pathStyleAccess ?? true, - region: s3Config_api.region, - sts: { - url: s3Config_api.sts.URL, - durationSeconds: - s3Config_api.sts.durationSeconds, - role: s3Config_api.sts.role, - oidcParams: - apiTypesOidcConfigurationToOidcParams_Partial( - s3Config_api.sts.oidcConfiguration - ) - }, - workingDirectory: - s3Config_api.workingDirectory, - bookmarkedDirectories: - s3Config_api.bookmarkedDirectories ?? [] - })); + .map(s3Config_api => + id({ + url: s3Config_api.URL, + pathStyleAccess: + s3Config_api.pathStyleAccess ?? true, + region: s3Config_api.region, + sts: { + url: s3Config_api.sts.URL, + durationSeconds: + s3Config_api.sts.durationSeconds, + role: s3Config_api.sts.role, + oidcParams: + apiTypesOidcConfigurationToOidcParams_Partial( + s3Config_api.sts + .oidcConfiguration + ) + }, + workingDirectory: + s3Config_api.workingDirectory, + bookmarkedDirectories: + s3Config_api.bookmarkedDirectories ?? + [] + }) + ); return { s3Configs, - s3ConfigCreationFormDefaults + s3ConfigCreationFormDefaults, + _s3Next: id({ + s3Profiles: id< + DeploymentRegion.S3Next.S3Profile[] + >( + s3Configs.map( + ({ + url, + pathStyleAccess, + region, + sts, + bookmarkedDirectories + }) => ({ + url, + pathStyleAccess, + region, + sts, + bookmarks: bookmarkedDirectories.map( + ({ + fullPath, + title, + description, + tags, + ...rest + }) => { + const { + bucketName, + objectName + } = + bucketNameAndObjectNameFromS3Path( + fullPath + ); + + return id( + { + bucket: bucketName, + keyPrefix: objectName, + title, + description, + tags: tags ?? [], + ...rest + } + ); + } + ) + }) + ) + ), + s3Profiles_defaultValuesOfCreationForm: + s3ConfigCreationFormDefaults + }) }; })(), allowedURIPatternForUserDefinedInitScript: diff --git a/web/src/core/adapters/s3Client/s3Client.ts b/web/src/core/adapters/s3Client/s3Client.ts index c97b5dd0e..2bb831723 100644 --- a/web/src/core/adapters/s3Client/s3Client.ts +++ b/web/src/core/adapters/s3Client/s3Client.ts @@ -51,6 +51,7 @@ export namespace ParamsOfCreateS3Client { roleSessionName: string; } | undefined; + // TODO: Remove this param nameOfBucketToCreateIfNotExist: string | undefined; }; } @@ -222,6 +223,7 @@ export function createS3Client( return { getAwsS3Client }; })(); + // TODO: Remove this block create_bucket: { if (!params.isStsEnabled) { break create_bucket; diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index 7dd134067..5ad93b4f2 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -273,6 +273,7 @@ export async function bootstrapCore( if (oidc.isUserLoggedIn) { await dispatch(usecases.s3ConfigManagement.protectedThunks.initialize()); + await dispatch(usecases.s3ProfilesManagement.protectedThunks.initialize()); } pluginSystemInitCore({ core, context }); diff --git a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts index 8d8453a3b..3129f4bd3 100644 --- a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts +++ b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts @@ -25,6 +25,16 @@ export type DeploymentRegion = { workingDirectory: DeploymentRegion.S3Config["workingDirectory"] | undefined; }) | undefined; + _s3Next: { + s3Profiles: DeploymentRegion.S3Next.S3Profile[]; + s3Profiles_defaultValuesOfCreationForm: + | Pick< + DeploymentRegion.S3Next.S3Profile, + "url" | "pathStyleAccess" | "region" + > + | undefined; + }; + allowedURIPatternForUserDefinedInitScript: string; kafka: | { @@ -160,4 +170,46 @@ export namespace DeploymentRegion { }; } } + + export namespace S3Next { + /** https://github.com/InseeFrLab/onyxia-api/blob/main/docs/region-configuration.md#s3 */ + export type S3Profile = { + url: string; + pathStyleAccess: boolean; + region: string | undefined; + sts: { + url: string | undefined; + durationSeconds: number | undefined; + role: + | { + roleARN: string; + roleSessionName: string; + } + | undefined; + oidcParams: OidcParams_Partial; + }; + bookmarks: S3Profile.Bookmark[]; + }; + + export namespace S3Profile { + export type Bookmark = { + bucket: string; + keyPrefix: string; + title: LocalizedString; + description: LocalizedString | undefined; + tags: LocalizedString[]; + } & ( + | { + claimName: undefined; + includedClaimPattern?: never; + excludedClaimPattern?: never; + } + | { + claimName: string; + includedClaimPattern: string | undefined; + excludedClaimPattern: string | undefined; + } + ); + } + } } diff --git a/web/src/core/usecases/_s3Next/s3CredentialsTest/index.ts b/web/src/core/usecases/_s3Next/s3CredentialsTest/index.ts new file mode 100644 index 000000000..3f3843384 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3CredentialsTest/index.ts @@ -0,0 +1,3 @@ +export * from "./state"; +export * from "./selectors"; +export * from "./thunks"; diff --git a/web/src/core/usecases/_s3Next/s3CredentialsTest/selectors.ts b/web/src/core/usecases/_s3Next/s3CredentialsTest/selectors.ts new file mode 100644 index 000000000..1d7233b0f --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3CredentialsTest/selectors.ts @@ -0,0 +1,6 @@ +import type { State as RootState } from "core/bootstrap"; +import { name } from "./state"; + +export const protectedSelectors = { + credentialsTestState: (rootState: RootState) => rootState[name] +}; diff --git a/web/src/core/usecases/_s3Next/s3CredentialsTest/state.ts b/web/src/core/usecases/_s3Next/s3CredentialsTest/state.ts new file mode 100644 index 000000000..c3ad1a485 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3CredentialsTest/state.ts @@ -0,0 +1,97 @@ +import { createUsecaseActions } from "clean-architecture"; +import { id } from "tsafe/id"; +import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; +import { same } from "evt/tools/inDepth/same"; + +export type State = { + testResults: State.TestResult[]; + ongoingTests: State.OngoingTest[]; +}; + +export namespace State { + export type OngoingTest = { + paramsOfCreateS3Client: ParamsOfCreateS3Client; + }; + + export type TestResult = { + paramsOfCreateS3Client: ParamsOfCreateS3Client; + result: + | { + isSuccess: true; + } + | { + isSuccess: false; + errorMessage: string; + }; + }; +} + +export const name = "s3CredentialsTest"; + +export const { actions, reducer } = createUsecaseActions({ + name, + initialState: id({ + testResults: [], + ongoingTests: [] + }), + reducers: { + testStarted: ( + state, + { + payload + }: { + payload: State["ongoingTests"][number]; + } + ) => { + const { paramsOfCreateS3Client } = payload; + + if ( + state.ongoingTests.find(e => same(e, { paramsOfCreateS3Client })) !== + undefined + ) { + return; + } + + state.ongoingTests.push({ paramsOfCreateS3Client }); + }, + testCompleted: ( + state, + { + payload + }: { + payload: State["testResults"][number]; + } + ) => { + const { paramsOfCreateS3Client, result } = payload; + + remove_from_ongoing: { + const entry = state.ongoingTests.find(e => + same(e, { paramsOfCreateS3Client }) + ); + + if (entry === undefined) { + break remove_from_ongoing; + } + + state.ongoingTests.splice(state.ongoingTests.indexOf(entry), 1); + } + + remove_existing_result: { + const entry = state.testResults.find(e => + same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) + ); + + if (entry === undefined) { + break remove_existing_result; + } + + state.testResults.splice(state.testResults.indexOf(entry), 1); + } + + state.testResults.push({ + paramsOfCreateS3Client, + result + }); + } + } +}); diff --git a/web/src/core/usecases/_s3Next/s3CredentialsTest/thunks.ts b/web/src/core/usecases/_s3Next/s3CredentialsTest/thunks.ts new file mode 100644 index 000000000..db7c5c165 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3CredentialsTest/thunks.ts @@ -0,0 +1,49 @@ +import type { Thunks } from "core/bootstrap"; +import { actions } from "./state"; +import { assert } from "tsafe/assert"; + +import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; + +export const thunks = {} satisfies Thunks; + +export const protectedThunks = { + testS3Credentials: + (params: { paramsOfCreateS3Client: ParamsOfCreateS3Client }) => + async (...args) => { + const { paramsOfCreateS3Client } = params; + + const [dispatch] = args; + + dispatch(actions.testStarted({ paramsOfCreateS3Client })); + + const result = await (async () => { + const { createS3Client } = await import("core/adapters/s3Client"); + + const getOidc = () => { + // TODO: Fix, since we allow testing sts connection + assert(false); + }; + + const s3Client = createS3Client(paramsOfCreateS3Client, getOidc); + + try { + console.log("Find a way to test only s3 credential", s3Client); + throw new Error("TODO: Not implemented yet"); + } catch (error) { + return { + isSuccess: false as const, + errorMessage: String(error) + }; + } + + return { isSuccess: true as const }; + })(); + + dispatch( + actions.testCompleted({ + paramsOfCreateS3Client, + result + }) + ); + } +} satisfies Thunks; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/index.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/index.ts new file mode 100644 index 000000000..3f3843384 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/index.ts @@ -0,0 +1,3 @@ +export * from "./state"; +export * from "./selectors"; +export * from "./thunks"; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts new file mode 100644 index 000000000..7934a5a28 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts @@ -0,0 +1,345 @@ +import type { State as RootState } from "core/bootstrap"; +import { createSelector } from "clean-architecture"; +import { name } from "./state"; +import { objectKeys } from "tsafe/objectKeys"; +import { assert } from "tsafe/assert"; +import { id } from "tsafe/id"; +import type { ProjectConfigs } from "core/usecases/projectManagement"; +import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; +import * as s3CredentialsTest from "core/usecases/_s3Next/s3CredentialsTest"; +import { same } from "evt/tools/inDepth/same"; + +const readyState = (rootState: RootState) => { + const state = rootState[name]; + + if (state.stateDescription !== "ready") { + return null; + } + + return state; +}; + +const isReady = createSelector(readyState, state => state !== null); + +const formValues = createSelector(readyState, state => { + if (state === null) { + return null; + } + + return state.formValues; +}); + +const formValuesErrors = createSelector(formValues, formValues => { + if (formValues === null) { + return null; + } + + const out: Record< + keyof typeof formValues, + "must be an url" | "is required" | "not a valid access key id" | undefined + > = {} as any; + + for (const key of objectKeys(formValues)) { + out[key] = (() => { + required_fields: { + if ( + !( + key === "url" || + key === "friendlyName" || + (!formValues.isAnonymous && + (key === "accessKeyId" || key === "secretAccessKey")) + ) + ) { + break required_fields; + } + + const value = formValues[key]; + + if ((value ?? "").trim() !== "") { + break required_fields; + } + + return "is required"; + } + + if (key === "url") { + const value = formValues[key]; + + try { + new URL(value.startsWith("http") ? value : `https://${value}`); + } catch { + return "must be an url"; + } + } + + return undefined; + })(); + } + + return out; +}); + +const isFormSubmittable = createSelector( + isReady, + formValuesErrors, + (isReady, formValuesErrors) => { + if (!isReady) { + return null; + } + + assert(formValuesErrors !== null); + + return objectKeys(formValuesErrors).every( + key => formValuesErrors[key] === undefined + ); + } +); + +const formattedFormValuesUrl = createSelector( + isReady, + formValues, + formValuesErrors, + (isReady, formValues, formValuesErrors) => { + if (!isReady) { + return null; + } + assert(formValues !== null); + assert(formValuesErrors !== null); + + if (formValuesErrors.url !== undefined) { + return undefined; + } + + const trimmedValue = formValues.url.trim(); + + return trimmedValue.startsWith("http") ? trimmedValue : `https://${trimmedValue}`; + } +); + +const submittableFormValuesAsProjectS3Config = createSelector( + isReady, + formValues, + formattedFormValuesUrl, + isFormSubmittable, + createSelector(readyState, state => { + if (state === null) { + return null; + } + return state.s3ProfileCreationTime; + }), + ( + isReady, + formValues, + formattedFormValuesUrl, + isFormSubmittable, + s3ProfileCreationTime + ) => { + if (!isReady) { + return null; + } + assert(formValues !== null); + assert(formattedFormValuesUrl !== null); + assert(isFormSubmittable !== null); + assert(s3ProfileCreationTime !== null); + + if (!isFormSubmittable) { + return undefined; + } + + assert(formattedFormValuesUrl !== undefined); + + return id({ + creationTime: s3ProfileCreationTime, + friendlyName: formValues.friendlyName.trim(), + url: formattedFormValuesUrl, + region: formValues.region?.trim(), + pathStyleAccess: formValues.pathStyleAccess, + credentials: (() => { + if (formValues.isAnonymous) { + return undefined; + } + + assert(formValues.accessKeyId !== undefined); + assert(formValues.secretAccessKey !== undefined); + + return { + accessKeyId: formValues.accessKeyId, + secretAccessKey: formValues.secretAccessKey, + sessionToken: formValues.sessionToken + }; + })(), + // TODO: Delete once we move on + workingDirectoryPath: "mybucket/my/prefix/" + }); + } +); + +const paramsOfCreateS3Client = createSelector( + isReady, + submittableFormValuesAsProjectS3Config, + (isReady, submittableFormValuesAsProjectS3Config) => { + if (!isReady) { + return null; + } + + assert(submittableFormValuesAsProjectS3Config !== null); + + if (submittableFormValuesAsProjectS3Config === undefined) { + return undefined; + } + + return id({ + url: submittableFormValuesAsProjectS3Config.url, + pathStyleAccess: submittableFormValuesAsProjectS3Config.pathStyleAccess, + isStsEnabled: false, + region: submittableFormValuesAsProjectS3Config.region, + credentials: submittableFormValuesAsProjectS3Config.credentials + }); + } +); + +type ConnectionTestStatus = + | { status: "test ongoing" } + | { status: "test succeeded" } + | { status: "test failed"; errorMessage: string } + | { status: "not tested" }; + +const connectionTestStatus = createSelector( + isReady, + isFormSubmittable, + paramsOfCreateS3Client, + s3CredentialsTest.protectedSelectors.credentialsTestState, + ( + isReady, + isFormSubmittable, + paramsOfCreateS3Client, + credentialsTestState + ): ConnectionTestStatus | null => { + if (!isReady) { + return null; + } + + assert(isFormSubmittable !== null); + assert(paramsOfCreateS3Client !== null); + + if (!isFormSubmittable) { + return { status: "not tested" }; + } + + assert(paramsOfCreateS3Client !== undefined); + + if ( + credentialsTestState.ongoingTests.find(e => + same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) + ) !== undefined + ) { + return { status: "test ongoing" }; + } + + has_result: { + const { result } = + credentialsTestState.testResults.find(e => + same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) + ) ?? {}; + + if (result === undefined) { + break has_result; + } + + return result.isSuccess + ? { status: "test succeeded" } + : { status: "test failed", errorMessage: result.errorMessage }; + } + + return { status: "not tested" } as ConnectionTestStatus; + } +); + +const urlStylesExamples = createSelector( + isReady, + formattedFormValuesUrl, + (isReady, formattedFormValuesUrl) => { + if (!isReady) { + return null; + } + + assert(formattedFormValuesUrl !== null); + + if (formattedFormValuesUrl === undefined) { + return undefined; + } + + const urlObject = new URL(formattedFormValuesUrl); + + const bucketName = "mybucket"; + const objectNamePrefix = "my/object/name/prefix/"; + + const domain = formattedFormValuesUrl + .split(urlObject.protocol)[1] + .split("//")[1] + .replace(/\/$/, ""); + + return { + pathStyle: `${domain}/${bucketName}/${objectNamePrefix}`, + virtualHostedStyle: `${bucketName}.${domain}/${objectNamePrefix}` + }; + } +); + +const isEditionOfAnExistingConfig = createSelector(readyState, state => { + if (state === null) { + return null; + } + return state.action === "Update existing S3 profile"; +}); + +const main = createSelector( + isReady, + formValues, + formValuesErrors, + isFormSubmittable, + urlStylesExamples, + isEditionOfAnExistingConfig, + connectionTestStatus, + ( + isReady, + formValues, + formValuesErrors, + isFormSubmittable, + urlStylesExamples, + isEditionOfAnExistingConfig, + connectionTestStatus + ) => { + if (!isReady) { + return { + isReady: false as const + }; + } + + assert(formValues !== null); + assert(formValuesErrors !== null); + assert(isFormSubmittable !== null); + assert(urlStylesExamples !== null); + assert(isEditionOfAnExistingConfig !== null); + assert(connectionTestStatus !== null); + + return { + isReady: true, + formValues, + formValuesErrors, + isFormSubmittable, + urlStylesExamples, + isEditionOfAnExistingConfig, + connectionTestStatus + }; + } +); + +export const privateSelectors = { + formattedFormValuesUrl, + submittableFormValuesAsProjectS3Config, + formValuesErrors, + paramsOfCreateS3Client +}; + +export const selectors = { main }; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/state.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/state.ts new file mode 100644 index 000000000..969ea6d16 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/state.ts @@ -0,0 +1,94 @@ +import { createUsecaseActions } from "clean-architecture"; +import { id } from "tsafe/id"; +import { assert } from "tsafe/assert"; + +export type State = State.NotInitialized | State.Ready; + +export namespace State { + export type NotInitialized = { + stateDescription: "not initialized"; + }; + + export type Ready = { + stateDescription: "ready"; + formValues: Ready.FormValues; + s3ProfileCreationTime: number; + action: "Update existing S3 profile" | "Create new S3 profile"; + }; + + export namespace Ready { + export type FormValues = { + friendlyName: string; + url: string; + region: string | undefined; + pathStyleAccess: boolean; + isAnonymous: boolean; + accessKeyId: string | undefined; + secretAccessKey: string | undefined; + sessionToken: string | undefined; + }; + } +} + +export type ChangeValueParams< + K extends keyof State.Ready.FormValues = keyof State.Ready.FormValues +> = { + key: K; + value: State.Ready.FormValues[K]; +}; + +export const name = "s3ProfilesCreationUiController"; + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: id( + id({ + stateDescription: "not initialized" + }) + ), + reducers: { + initialized: ( + _state, + { + payload + }: { + payload: { + creationTimeOfS3ProfileToEdit: number | undefined; + initialFormValues: State.Ready["formValues"]; + }; + } + ) => { + const { creationTimeOfS3ProfileToEdit, initialFormValues } = payload; + + return id({ + stateDescription: "ready", + formValues: initialFormValues, + s3ProfileCreationTime: creationTimeOfS3ProfileToEdit ?? Date.now(), + action: + creationTimeOfS3ProfileToEdit === undefined + ? "Create new S3 profile" + : "Update existing S3 profile" + }); + }, + formValueChanged: ( + state, + { + payload + }: { + payload: ChangeValueParams; + } + ) => { + assert(state.stateDescription === "ready"); + + if (state.formValues[payload.key] === payload.value) { + return; + } + + Object.assign(state.formValues, { [payload.key]: payload.value }); + }, + stateResetToNotInitialized: () => + id({ + stateDescription: "not initialized" + }) + } +}); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts new file mode 100644 index 000000000..fbeb86fab --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts @@ -0,0 +1,212 @@ +import type { Thunks } from "core/bootstrap"; +import { actions, type State, type ChangeValueParams } from "./state"; +import { assert } from "tsafe/assert"; +import { privateSelectors } from "./selectors"; +import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; +import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; +import * as s3CredentialsTest from "core/usecases/_s3Next/s3CredentialsTest"; + +export const thunks = { + initialize: + (params: { creationTimeOfS3ProfileToEdit: number | undefined }) => + async (...args) => { + const { creationTimeOfS3ProfileToEdit } = params; + + const [dispatch, getState] = args; + + const s3Profiles = s3ProfilesManagement.selectors.s3Profiles(getState()); + + update_existing_config: { + if (creationTimeOfS3ProfileToEdit === undefined) { + break update_existing_config; + } + + const s3Profile = s3Profiles + .filter( + s3Profile => + s3Profile.origin === + "created by user (or group project member)" + ) + .find( + s3Profile => + s3Profile.creationTime === creationTimeOfS3ProfileToEdit + ); + + assert(s3Profile !== undefined); + + dispatch( + actions.initialized({ + creationTimeOfS3ProfileToEdit, + initialFormValues: { + friendlyName: s3Profile.friendlyName, + url: s3Profile.paramsOfCreateS3Client.url, + region: s3Profile.paramsOfCreateS3Client.region, + pathStyleAccess: + s3Profile.paramsOfCreateS3Client.pathStyleAccess, + ...(() => { + if ( + s3Profile.paramsOfCreateS3Client.credentials === + undefined + ) { + return { + isAnonymous: true, + accessKeyId: undefined, + secretAccessKey: undefined, + sessionToken: undefined + }; + } + + return { + isAnonymous: false, + accessKeyId: + s3Profile.paramsOfCreateS3Client.credentials + .accessKeyId, + secretAccessKey: + s3Profile.paramsOfCreateS3Client.credentials + .secretAccessKey, + sessionToken: + s3Profile.paramsOfCreateS3Client.credentials + .sessionToken + }; + })() + } + }) + ); + + return; + } + + const { s3Profiles_defaultValuesOfCreationForm } = + deploymentRegionManagement.selectors.currentDeploymentRegion( + getState() + )._s3Next; + + if (s3Profiles_defaultValuesOfCreationForm === undefined) { + dispatch( + actions.initialized({ + creationTimeOfS3ProfileToEdit: undefined, + initialFormValues: { + friendlyName: "", + url: "", + region: undefined, + pathStyleAccess: false, + isAnonymous: true, + accessKeyId: undefined, + secretAccessKey: undefined, + sessionToken: undefined + } + }) + ); + return; + } + + dispatch( + actions.initialized({ + creationTimeOfS3ProfileToEdit: undefined, + initialFormValues: { + friendlyName: "", + url: s3Profiles_defaultValuesOfCreationForm.url, + region: s3Profiles_defaultValuesOfCreationForm.region, + pathStyleAccess: + s3Profiles_defaultValuesOfCreationForm.pathStyleAccess ?? + false, + isAnonymous: false, + accessKeyId: undefined, + secretAccessKey: undefined, + sessionToken: undefined + } + }) + ); + }, + reset: + () => + (...args) => { + const [dispatch] = args; + + dispatch(actions.stateResetToNotInitialized()); + }, + submit: + () => + async (...args) => { + const [dispatch, getState] = args; + + const s3Config_vault = + privateSelectors.submittableFormValuesAsProjectS3Config(getState()); + + assert(s3Config_vault !== null); + assert(s3Config_vault !== undefined); + + await dispatch( + s3ProfilesManagement.protectedThunks.createOrUpdateS3Profile({ + s3Config_vault + }) + ); + + dispatch(actions.stateResetToNotInitialized()); + }, + changeValue: + (params: ChangeValueParams) => + async (...args) => { + const { key, value } = params; + + const [dispatch, getState] = args; + dispatch(actions.formValueChanged({ key, value })); + + preset_pathStyleAccess: { + if (key !== "url") { + break preset_pathStyleAccess; + } + + const url = privateSelectors.formattedFormValuesUrl(getState()); + + assert(url !== null); + + if (url === undefined) { + break preset_pathStyleAccess; + } + + if (url.toLowerCase().includes("amazonaws.com")) { + dispatch( + actions.formValueChanged({ + key: "pathStyleAccess", + value: false + }) + ); + break preset_pathStyleAccess; + } + + if (url.toLocaleLowerCase().includes("minio")) { + dispatch( + actions.formValueChanged({ + key: "pathStyleAccess", + value: true + }) + ); + break preset_pathStyleAccess; + } + } + }, + testConnection: + () => + async (...args) => { + const [dispatch, getState] = args; + + const projectS3Config = + privateSelectors.submittableFormValuesAsProjectS3Config(getState()); + + assert(projectS3Config !== null); + assert(projectS3Config !== undefined); + + await dispatch( + s3CredentialsTest.protectedThunks.testS3Credentials({ + paramsOfCreateS3Client: { + isStsEnabled: false, + url: projectS3Config.url, + pathStyleAccess: projectS3Config.pathStyleAccess, + region: projectS3Config.region, + credentials: projectS3Config.credentials + } + }) + ); + } +} satisfies Thunks; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts new file mode 100644 index 000000000..bd2e1c4f0 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts @@ -0,0 +1,123 @@ +import type { DeploymentRegion } from "core/ports/OnyxiaApi"; +import { id } from "tsafe/id"; +import type { LocalizedString } from "ui/i18n"; +import type { S3Profile } from "./s3Profiles"; +import { z } from "zod"; +import { getValueAtPath } from "core/tools/Stringifyable"; + +export async function resolveTemplatedBookmark(params: { + bookmark_region: DeploymentRegion.S3Next.S3Profile.Bookmark; + getDecodedIdToken: () => Promise>; +}): Promise { + const { bookmark_region, getDecodedIdToken } = params; + + if (bookmark_region.claimName === undefined) { + return [ + id({ + title: bookmark_region.title, + description: bookmark_region.description, + tags: bookmark_region.tags, + bucket: bookmark_region.bucket, + keyPrefix: bookmark_region.keyPrefix + }) + ]; + } + + const { claimName, excludedClaimPattern, includedClaimPattern } = bookmark_region; + + const decodedIdToken = await getDecodedIdToken(); + + const claimValue_arr: string[] = (() => { + let claimValue_untrusted: unknown = (() => { + const candidate = decodedIdToken[claimName]; + + if (candidate !== undefined) { + return candidate; + } + + const claimPath = claimName.split("."); + + if (claimPath.length === 1) { + return undefined; + } + + return getValueAtPath({ + // @ts-expect-error: We know decodedIdToken is Stringifyable + stringifyableObjectOrArray: decodedIdToken, + doDeleteFromSource: false, + doFailOnUnresolved: false, + path: claimPath + }); + })(); + + if (!claimValue_untrusted) { + return []; + } + + let claimValue: string | string[]; + + try { + claimValue = z + .union([z.string(), z.array(z.string())]) + .parse(claimValue_untrusted); + } catch (error) { + throw new Error( + [ + `decodedIdToken -> ${claimName} is supposed to be`, + `string or array of string`, + `The decoded id token is:`, + JSON.stringify(decodedIdToken, null, 2) + ].join(" "), + { cause: error } + ); + } + + return claimValue instanceof Array ? claimValue : [claimValue]; + })(); + + const includedRegex = + includedClaimPattern !== undefined ? new RegExp(includedClaimPattern) : /^(.+)$/; + const excludedRegex = + excludedClaimPattern !== undefined ? new RegExp(excludedClaimPattern) : undefined; + + return claimValue_arr + .map(value => { + if (excludedRegex !== undefined && excludedRegex.test(value)) { + return undefined; + } + + const match = includedRegex.exec(value); + + if (match === null) { + return undefined; + } + + const substituteTemplateString = (str: string) => + str.replace(/\$(\d+)/g, (_, i) => match[parseInt(i)] ?? ""); + + const substituteLocalizedString = ( + locStr: LocalizedString + ): LocalizedString => { + if (typeof locStr === "string") { + return substituteTemplateString(locStr); + } + return Object.fromEntries( + Object.entries(locStr) + .filter(([, value]) => value !== undefined) + .map(([lang, value]) => [lang, substituteTemplateString(value)]) + ); + }; + + return id({ + bucket: substituteTemplateString(bookmark_region.bucket), + keyPrefix: substituteTemplateString(bookmark_region.keyPrefix), + title: substituteLocalizedString(bookmark_region.title), + description: + bookmark_region.description === undefined + ? undefined + : substituteLocalizedString(bookmark_region.description), + tags: bookmark_region.tags.map(tag => substituteLocalizedString(tag)) + }); + }) + .filter(x => x !== undefined); +} diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts new file mode 100644 index 000000000..54c9cdf79 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -0,0 +1,198 @@ +import * as projectManagement from "core/usecases/projectManagement"; +import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; +import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; +import { same } from "evt/tools/inDepth/same"; +import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; +import { assert, type Equals } from "tsafe"; +import type * as s3CredentialsTest from "core/usecases/_s3Next/s3CredentialsTest"; +import type { LocalizedString } from "core/ports/OnyxiaApi"; + +export type S3Profile = S3Profile.DefinedInRegion | S3Profile.CreatedByUser; + +export namespace S3Profile { + type Common = { + id: string; + isXOnyxiaDefault: boolean; + isExplorerConfig: boolean; + credentialsTestStatus: + | { status: "not tested" } + | { status: "test ongoing" } + | { status: "test failed"; errorMessage: string } + | { status: "test succeeded" }; + }; + + export type DefinedInRegion = Common & { + origin: "defined in region"; + paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts; + bookmarks: DefinedInRegion.Bookmark[]; + }; + + export namespace DefinedInRegion { + export type Bookmark = { + title: LocalizedString; + description: LocalizedString | undefined; + tags: LocalizedString[]; + bucket: string; + keyPrefix: string; + }; + } + + export type CreatedByUser = Common & { + origin: "created by user (or group project member)"; + creationTime: number; + paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts; + friendlyName: string; + bookmarks: CreatedByUser.Bookmark[]; + }; + + export namespace CreatedByUser { + export type Bookmark = { + friendlyName: string; + bucket: string; + keyPrefix: string; + }; + } +} + +export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { + fromVault: projectManagement.ProjectConfigs["s3"]; + fromRegion: (Omit & { + bookmarks: S3Profile.DefinedInRegion.Bookmark[]; + })[]; + credentialsTestState: s3CredentialsTest.State; +}): S3Profile[] { + const { fromVault, fromRegion, credentialsTestState } = params; + + const getCredentialsTestStatus = (params: { + paramsOfCreateS3Client: ParamsOfCreateS3Client; + }): S3Profile["credentialsTestStatus"] => { + const { paramsOfCreateS3Client } = params; + + if ( + credentialsTestState.ongoingTests.find(e => + same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) + ) !== undefined + ) { + return { status: "test ongoing" }; + } + + has_result: { + const { result } = + credentialsTestState.testResults.find(e => + same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) + ) ?? {}; + + if (result === undefined) { + break has_result; + } + + return result.isSuccess + ? { status: "test succeeded" } + : { status: "test failed", errorMessage: result.errorMessage }; + } + + return { status: "not tested" }; + }; + + const s3Profiles: S3Profile[] = [ + ...fromVault.s3Configs + .map((c): S3Profile.CreatedByUser => { + const url = c.url; + const pathStyleAccess = c.pathStyleAccess; + const region = c.region; + + const paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts = { + url, + pathStyleAccess, + isStsEnabled: false, + region, + credentials: c.credentials + }; + + return { + origin: "created by user (or group project member)", + creationTime: c.creationTime, + friendlyName: c.friendlyName, + id: `${c.creationTime}`, + paramsOfCreateS3Client, + isXOnyxiaDefault: false, + isExplorerConfig: false, + // TODO: Actually store custom bookmarks + bookmarks: [], + credentialsTestStatus: getCredentialsTestStatus({ + paramsOfCreateS3Client + }) + }; + }) + .sort((a, b) => b.creationTime - a.creationTime), + ...fromRegion.map((c): S3Profile.DefinedInRegion => { + const url = c.url; + const pathStyleAccess = c.pathStyleAccess; + const region = c.region; + + const paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts = { + url, + pathStyleAccess, + isStsEnabled: true, + stsUrl: c.sts.url, + region, + oidcParams: c.sts.oidcParams, + durationSeconds: c.sts.durationSeconds, + role: c.sts.role, + nameOfBucketToCreateIfNotExist: undefined + }; + + return { + origin: "defined in region", + id: fnv1aHashToHex( + JSON.stringify( + Object.fromEntries( + Object.entries(c).sort(([key1], [key2]) => + key1.localeCompare(key2) + ) + ) + ) + ), + bookmarks: c.bookmarks, + paramsOfCreateS3Client, + credentialsTestStatus: getCredentialsTestStatus({ + paramsOfCreateS3Client + }), + isXOnyxiaDefault: false, + isExplorerConfig: false + }; + }) + ]; + + ( + [ + ["defaultXOnyxia", fromVault.s3ConfigId_defaultXOnyxia], + ["explorer", fromVault.s3ConfigId_explorer] + ] as const + ).forEach(([prop, s3ProfileId]) => { + if (s3ProfileId === undefined) { + return; + } + + const s3Profile = + s3Profiles.find(({ id }) => id === s3ProfileId) ?? + s3Profiles.find(s3Config => s3Config.origin === "defined in region"); + + if (s3Profile === undefined) { + return; + } + + switch (prop) { + case "defaultXOnyxia": + s3Profile.isXOnyxiaDefault = true; + return; + case "explorer": + s3Profile.isExplorerConfig = true; + return; + default: + assert>(false); + } + }); + + return s3Profiles; +} diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts new file mode 100644 index 000000000..95dc0f996 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts @@ -0,0 +1,68 @@ +import * as projectManagement from "core/usecases/projectManagement"; +import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; +import { aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet } from "./s3Profiles"; + +type R = Record< + "s3ConfigId_defaultXOnyxia" | "s3ConfigId_explorer", + | { + isUpdateNeeded: false; + } + | { + isUpdateNeeded: true; + s3ProfileId: string | undefined; + } +>; + +export function updateDefaultS3ProfilesAfterPotentialDeletion(params: { + fromRegion: DeploymentRegion.S3Next.S3Profile[]; + fromVault: projectManagement.ProjectConfigs["s3"]; +}): R { + const { fromRegion, fromVault } = params; + + const s3Profiles = aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ + fromRegion: fromRegion.map(s3Profile => ({ + ...s3Profile, + bookmarks: [] + })), + fromVault, + credentialsTestState: { + ongoingTests: [], + testResults: [] + } + }); + + const actions: R = { + s3ConfigId_defaultXOnyxia: { + isUpdateNeeded: false + }, + s3ConfigId_explorer: { + isUpdateNeeded: false + } + }; + + for (const propertyName of [ + "s3ConfigId_defaultXOnyxia", + "s3ConfigId_explorer" + ] as const) { + const s3ConfigId_default = fromVault[propertyName]; + + if (s3ConfigId_default === undefined) { + continue; + } + + if (s3Profiles.find(({ id }) => id === s3ConfigId_default) !== undefined) { + continue; + } + + const s3ConfigId_toUseAsDefault = s3Profiles.find( + ({ origin }) => origin === "defined in region" + )?.id; + + actions[propertyName] = { + isUpdateNeeded: true, + s3ProfileId: s3ConfigId_toUseAsDefault + }; + } + + return actions; +} diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/index.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/index.ts new file mode 100644 index 000000000..84fe07fe2 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/index.ts @@ -0,0 +1,4 @@ +export * from "./state"; +export * from "./selectors"; +export * from "./thunks"; +export type { S3Profile } from "./decoupledLogic/s3Profiles"; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts new file mode 100644 index 000000000..272137d99 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts @@ -0,0 +1,57 @@ +import { createSelector } from "clean-architecture"; +import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; +import * as projectManagement from "core/usecases/projectManagement"; +import * as s3CredentialsTest from "core/usecases/_s3Next/s3CredentialsTest"; +import { assert } from "tsafe/assert"; +import { + type S3Profile, + aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet +} from "./decoupledLogic/s3Profiles"; +import { name } from "./state"; +import type { State as RootState } from "core/bootstrap"; + +const resolvedTemplatedBookmarks = createSelector( + (state: RootState) => state[name], + state => state.resolvedTemplatedBookmarks +); + +const s3Profiles = createSelector( + createSelector( + projectManagement.protectedSelectors.projectConfig, + projectConfig => projectConfig.s3 + ), + createSelector( + deploymentRegionManagement.selectors.currentDeploymentRegion, + deploymentRegion => deploymentRegion._s3Next.s3Profiles + ), + resolvedTemplatedBookmarks, + s3CredentialsTest.protectedSelectors.credentialsTestState, + ( + projectConfigS3, + s3Profiles_region, + resolvedTemplatedBookmarks, + credentialsTestState + ): S3Profile[] => + aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ + fromVault: projectConfigS3, + fromRegion: s3Profiles_region.map((s3Profile, i) => ({ + ...s3Profile, + bookmarks: (() => { + const entry = resolvedTemplatedBookmarks.find( + entry => entry.correspondingS3ConfigIndexInRegion === i + ); + + assert(entry !== undefined); + + return entry.bookmarks; + })() + })), + credentialsTestState + }) +); + +export const selectors = { s3Profiles }; + +export const protectedSelectors = { + resolvedTemplatedBookmarks +}; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts new file mode 100644 index 000000000..ac2988936 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts @@ -0,0 +1,39 @@ +import { + createUsecaseActions, + createObjectThatThrowsIfAccessed +} from "clean-architecture"; +import type { S3Profile } from "./decoupledLogic/s3Profiles"; + +type State = { + resolvedTemplatedBookmarks: { + correspondingS3ConfigIndexInRegion: number; + bookmarks: S3Profile.DefinedInRegion.Bookmark[]; + }[]; +}; + +export const name = "s3ProfilesManagement"; + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: createObjectThatThrowsIfAccessed(), + reducers: { + initialized: ( + _, + { + payload + }: { + payload: { + resolvedTemplatedBookmarks: State["resolvedTemplatedBookmarks"]; + }; + } + ) => { + const { resolvedTemplatedBookmarks } = payload; + + const state: State = { + resolvedTemplatedBookmarks + }; + + return state; + } + } +}); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts new file mode 100644 index 000000000..dfef62fb1 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts @@ -0,0 +1,368 @@ +import type { Thunks } from "core/bootstrap"; +import { selectors, protectedSelectors } from "./selectors"; +import * as projectManagement from "core/usecases/projectManagement"; +import { assert } from "tsafe/assert"; +import type { S3Client } from "core/ports/S3Client"; +import { createUsecaseContextApi } from "clean-architecture"; +import * as s3CredentialsTest from "core/usecases/_s3Next/s3CredentialsTest"; +import { updateDefaultS3ProfilesAfterPotentialDeletion } from "./decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion"; +import structuredClone from "@ungap/structured-clone"; +import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; +import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; +import { resolveTemplatedBookmark } from "./decoupledLogic/resolveTemplatedBookmark"; +import { actions } from "./state"; +import type { S3Profile } from "./decoupledLogic/s3Profiles"; + +export const thunks = { + testS3ProfileCredentials: + (params: { s3ProfileId: string }) => + async (...args) => { + const { s3ProfileId } = params; + const [dispatch, getState] = args; + + const s3Profiles = selectors.s3Profiles(getState()); + + const s3Profile = s3Profiles.find(s3Profile => s3Profile.id === s3ProfileId); + + assert(s3Profile !== undefined); + + await dispatch( + s3CredentialsTest.protectedThunks.testS3Credentials({ + paramsOfCreateS3Client: s3Profile.paramsOfCreateS3Client + }) + ); + }, + deleteS3Config: + (params: { s3ProfileCreationTime: number }) => + async (...args) => { + const { s3ProfileCreationTime } = params; + + const [dispatch, getState] = args; + + const fromVault = structuredClone( + projectManagement.protectedSelectors.projectConfig(getState()).s3 + ); + + const i = fromVault.s3Configs.findIndex( + ({ creationTime }) => creationTime === s3ProfileCreationTime + ); + + assert(i !== -1); + + fromVault.s3Configs.splice(i, 1); + + { + const actions = updateDefaultS3ProfilesAfterPotentialDeletion({ + fromRegion: + deploymentRegionManagement.selectors.currentDeploymentRegion( + getState() + )._s3Next.s3Profiles, + fromVault: fromVault + }); + + await Promise.all( + (["s3ConfigId_defaultXOnyxia", "s3ConfigId_explorer"] as const).map( + async propertyName => { + const action = actions[propertyName]; + + if (!action.isUpdateNeeded) { + return; + } + + fromVault[propertyName] = action.s3ProfileId; + } + ) + ); + } + + await dispatch( + projectManagement.protectedThunks.updateConfigValue({ + key: "s3", + value: fromVault + }) + ); + }, + changeIsDefault: + (params: { + s3ProfileId: string; + usecase: "defaultXOnyxia" | "explorer"; + value: boolean; + }) => + async (...args) => { + const { s3ProfileId, usecase, value } = params; + + const [dispatch, getState] = args; + + const fromVault = structuredClone( + projectManagement.protectedSelectors.projectConfig(getState()).s3 + ); + + const propertyName = (() => { + switch (usecase) { + case "defaultXOnyxia": + return "s3ConfigId_defaultXOnyxia"; + case "explorer": + return "s3ConfigId_explorer"; + } + })(); + + { + const s3ProfileId_currentDefault = fromVault[propertyName]; + + if (value) { + if (s3ProfileId_currentDefault === s3ProfileId) { + return; + } + } else { + if (s3ProfileId_currentDefault !== s3ProfileId) { + return; + } + } + } + + fromVault[propertyName] = value ? s3ProfileId : undefined; + + await dispatch( + projectManagement.protectedThunks.updateConfigValue({ + key: "s3", + value: fromVault + }) + ); + } +} satisfies Thunks; + +export const protectedThunks = { + getS3ClientForSpecificConfig: + (params: { s3ProfileId: string | undefined }) => + async (...args): Promise => { + const { s3ProfileId } = params; + const [, getState, rootContext] = args; + + const { prS3ClientByConfigId: prS3ClientByProfileId } = + getContext(rootContext); + + const s3Profile = (() => { + const s3Profiles = selectors.s3Profiles(getState()); + + const s3Config = s3Profiles.find( + s3Profile => s3Profile.id === s3ProfileId + ); + assert(s3Config !== undefined); + + return s3Config; + })(); + + use_cached_s3Client: { + const prS3Client = prS3ClientByProfileId.get(s3Profile.id); + + if (prS3Client === undefined) { + break use_cached_s3Client; + } + + return prS3Client; + } + + const prS3Client = (async () => { + const { createS3Client } = await import("core/adapters/s3Client"); + const { createOidc, mergeOidcParams } = await import( + "core/adapters/oidc" + ); + const { paramsOfBootstrapCore, onyxiaApi } = rootContext; + + return createS3Client( + s3Profile.paramsOfCreateS3Client, + async oidcParams_partial => { + const { oidcParams } = + await onyxiaApi.getAvailableRegionsAndOidcParams(); + + assert(oidcParams !== undefined); + + const oidc_s3 = await createOidc({ + ...mergeOidcParams({ + oidcParams, + oidcParams_partial + }), + autoLogin: true, + transformBeforeRedirectForKeycloakTheme: + paramsOfBootstrapCore.transformBeforeRedirectForKeycloakTheme, + getCurrentLang: paramsOfBootstrapCore.getCurrentLang, + enableDebugLogs: paramsOfBootstrapCore.enableOidcDebugLogs + }); + + const doClearCachedS3Token_groupClaimValue: boolean = + await (async () => { + const { projects } = await onyxiaApi.getUserAndProjects(); + + const KEY = "onyxia:s3:projects-hash"; + + const hash = fnv1aHashToHex(JSON.stringify(projects)); + + if ( + !oidc_s3.isNewBrowserSession && + sessionStorage.getItem(KEY) === hash + ) { + return false; + } + + sessionStorage.setItem(KEY, hash); + return true; + })(); + + const doClearCachedS3Token_s3BookmarkClaimValue: boolean = + (() => { + const resolvedTemplatedBookmarks = + protectedSelectors.resolvedTemplatedBookmarks( + getState() + ); + + const KEY = "onyxia:s3:resolvedAdminBookmarks-hash"; + + const hash = fnv1aHashToHex( + JSON.stringify(resolvedTemplatedBookmarks) + ); + + if ( + !oidc_s3.isNewBrowserSession && + sessionStorage.getItem(KEY) === hash + ) { + return false; + } + + sessionStorage.setItem(KEY, hash); + return true; + })(); + + return { + oidc: oidc_s3, + doClearCachedS3Token: + doClearCachedS3Token_groupClaimValue || + doClearCachedS3Token_s3BookmarkClaimValue + }; + } + ); + })(); + + prS3ClientByProfileId.set(s3Profile.id, prS3Client); + + return prS3Client; + }, + getS3ConfigAndClientForExplorer: + () => + async ( + ...args + ): Promise => { + const [dispatch, getState] = args; + + const s3Profile = selectors + .s3Profiles(getState()) + .find(s3Profile => s3Profile.isExplorerConfig); + + if (s3Profile === undefined) { + return undefined; + } + + const s3Client = await dispatch( + protectedThunks.getS3ClientForSpecificConfig({ + s3ProfileId: s3Profile.id + }) + ); + + return { s3Client, s3Profile }; + }, + createOrUpdateS3Profile: + (params: { s3Config_vault: projectManagement.ProjectConfigs.S3Config }) => + async (...args) => { + const { s3Config_vault: s3Config_vault } = params; + + const [dispatch, getState] = args; + + const fromVault = structuredClone( + projectManagement.protectedSelectors.projectConfig(getState()).s3 + ); + + const i = fromVault.s3Configs.findIndex( + projectS3Config_i => + projectS3Config_i.creationTime === s3Config_vault.creationTime + ); + + if (i < 0) { + fromVault.s3Configs.push(s3Config_vault); + } else { + fromVault.s3Configs[i] = s3Config_vault; + } + + await dispatch( + projectManagement.protectedThunks.updateConfigValue({ + key: "s3", + value: fromVault + }) + ); + }, + + initialize: + () => + async (...args) => { + const [dispatch, getState, { onyxiaApi, paramsOfBootstrapCore }] = args; + + const deploymentRegion = + deploymentRegionManagement.selectors.currentDeploymentRegion(getState()); + + const resolvedTemplatedBookmarks = await Promise.all( + deploymentRegion._s3Next.s3Profiles.map( + async (s3Config, s3ConfigIndex) => { + const { + bookmarks, + sts: { oidcParams: oidcParams_partial } + } = s3Config; + + const getDecodedIdToken = async () => { + const { createOidc, mergeOidcParams } = await import( + "core/adapters/oidc" + ); + + const { oidcParams } = + await onyxiaApi.getAvailableRegionsAndOidcParams(); + + assert(oidcParams !== undefined); + + const oidc = await createOidc({ + ...mergeOidcParams({ + oidcParams, + oidcParams_partial + }), + autoLogin: true, + transformBeforeRedirectForKeycloakTheme: + paramsOfBootstrapCore.transformBeforeRedirectForKeycloakTheme, + getCurrentLang: paramsOfBootstrapCore.getCurrentLang, + enableDebugLogs: paramsOfBootstrapCore.enableOidcDebugLogs + }); + + const { decodedIdToken } = await oidc.getTokens(); + + return decodedIdToken; + }; + + return { + correspondingS3ConfigIndexInRegion: s3ConfigIndex, + bookmarks: ( + await Promise.all( + bookmarks.map(bookmark => + resolveTemplatedBookmark({ + bookmark_region: bookmark, + getDecodedIdToken + }) + ) + ) + ).flat() + }; + } + ) + ); + + dispatch(actions.initialized({ resolvedTemplatedBookmarks })); + } +} satisfies Thunks; + +const { getContext } = createUsecaseContextApi(() => ({ + prS3ClientByConfigId: new Map>() +})); diff --git a/web/src/core/usecases/index.ts b/web/src/core/usecases/index.ts index bd292355c..55357aa7e 100644 --- a/web/src/core/usecases/index.ts +++ b/web/src/core/usecases/index.ts @@ -25,6 +25,10 @@ import * as projectManagement from "./projectManagement"; import * as viewQuotas from "./viewQuotas"; import * as dataCollection from "./dataCollection"; +import * as s3CredentialsTest from "./_s3Next/s3CredentialsTest"; +import * as s3ProfilesManagement from "./_s3Next/s3ProfilesManagement"; +import * as s3ProfilesCreationUiController from "./_s3Next/s3ProfilesCreationUiController"; + export const usecases = { autoLogoutCountdown, catalog, @@ -51,5 +55,9 @@ export const usecases = { dataExplorer, projectManagement, viewQuotas, - dataCollection + dataCollection, + // Next + s3CredentialsTest, + s3ProfilesManagement, + s3ProfilesCreationUiController }; From 4a810747905f1ecfec97464baf30053a58bb2ced Mon Sep 17 00:00:00 2001 From: garronej Date: Fri, 24 Oct 2025 16:15:26 +0200 Subject: [PATCH 02/41] POC of the new s3 explorer page --- .../_s3Next/s3ExplorerRootUiController/evt.ts | 48 +++++ .../s3ExplorerRootUiController/index.ts | 3 + .../s3ExplorerRootUiController/selectors.ts | 74 ++++++++ .../s3ExplorerRootUiController/state.ts | 54 ++++++ .../s3ExplorerRootUiController/thunks.ts | 79 ++++++++ .../decoupledLogic/s3Profiles.ts | 22 ++- web/src/core/usecases/index.ts | 4 +- web/src/ui/App/LeftBar.tsx | 12 ++ web/src/ui/pages/index.ts | 5 +- web/src/ui/pages/s3Explorer/Explorer.tsx | 172 ++++++++++++++++++ web/src/ui/pages/s3Explorer/Page.tsx | 115 ++++++++++++ web/src/ui/pages/s3Explorer/index.ts | 3 + web/src/ui/pages/s3Explorer/route.ts | 14 ++ web/src/ui/tools/withLoader.tsx | 8 +- 14 files changed, 597 insertions(+), 16 deletions(-) create mode 100644 web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts create mode 100644 web/src/core/usecases/_s3Next/s3ExplorerRootUiController/index.ts create mode 100644 web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts create mode 100644 web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts create mode 100644 web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts create mode 100644 web/src/ui/pages/s3Explorer/Explorer.tsx create mode 100644 web/src/ui/pages/s3Explorer/Page.tsx create mode 100644 web/src/ui/pages/s3Explorer/index.ts create mode 100644 web/src/ui/pages/s3Explorer/route.ts diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts new file mode 100644 index 000000000..9b38fc2e0 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts @@ -0,0 +1,48 @@ +import type { CreateEvt } from "core/bootstrap"; +import { Evt } from "evt"; +import { name, type RouteParams } from "./state"; +import { onlyIfChanged } from "evt/operators/onlyIfChanged"; +import { protectedSelectors } from "./selectors"; +import { same } from "evt/tools/inDepth/same"; + +export const createEvt = (({ evtAction, getState }) => { + const evtOut = Evt.create<{ + actionName: "updateRoute"; + method: "replace" | "push"; + routeParams: RouteParams; + }>(); + + evtAction + .pipe(action => (action.usecaseName !== name ? null : [action.actionName])) + .pipe(() => protectedSelectors.isStateInitialized(getState())) + .pipe(actionName => [ + { + actionName, + routeParams: protectedSelectors.routeParams(getState()) + } + ]) + .pipe( + onlyIfChanged({ + areEqual: (a, b) => same(a.routeParams, b.routeParams) + }) + ) + .attach(({ actionName, routeParams }) => { + if (actionName === "routeParamsSet") { + return; + } + + evtOut.post({ + actionName: "updateRoute", + method: (() => { + switch (actionName) { + case "locationUpdated": + case "selectedS3ProfileUpdated": + return "replace" as const; + } + })(), + routeParams + }); + }); + + return evtOut; +}) satisfies CreateEvt; diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/index.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/index.ts new file mode 100644 index 000000000..2dd929c75 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/index.ts @@ -0,0 +1,3 @@ +export * from "./thunks"; +export * from "./selectors"; +export * from "./state"; diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts new file mode 100644 index 000000000..5dc3ce955 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts @@ -0,0 +1,74 @@ +import type { State as RootState } from "core/bootstrap"; +import { name } from "./state"; +import { isObjectThatThrowIfAccessed, createSelector } from "clean-architecture"; +import { assert } from "tsafe"; +import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; +import type { LocalizedString } from "core/ports/OnyxiaApi"; + +const state = (rootState: RootState) => rootState[name]; + +export const protectedSelectors = { + isStateInitialized: createSelector(state, state => + isObjectThatThrowIfAccessed(state) + ), + routeParams: createSelector(state, state => state.routeParams) +}; + +export type View = { + selectedS3ProfileId: string | undefined; + availableS3Profiles: { + id: string; + displayName: string; + }[]; + bookmarks: { + displayName: LocalizedString | undefined; + bucket: string; + keyPrefix: string; + }[]; + location: { bucket: string; keyPrefix: string } | undefined; +}; + +const view = createSelector( + protectedSelectors.isStateInitialized, + protectedSelectors.routeParams, + s3ProfilesManagement.selectors.s3Profiles, + (isStateInitialized, routeParams, s3Profiles): View => { + assert(isStateInitialized); + + if (routeParams.profile === undefined) { + return { + selectedS3ProfileId: undefined, + availableS3Profiles: [], + bookmarks: [], + location: undefined + }; + } + + const selectedS3ProfileId = routeParams.profile; + + const s3Profile = s3Profiles.find( + s3Profile => s3Profile.id === selectedS3ProfileId + ); + + assert(s3Profile !== undefined); + + return { + selectedS3ProfileId, + availableS3Profiles: s3Profiles.map(s3Profile => ({ + id: s3Profile.id, + displayName: s3Profile.paramsOfCreateS3Client.url + })), + bookmarks: s3Profile.bookmarks, + location: + routeParams.bucket === undefined + ? undefined + : (assert(routeParams.prefix !== undefined), + { + bucket: routeParams.bucket, + keyPrefix: routeParams.prefix + }) + }; + } +); + +export const selectors = { view }; diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts new file mode 100644 index 000000000..dbf2563bb --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts @@ -0,0 +1,54 @@ +import { createUsecaseActions } from "clean-architecture"; +import { createObjectThatThrowsIfAccessed } from "clean-architecture"; + +export const name = "s3ExplorerRootUiController"; + +export type RouteParams = { + profile?: string; + bucket?: string; + prefix?: string; +}; + +export type State = { + routeParams: RouteParams; +}; + +export const { actions, reducer } = createUsecaseActions({ + name, + initialState: createObjectThatThrowsIfAccessed(), + reducers: { + routeParamsSet: ( + state, + { + payload + }: { + payload: { + routeParams: RouteParams; + }; + } + ) => { + const { routeParams } = payload; + + state.routeParams = routeParams; + }, + locationUpdated: ( + state, + { payload }: { payload: { bucket: string; keyPrefix: string } } + ) => { + const { bucket, keyPrefix } = payload; + + state.routeParams.bucket = bucket; + state.routeParams.prefix = keyPrefix; + }, + selectedS3ProfileUpdated: ( + state, + { payload }: { payload: { s3ProfileId: string } } + ) => { + const { s3ProfileId } = payload; + + state.routeParams.profile = s3ProfileId; + state.routeParams.bucket = undefined; + state.routeParams.prefix = undefined; + } + } +}); diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts new file mode 100644 index 000000000..0270abd5a --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts @@ -0,0 +1,79 @@ +import type { Thunks } from "core/bootstrap"; +import { actions, type RouteParams } from "./state"; +import { protectedSelectors } from "./selectors"; +import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; + +export const thunks = { + load: + (params: { routeParams: RouteParams }) => + (...args): { routeParams_toSet: RouteParams | undefined } => { + const [dispatch, getState] = args; + + const { routeParams } = params; + + if (routeParams.profile !== undefined) { + dispatch(actions.routeParamsSet({ routeParams })); + return { routeParams_toSet: undefined }; + } + + const isStateInitialized = protectedSelectors.isStateInitialized(getState()); + + if (isStateInitialized) { + const routeParams = protectedSelectors.routeParams(getState()); + return { routeParams_toSet: routeParams }; + } + + const s3Profiles = s3ProfilesManagement.selectors.s3Profiles(getState()); + + const s3Profile = + s3Profiles.find(s3Profile => s3Profile.origin === "defined in region") ?? + s3Profiles[0]; + + if (s3Profile === undefined) { + return { + routeParams_toSet: { + profile: undefined, + bucket: undefined, + prefix: undefined + } + }; + } + + const routeParams_toSet: RouteParams = { + profile: s3Profile.id, + bucket: undefined, + prefix: undefined + }; + + dispatch(actions.routeParamsSet({ routeParams: routeParams_toSet })); + + return { routeParams_toSet }; + }, + updateLocation: + (params: { bucket: string; keyPrefix: string }) => + (...args) => { + const [dispatch] = args; + + const { bucket, keyPrefix } = params; + + dispatch( + actions.locationUpdated({ + bucket, + keyPrefix + }) + ); + }, + updateSelectedS3Profile: + (params: { s3ProfileId: string }) => + (...args) => { + const [dispatch] = args; + + const { s3ProfileId } = params; + + dispatch( + actions.selectedS3ProfileUpdated({ + s3ProfileId + }) + ); + } +} satisfies Thunks; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts index 54c9cdf79..6fc6d1ea4 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -24,7 +24,7 @@ export namespace S3Profile { export type DefinedInRegion = Common & { origin: "defined in region"; paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts; - bookmarks: DefinedInRegion.Bookmark[]; + bookmarks: Bookmark[]; }; export namespace DefinedInRegion { @@ -42,16 +42,14 @@ export namespace S3Profile { creationTime: number; paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts; friendlyName: string; - bookmarks: CreatedByUser.Bookmark[]; + bookmarks: Bookmark[]; }; - export namespace CreatedByUser { - export type Bookmark = { - friendlyName: string; - bucket: string; - keyPrefix: string; - }; - } + export type Bookmark = { + displayName: LocalizedString | undefined; + bucket: string; + keyPrefix: string; + }; } export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { @@ -153,7 +151,11 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { ) ) ), - bookmarks: c.bookmarks, + bookmarks: c.bookmarks.map(({ title, bucket, keyPrefix }) => ({ + displayName: title, + bucket, + keyPrefix + })), paramsOfCreateS3Client, credentialsTestStatus: getCredentialsTestStatus({ paramsOfCreateS3Client diff --git a/web/src/core/usecases/index.ts b/web/src/core/usecases/index.ts index 55357aa7e..87d4157bc 100644 --- a/web/src/core/usecases/index.ts +++ b/web/src/core/usecases/index.ts @@ -28,6 +28,7 @@ import * as dataCollection from "./dataCollection"; import * as s3CredentialsTest from "./_s3Next/s3CredentialsTest"; import * as s3ProfilesManagement from "./_s3Next/s3ProfilesManagement"; import * as s3ProfilesCreationUiController from "./_s3Next/s3ProfilesCreationUiController"; +import * as s3ExplorerRootUiController from "./_s3Next/s3ExplorerRootUiController"; export const usecases = { autoLogoutCountdown, @@ -59,5 +60,6 @@ export const usecases = { // Next s3CredentialsTest, s3ProfilesManagement, - s3ProfilesCreationUiController + s3ProfilesCreationUiController, + s3ExplorerRootUiController }; diff --git a/web/src/ui/App/LeftBar.tsx b/web/src/ui/App/LeftBar.tsx index a6992b5cf..08da38309 100644 --- a/web/src/ui/App/LeftBar.tsx +++ b/web/src/ui/App/LeftBar.tsx @@ -121,6 +121,16 @@ export const LeftBar = memo((props: Props) => { link: routes.sqlOlapShell().link, availability: isDevModeEnabled ? "available" : "not visible" }, + { + itemId: "s3Explorer", + icon: customIcons.filesSvgUrl, + label: "File Explorer", + link: routes.s3Explorer().link, + availability: + isDevModeEnabled && isFileExplorerEnabled + ? "available" + : "not visible" + }, { groupId: "custom-leftbar-links", label: t("divider: onyxia instance specific features") @@ -168,6 +178,8 @@ export const LeftBar = memo((props: Props) => { return "dataExplorer"; case "dataCollection": return "dataCollection"; + case "s3Explorer": + return "s3Explorer"; case "page404": return null; case "document": diff --git a/web/src/ui/pages/index.ts b/web/src/ui/pages/index.ts index eb5118f4f..be6cec1ff 100644 --- a/web/src/ui/pages/index.ts +++ b/web/src/ui/pages/index.ts @@ -16,6 +16,8 @@ import * as dataExplorer from "./dataExplorer"; import * as fileExplorer from "./fileExplorerEntry"; import * as dataCollection from "./dataCollection"; +import * as s3Explorer from "./s3Explorer"; + export const pages = { account, catalog, @@ -31,7 +33,8 @@ export const pages = { sqlOlapShell, dataExplorer, fileExplorer, - dataCollection + dataCollection, + s3Explorer }; export const { routeDefs } = mergeRouteDefs({ pages }); diff --git a/web/src/ui/pages/s3Explorer/Explorer.tsx b/web/src/ui/pages/s3Explorer/Explorer.tsx new file mode 100644 index 000000000..43c771ae3 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/Explorer.tsx @@ -0,0 +1,172 @@ +import { useEffect } from "react"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import { copyToClipboard } from "ui/tools/copyToClipboard"; +import { useCoreState, getCoreSync } from "core"; +import { + Explorer as HeadlessExplorer, + type ExplorerProps as HeadlessExplorerProps +} from "../fileExplorer/Explorer"; +import { routes } from "ui/routes"; +import { useSplashScreen } from "onyxia-ui"; +import { Evt } from "evt"; +import type { Param0 } from "tsafe"; +import { useConst } from "powerhooks/useConst"; +import { assert } from "tsafe/assert"; +import { triggerBrowserDownload } from "ui/tools/triggerBrowserDonwload"; + +type Props = { + className?: string; + directoryPath: string; + changeCurrentDirectory: (params: { directoryPath: string }) => void; +}; + +export function Explorer(props: Props) { + const { className, directoryPath, changeCurrentDirectory } = props; + + const { + isCurrentWorkingDirectoryLoaded, + commandLogsEntries, + isNavigationOngoing, + uploadProgress, + currentWorkingDirectoryView, + pathMinDepth, + viewMode, + shareView, + isDownloadPreparing + } = useCoreState("fileExplorer", "main"); + + const evtIsSnackbarOpen = useConst(() => Evt.create(isDownloadPreparing)); + + useEffect(() => { + evtIsSnackbarOpen.state = isDownloadPreparing; + }, [isDownloadPreparing]); + + const { + functions: { fileExplorer } + } = getCoreSync(); + + useEffect(() => { + fileExplorer.changeCurrentDirectory({ + directoryPath + }); + }, [directoryPath]); + + const onRefresh = useConstCallback(() => fileExplorer.refreshCurrentDirectory()); + + const onCreateNewEmptyDirectory = useConstCallback( + ({ basename }: Param0) => + fileExplorer.createNewEmptyDirectory({ + basename + }) + ); + + const onDownloadItems = useConstCallback( + async (params: Param0) => { + const { items } = params; + + const { url, filename } = await fileExplorer.getBlobUrl({ + s3Objects: items + }); + + triggerBrowserDownload({ url, filename }); + } + ); + + const onDeleteItems = useConstCallback( + (params: Param0) => + fileExplorer.bulkDelete({ + s3Objects: params.items + }) + ); + + const onCopyPath = useConstCallback( + ({ path }: Param0) => { + assert(currentWorkingDirectoryView !== undefined); + return copyToClipboard( + path.split(currentWorkingDirectoryView.directoryPath.split("/")[0])[1] //get the path to object without + ); + } + ); + + const { showSplashScreen, hideSplashScreen } = useSplashScreen(); + + useEffect(() => { + if (currentWorkingDirectoryView === undefined) { + showSplashScreen({ enableTransparency: true }); + } else { + hideSplashScreen(); + } + }, [currentWorkingDirectoryView === undefined]); + + const evtExplorerAction = useConst(() => + Evt.create() + ); + + const onOpenFile = useConstCallback( + ({ basename }) => { + //TODO use dataExplorer thunk + if ( + basename.endsWith(".parquet") || + basename.endsWith(".csv") || + basename.endsWith(".json") + ) { + routes + .dataExplorer({ + source: `s3://${directoryPath.replace(/\/$/g, "")}/${basename}` + }) + .push(); + return; + } + + fileExplorer.getFileDownloadUrl({ basename }).then(window.open); + } + ); + + const onRequestFilesUpload = useConstCallback< + HeadlessExplorerProps["onRequestFilesUpload"] + >(({ files }) => + fileExplorer.uploadFiles({ + files + }) + ); + + if (!isCurrentWorkingDirectoryLoaded) { + return null; + } + + return ( + + ); +} diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx new file mode 100644 index 000000000..0811a5112 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -0,0 +1,115 @@ +import { useRoute, getRoute, routes } from "ui/routes"; +import { routeGroup } from "./route"; +import { assert } from "tsafe/assert"; +import { withLoader } from "ui/tools/withLoader"; +import { enforceLogin } from "ui/shared/enforceLogin"; +import { getCore, useCoreState, getCoreSync } from "core"; +import InputLabel from "@mui/material/InputLabel"; +import MenuItem from "@mui/material/MenuItem"; +import FormControl from "@mui/material/FormControl"; +import Select from "@mui/material/Select"; +import { Explorer } from "./Explorer"; +import { Button } from "onyxia-ui/Button"; +import { tss } from "tss"; + +const Page = withLoader({ + loader: async () => { + await enforceLogin(); + + const core = await getCore(); + + const route = getRoute(); + assert(routeGroup.has(route)); + + const { routeParams_toSet } = core.functions.s3ExplorerRootUiController.load({ + routeParams: route.params + }); + + if (routeParams_toSet !== undefined) { + routes.s3Explorer(routeParams_toSet).replace(); + } + }, + Component: S3Explorer +}); +export default Page; + +function S3Explorer() { + const route = useRoute(); + assert(routeGroup.has(route)); + + const { + functions: { s3ExplorerRootUiController } + } = getCoreSync(); + + const { selectedS3ProfileId, availableS3Profiles, bookmarks, location } = + useCoreState("s3ExplorerRootUiController", "view"); + + const { classes } = useStyles(); + + return ( +
+ + S3 Profile + + +
+ {bookmarks.map((bookmark, i) => ( + + ))} +
+ + {(() => { + if (selectedS3ProfileId === undefined) { + return

Create a profile

; + } + + if (location === undefined) { + return

Direct navigation

; + } + + return ( + { + const [bucket, ...rest] = directoryPath.split("/"); + s3ExplorerRootUiController.updateLocation({ + bucket, + keyPrefix: rest.join("/") + }); + }} + directoryPath={`${location.bucket}/${location.keyPrefix}`} + /> + ); + })()} +
+ ); +} + +const useStyles = tss.withName({ S3Explorer }).create(({ theme }) => ({ + bookmarksBar: { + display: "inline-flex", + gap: theme.spacing(2) + } +})); diff --git a/web/src/ui/pages/s3Explorer/index.ts b/web/src/ui/pages/s3Explorer/index.ts new file mode 100644 index 000000000..9cf4bc637 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/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/s3Explorer/route.ts b/web/src/ui/pages/s3Explorer/route.ts new file mode 100644 index 000000000..c6dd85ea1 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/route.ts @@ -0,0 +1,14 @@ +import { defineRoute, createGroup, param } from "type-route"; + +export const routeDefs = { + s3Explorer: defineRoute( + { + bucket: param.query.optional.string, + prefix: param.query.optional.string, + profile: param.query.optional.string + }, + () => `/s3` + ) +}; + +export const routeGroup = createGroup(routeDefs); diff --git a/web/src/ui/tools/withLoader.tsx b/web/src/ui/tools/withLoader.tsx index 95c306b5a..e80a59bc6 100644 --- a/web/src/ui/tools/withLoader.tsx +++ b/web/src/ui/tools/withLoader.tsx @@ -3,7 +3,7 @@ import { use } from "./use"; import { assert } from "tsafe"; export function withLoader>(params: { - loader: () => Promise; + loader: (props: NoInfer) => Promise; Component: ComponentType; FallbackComponent?: ComponentType; }): FC { @@ -15,13 +15,13 @@ export function withLoader>(params: { const [isLoaded, setIsLoaded] = useState(false); if (prLoaded === undefined) { - prLoaded = loader(); + prLoaded = loader(props); } useEffect(() => { let isActive = true; - (prLoaded ??= loader()).then(() => { + (prLoaded ??= loader(props)).then(() => { if (!isActive) { return; } @@ -52,7 +52,7 @@ export function withLoader>(params: { use( (prLoaded ??= (async () => { await Promise.resolve(); - await loader(); + await loader(props); await Promise.resolve(); })()) ); From f9b094c2472613657eaaea2ed0b0d5e0cc26b56e Mon Sep 17 00:00:00 2001 From: garronej Date: Fri, 24 Oct 2025 18:08:47 +0200 Subject: [PATCH 03/41] Fix boolean logic error --- .../usecases/_s3Next/s3ExplorerRootUiController/selectors.ts | 5 +++-- .../usecases/_s3Next/s3ExplorerRootUiController/state.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts index 5dc3ce955..867ba7ecf 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts @@ -8,8 +8,9 @@ import type { LocalizedString } from "core/ports/OnyxiaApi"; const state = (rootState: RootState) => rootState[name]; export const protectedSelectors = { - isStateInitialized: createSelector(state, state => - isObjectThatThrowIfAccessed(state) + isStateInitialized: createSelector( + state, + state => !isObjectThatThrowIfAccessed(state) ), routeParams: createSelector(state, state => state.routeParams) }; diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts index dbf2563bb..d11180573 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts @@ -18,7 +18,7 @@ export const { actions, reducer } = createUsecaseActions({ initialState: createObjectThatThrowsIfAccessed(), reducers: { routeParamsSet: ( - state, + _state, { payload }: { @@ -29,7 +29,7 @@ export const { actions, reducer } = createUsecaseActions({ ) => { const { routeParams } = payload; - state.routeParams = routeParams; + return { routeParams }; }, locationUpdated: ( state, From b9c2149cddf024c974441a70e7740a30862b4da3 Mon Sep 17 00:00:00 2001 From: garronej Date: Fri, 24 Oct 2025 18:11:30 +0200 Subject: [PATCH 04/41] Fix update route --- .../_s3Next/s3ExplorerRootUiController/index.ts | 1 + web/src/ui/pages/s3Explorer/Page.tsx | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/index.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/index.ts index 2dd929c75..8cede8377 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/index.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/index.ts @@ -1,3 +1,4 @@ export * from "./thunks"; export * from "./selectors"; export * from "./state"; +export * from "./evt"; diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 0811a5112..225a33b28 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -11,6 +11,7 @@ import Select from "@mui/material/Select"; import { Explorer } from "./Explorer"; import { Button } from "onyxia-ui/Button"; import { tss } from "tss"; +import { useEvt } from "evt/hooks"; const Page = withLoader({ loader: async () => { @@ -38,7 +39,8 @@ function S3Explorer() { assert(routeGroup.has(route)); const { - functions: { s3ExplorerRootUiController } + functions: { s3ExplorerRootUiController }, + evts: { evtS3ExplorerRootUiController } } = getCoreSync(); const { selectedS3ProfileId, availableS3Profiles, bookmarks, location } = @@ -46,6 +48,15 @@ function S3Explorer() { const { classes } = useStyles(); + useEvt(ctx => { + evtS3ExplorerRootUiController + .pipe(ctx) + .pipe(action => action.actionName === "updateRoute") + .attach(({ routeParams, method }) => + routes.s3Explorer(routeParams)[method]() + ); + }, []); + return (
From a29471daa1809024a4ab40ebca22d979d3e23713 Mon Sep 17 00:00:00 2001 From: garronej Date: Fri, 24 Oct 2025 18:18:24 +0200 Subject: [PATCH 05/41] Use special character to represent slash --- web/src/ui/pages/s3Explorer/route.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web/src/ui/pages/s3Explorer/route.ts b/web/src/ui/pages/s3Explorer/route.ts index c6dd85ea1..36887d16c 100644 --- a/web/src/ui/pages/s3Explorer/route.ts +++ b/web/src/ui/pages/s3Explorer/route.ts @@ -1,10 +1,17 @@ import { defineRoute, createGroup, param } from "type-route"; +import { type ValueSerializer } from "type-route"; +import { id } from "tsafe"; export const routeDefs = { s3Explorer: defineRoute( { bucket: param.query.optional.string, - prefix: param.query.optional.string, + prefix: param.query.optional.ofType( + id>({ + parse: raw => raw.replace(/\u2044/g, "/"), + stringify: value => value.replace(/\//g, "\u2044") + }) + ), profile: param.query.optional.string }, () => `/s3` From 93e509bc67be8e493ebac2b2a7590e21185d30f2 Mon Sep 17 00:00:00 2001 From: garronej Date: Mon, 3 Nov 2025 19:28:58 +0100 Subject: [PATCH 06/41] Give a different name to the leftbar entry for the new explorer --- web/src/ui/App/LeftBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/ui/App/LeftBar.tsx b/web/src/ui/App/LeftBar.tsx index 08da38309..b0bef8a57 100644 --- a/web/src/ui/App/LeftBar.tsx +++ b/web/src/ui/App/LeftBar.tsx @@ -124,7 +124,7 @@ export const LeftBar = memo((props: Props) => { { itemId: "s3Explorer", icon: customIcons.filesSvgUrl, - label: "File Explorer", + label: "S3 Explorer", link: routes.s3Explorer().link, availability: isDevModeEnabled && isFileExplorerEnabled From 169d26746a7e90f6dc640ae523d5814e84574b81 Mon Sep 17 00:00:00 2001 From: garronej Date: Mon, 3 Nov 2025 19:56:16 +0100 Subject: [PATCH 07/41] Update explorer headless component to be able to display bookmarks --- .../pages/fileExplorer/Explorer/Explorer.tsx | 78 +++++++------------ 1 file changed, 29 insertions(+), 49 deletions(-) diff --git a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx b/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx index 9a8cff56d..1d1198b1b 100644 --- a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx +++ b/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx @@ -19,10 +19,7 @@ import type { NonPostableEvt, StatefulReadonlyEvt, UnpackEvt } from "evt"; import { useEvt } from "evt/hooks"; import { ExplorerItems } from "./ExplorerItems"; import { ExplorerButtonBar, type ExplorerButtonBarProps } from "./ExplorerButtonBar"; -//TODO: The margin was set to itself be mindful when replacing by the onyxia-ui version. -import { DirectoryHeader } from "onyxia-ui/DirectoryHeader"; import { useDomRect } from "powerhooks/useDomRect"; -import { ExplorerIcon } from "./ExplorerIcon/ExplorerIcon"; import { Dialog } from "onyxia-ui/Dialog"; import { useCallbackFactory } from "powerhooks/useCallbackFactory"; import { Deferred } from "evt/tools/Deferred"; @@ -47,6 +44,8 @@ import { isDirectory } from "../shared/tools"; import { ShareDialog } from "../ShareFile/ShareDialog"; import type { ShareView } from "core/usecases/fileExplorer"; import { ExplorerDownloadSnackbar } from "./ExplorerDownloadSnackbar"; +import { IconButton } from "onyxia-ui/IconButton"; +import { getIconUrlByName } from "lazy-icons"; export type ExplorerProps = { /** @@ -99,6 +98,9 @@ export type ExplorerProps = { blob: Blob; }[]; }) => void; + + isDirectoryPathBookmarked: boolean | undefined; + onToggleIsDirectoryPathBookmarked: (() => void) | undefined; }; assert< @@ -141,7 +143,9 @@ export const Explorer = memo((props: ExplorerProps) => { onShareRequestSignedUrl, onChangeShareSelectedValidityDuration, onDownloadItems, - evtIsDownloadSnackbarOpen + evtIsDownloadSnackbarOpen, + isDirectoryPathBookmarked, + onToggleIsDirectoryPathBookmarked } = props; const [items] = useMemo( @@ -207,10 +211,6 @@ export const Explorer = memo((props: ExplorerProps) => { } ); - const onGoBack = useConstCallback(() => { - onNavigate({ directoryPath: pathJoin(directoryPath, "..") }); - }); - const evtExplorerItemsAction = useConst(() => Evt.create>() ); @@ -396,46 +396,15 @@ export const Explorer = memo((props: ExplorerProps) => { /> )} - {(() => { - const title = (() => { - let split = directoryPath.split("/"); - - // remove the last element - split.pop(); - - // remove path min depth elements at the beginning - split = split.slice(pathMinDepth + 1); - - if (split.length === 0) { - return undefined; - } - - return split[split.length - 1]; - })(); - - if (title === undefined) { - return null; - } - - return ( - - } - /> - ); - })()}
part !== "")} + path={directoryPath + .split("/") + .filter(part => part !== "") + .map((segment, i, arr) => + i === arr.length - 1 ? `${segment} /` : segment + )} isNavigationDisabled={isNavigating} onNavigate={onBreadcrumbNavigate} evtAction={evtBreadcrumbAction} @@ -447,6 +416,21 @@ export const Explorer = memo((props: ExplorerProps) => { className={classes.circularProgress} /> )} + {(() => { + if (isDirectoryPathBookmarked === undefined) { + return null; + } + assert(onToggleIsDirectoryPathBookmarked !== undefined); + + return ( + + ); + })()}
Date: Mon, 3 Nov 2025 19:57:19 +0100 Subject: [PATCH 08/41] Disable bookmark toggling for the legacy explorer --- web/src/ui/pages/fileExplorer/Page.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/ui/pages/fileExplorer/Page.tsx b/web/src/ui/pages/fileExplorer/Page.tsx index 9fa3701cb..3dda7cf4c 100644 --- a/web/src/ui/pages/fileExplorer/Page.tsx +++ b/web/src/ui/pages/fileExplorer/Page.tsx @@ -200,6 +200,8 @@ function FileExplorer() { } onDownloadItems={onDownloadItems} evtIsDownloadSnackbarOpen={evtIsSnackbarOpen} + isDirectoryPathBookmarked={undefined} + onToggleIsDirectoryPathBookmarked={undefined} />
); From e4438cfa858eb1eb674aaf10ca6536331a60057d Mon Sep 17 00:00:00 2001 From: garronej Date: Mon, 3 Nov 2025 20:01:32 +0100 Subject: [PATCH 09/41] Actually forward the bookmark state to the new controlled explorer --- web/src/ui/pages/s3Explorer/Explorer.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/src/ui/pages/s3Explorer/Explorer.tsx b/web/src/ui/pages/s3Explorer/Explorer.tsx index 43c771ae3..4126040ff 100644 --- a/web/src/ui/pages/s3Explorer/Explorer.tsx +++ b/web/src/ui/pages/s3Explorer/Explorer.tsx @@ -18,10 +18,18 @@ type Props = { className?: string; directoryPath: string; changeCurrentDirectory: (params: { directoryPath: string }) => void; + isDirectoryPathBookmarked: boolean; + onToggleIsDirectoryPathBookmarked: () => void; }; export function Explorer(props: Props) { - const { className, directoryPath, changeCurrentDirectory } = props; + const { + className, + directoryPath, + changeCurrentDirectory, + isDirectoryPathBookmarked, + onToggleIsDirectoryPathBookmarked + } = props; const { isCurrentWorkingDirectoryLoaded, @@ -167,6 +175,8 @@ export function Explorer(props: Props) { } onDownloadItems={onDownloadItems} evtIsDownloadSnackbarOpen={evtIsSnackbarOpen} + isDirectoryPathBookmarked={isDirectoryPathBookmarked} + onToggleIsDirectoryPathBookmarked={onToggleIsDirectoryPathBookmarked} /> ); } From 44b8c87a3692ee9a595b977f76430023b35f654f Mon Sep 17 00:00:00 2001 From: garronej Date: Mon, 3 Nov 2025 20:14:51 +0100 Subject: [PATCH 10/41] Scaffolding of the ui component we need for the new explorer. --- web/src/ui/pages/s3Explorer/Page.tsx | 194 +++++++++++++++++++++------ 1 file changed, 154 insertions(+), 40 deletions(-) diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 225a33b28..159fcdf0e 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useRoute, getRoute, routes } from "ui/routes"; import { routeGroup } from "./route"; import { assert } from "tsafe/assert"; @@ -9,9 +10,11 @@ import MenuItem from "@mui/material/MenuItem"; import FormControl from "@mui/material/FormControl"; import Select from "@mui/material/Select"; import { Explorer } from "./Explorer"; -import { Button } from "onyxia-ui/Button"; import { tss } from "tss"; import { useEvt } from "evt/hooks"; +import { Text } from "onyxia-ui/Text"; +import MuiLink from "@mui/material/Link"; +import { SearchBar } from "onyxia-ui/SearchBar"; const Page = withLoader({ loader: async () => { @@ -43,10 +46,12 @@ function S3Explorer() { evts: { evtS3ExplorerRootUiController } } = getCoreSync(); - const { selectedS3ProfileId, availableS3Profiles, bookmarks, location } = - useCoreState("s3ExplorerRootUiController", "view"); + const { selectedS3ProfileId, availableS3Profiles, location } = useCoreState( + "s3ExplorerRootUiController", + "view" + ); - const { classes } = useStyles(); + const { classes, css, theme } = useStyles(); useEvt(ctx => { evtS3ExplorerRootUiController @@ -58,38 +63,39 @@ function S3Explorer() { }, []); return ( -
- - S3 Profile - - -
- {bookmarks.map((bookmark, i) => ( - - ))} +
+
+ + S3 Profile + + +
{(() => { @@ -98,11 +104,18 @@ function S3Explorer() { } if (location === undefined) { - return

Direct navigation

; + return ( + + ); } return ( { const [bucket, ...rest] = directoryPath.split("/"); s3ExplorerRootUiController.updateLocation({ @@ -111,6 +124,10 @@ function S3Explorer() { }); }} directoryPath={`${location.bucket}/${location.keyPrefix}`} + isDirectoryPathBookmarked={false} + onToggleIsDirectoryPathBookmarked={() => { + alert("TODO: Implement this feature"); + }} /> ); })()} @@ -118,9 +135,106 @@ function S3Explorer() { ); } +function DirectNavigation(props: { className?: string }) { + const { className } = props; + + const { + functions: { s3ExplorerRootUiController } + } = getCoreSync(); + + const PREFIX = "s3://"; + + const [search, setSearch] = useState(PREFIX); + + return ( + { + switch (keyId) { + case "Enter": + { + const directoryPath = search.slice(PREFIX.length); + + const [bucket, ...rest] = directoryPath.split("/"); + + s3ExplorerRootUiController.updateLocation({ + bucket, + keyPrefix: rest.join("/") + }); + } + break; + case "Escape": + setSearch(PREFIX); + break; + } + }} + /> + ); +} + +function BookmarkBar(props: { className?: string }) { + const { className } = props; + + const { cx, css, theme } = useStyles(); + + const { + functions: { s3ExplorerRootUiController } + } = getCoreSync(); + + const { bookmarks } = useCoreState("s3ExplorerRootUiController", "view"); + + return ( +
+ + Bookmarks + + {bookmarks.map((bookmark, i) => ( + { + console.log("click"); + s3ExplorerRootUiController.updateLocation({ + bucket: bookmark.bucket, + keyPrefix: bookmark.keyPrefix + }); + }} + > + {`s3://${bookmark.bucket}/${bookmark.keyPrefix}`} + + ))} +
+ ); +} + const useStyles = tss.withName({ S3Explorer }).create(({ theme }) => ({ - bookmarksBar: { - display: "inline-flex", - gap: theme.spacing(2) + root: {}, + explorer: { + marginTop: theme.spacing(4) } })); From 71cbdc6831e3847e21cf66cc0097d08e98a31108 Mon Sep 17 00:00:00 2001 From: garronej Date: Mon, 3 Nov 2025 22:36:03 +0100 Subject: [PATCH 11/41] Disable default action on link --- web/src/ui/pages/s3Explorer/Page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 159fcdf0e..0cd622e9f 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -217,8 +217,8 @@ function BookmarkBar(props: { className?: string }) { ...theme.typography.variants.caption.style })} href="#" - onClick={() => { - console.log("click"); + onClick={e => { + e.preventDefault(); s3ExplorerRootUiController.updateLocation({ bucket: bookmark.bucket, keyPrefix: bookmark.keyPrefix From 13af7c5cdf99c600649b264e9c089aef4de36aa9 Mon Sep 17 00:00:00 2001 From: garronej Date: Tue, 4 Nov 2025 17:28:27 +0100 Subject: [PATCH 12/41] Fix external routing --- .../_s3Next/s3ExplorerRootUiController/evt.ts | 21 ++++++++----------- .../s3ExplorerRootUiController/thunks.ts | 16 ++++++++++++++ web/src/ui/pages/s3Explorer/Page.tsx | 18 ++++++++++++---- 3 files changed, 39 insertions(+), 16 deletions(-) diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts index 9b38fc2e0..6f04a2ade 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts @@ -5,13 +5,13 @@ import { onlyIfChanged } from "evt/operators/onlyIfChanged"; import { protectedSelectors } from "./selectors"; import { same } from "evt/tools/inDepth/same"; -export const createEvt = (({ evtAction, getState }) => { - const evtOut = Evt.create<{ - actionName: "updateRoute"; - method: "replace" | "push"; - routeParams: RouteParams; - }>(); +export const evt = Evt.create<{ + actionName: "updateRoute"; + method: "replace" | "push"; + routeParams: RouteParams; +}>(); +export const createEvt = (({ evtAction, getState }) => { evtAction .pipe(action => (action.usecaseName !== name ? null : [action.actionName])) .pipe(() => protectedSelectors.isStateInitialized(getState())) @@ -27,14 +27,11 @@ export const createEvt = (({ evtAction, getState }) => { }) ) .attach(({ actionName, routeParams }) => { - if (actionName === "routeParamsSet") { - return; - } - - evtOut.post({ + evt.post({ actionName: "updateRoute", method: (() => { switch (actionName) { + case "routeParamsSet": case "locationUpdated": case "selectedS3ProfileUpdated": return "replace" as const; @@ -44,5 +41,5 @@ export const createEvt = (({ evtAction, getState }) => { }); }); - return evtOut; + return evt; }) satisfies CreateEvt; diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts index 0270abd5a..0ad8d424f 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts @@ -2,6 +2,7 @@ import type { Thunks } from "core/bootstrap"; import { actions, type RouteParams } from "./state"; import { protectedSelectors } from "./selectors"; import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; +import { evt } from "./evt"; export const thunks = { load: @@ -49,6 +50,21 @@ export const thunks = { return { routeParams_toSet }; }, + notifyRouteParamsExternallyUpdated: + (params: { routeParams: RouteParams }) => + (...args) => { + const { routeParams } = params; + const [dispatch] = args; + const { routeParams_toSet } = dispatch(thunks.load({ routeParams })); + + if (routeParams_toSet !== undefined) { + evt.post({ + actionName: "updateRoute", + method: "replace", + routeParams: routeParams_toSet + }); + } + }, updateLocation: (params: { bucket: string; keyPrefix: string }) => (...args) => { diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 0cd622e9f..409681e94 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; -import { useRoute, getRoute, routes } from "ui/routes"; +import { useEffect } from "react"; +import { routes, getRoute, session } from "ui/routes"; import { routeGroup } from "./route"; import { assert } from "tsafe/assert"; import { withLoader } from "ui/tools/withLoader"; @@ -38,9 +39,6 @@ const Page = withLoader({ export default Page; function S3Explorer() { - const route = useRoute(); - assert(routeGroup.has(route)); - const { functions: { s3ExplorerRootUiController }, evts: { evtS3ExplorerRootUiController } @@ -62,6 +60,18 @@ function S3Explorer() { ); }, []); + useEffect( + () => + session.listen(route => { + if (routeGroup.has(route)) { + s3ExplorerRootUiController.notifyRouteParamsExternallyUpdated({ + routeParams: route.params + }); + } + }), + [] + ); + return (
Date: Tue, 4 Nov 2025 23:46:34 +0100 Subject: [PATCH 13/41] better navigation bar for new s3 explorer, better urls --- web/spec.md | 57 +++++++++++++++++++ web/src/core/tools/S3PrefixUrlParsed.ts | 31 ++++++++++ .../_s3Next/s3ExplorerRootUiController/evt.ts | 2 +- .../s3ExplorerRootUiController/selectors.ts | 15 ++--- .../s3ExplorerRootUiController/state.ts | 18 +++--- .../s3ExplorerRootUiController/thunks.ts | 20 +++---- web/src/ui/App/LeftBar.tsx | 4 +- .../pages/fileExplorer/Explorer/Explorer.tsx | 18 +++--- web/src/ui/pages/s3Explorer/Page.tsx | 54 +++++++++++------- web/src/ui/pages/s3Explorer/route.ts | 15 ++--- 10 files changed, 164 insertions(+), 70 deletions(-) create mode 100644 web/spec.md create mode 100644 web/src/core/tools/S3PrefixUrlParsed.ts diff --git a/web/spec.md b/web/spec.md new file mode 100644 index 000000000..22285b462 --- /dev/null +++ b/web/spec.md @@ -0,0 +1,57 @@ +Before: + +```js +{ + workingDirectory: { + bucketMode: "multi", + bucketNamePrefix: "", + bucketNamePrefixGroup: "projet-" + }, + bookmarkedDirectories: [ + { + fullPath: "donnees-insee/diffusion/", + title: { + fr: "Données de diffusion", + en: "Dissemination Data" + }, + description: { + fr: "Bucket public destiné à la diffusion de données", + en: "Public bucket intended for data dissemination" + } + } + ] +} +``` + +After: + +```js +{ + bookmarkedDirectories: [ + { + fullPath: "$1/", + title: "Personal", + description: "Personal storage", + claimName: "preferred_username" + }, + { + fullPath: "projet-$1/", + title: "Group $1", + description: "Shared storage for project $1", + claimName: "groups", + excludedClaimPattern: "^USER_ONYXIA$" + }, + { + fullPath: "donnees-insee/diffusion/", + title: { + fr: "Données de diffusion", + en: "Dissemination Data" + }, + description: { + fr: "Bucket public destiné à la diffusion de données", + en: "Public bucket intended for data dissemination" + } + } + ]; +} +``` diff --git a/web/src/core/tools/S3PrefixUrlParsed.ts b/web/src/core/tools/S3PrefixUrlParsed.ts new file mode 100644 index 000000000..0c5583074 --- /dev/null +++ b/web/src/core/tools/S3PrefixUrlParsed.ts @@ -0,0 +1,31 @@ +export type S3PrefixUrlParsed = { + bucket: string; + /** "" | `${string}/` */ + keyPrefix: string; +}; + +export namespace S3PrefixUrlParsed { + export function parse(str: string): S3PrefixUrlParsed { + const match = str.match(/^s3:\/\/([^/]+)(\/?.*)$/); + + if (match === null) { + throw new Error(`Malformed s3 prefix url: ${str}`); + } + + const bucket = match[1]; + + let keyPrefix = match[2].replace(/^\//, ""); + + if (keyPrefix !== "" && !keyPrefix.endsWith("/")) { + keyPrefix += "/"; + } + + return { bucket, keyPrefix }; + } + + export function stringify(obj: S3PrefixUrlParsed): string { + const { bucket, keyPrefix } = obj; + + return `s3://${bucket}/${keyPrefix}`; + } +} diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts index 6f04a2ade..c1d0dd6d3 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts @@ -32,7 +32,7 @@ export const createEvt = (({ evtAction, getState }) => { method: (() => { switch (actionName) { case "routeParamsSet": - case "locationUpdated": + case "s3UrlUpdated": case "selectedS3ProfileUpdated": return "replace" as const; } diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts index 867ba7ecf..1a6443ed3 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts @@ -4,6 +4,7 @@ import { isObjectThatThrowIfAccessed, createSelector } from "clean-architecture" import { assert } from "tsafe"; import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; import type { LocalizedString } from "core/ports/OnyxiaApi"; +import { S3PrefixUrlParsed } from "core/tools/S3PrefixUrlParsed"; const state = (rootState: RootState) => rootState[name]; @@ -26,7 +27,7 @@ export type View = { bucket: string; keyPrefix: string; }[]; - location: { bucket: string; keyPrefix: string } | undefined; + s3Url_parsed: S3PrefixUrlParsed | undefined; }; const view = createSelector( @@ -41,7 +42,7 @@ const view = createSelector( selectedS3ProfileId: undefined, availableS3Profiles: [], bookmarks: [], - location: undefined + s3Url_parsed: undefined }; } @@ -60,14 +61,10 @@ const view = createSelector( displayName: s3Profile.paramsOfCreateS3Client.url })), bookmarks: s3Profile.bookmarks, - location: - routeParams.bucket === undefined + s3Url_parsed: + routeParams.path === "" ? undefined - : (assert(routeParams.prefix !== undefined), - { - bucket: routeParams.bucket, - keyPrefix: routeParams.prefix - }) + : S3PrefixUrlParsed.parse(`s3://${routeParams.path}`) }; } ); diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts index d11180573..93695e709 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts @@ -1,12 +1,12 @@ import { createUsecaseActions } from "clean-architecture"; import { createObjectThatThrowsIfAccessed } from "clean-architecture"; +import { S3PrefixUrlParsed } from "core/tools/S3PrefixUrlParsed"; export const name = "s3ExplorerRootUiController"; export type RouteParams = { profile?: string; - bucket?: string; - prefix?: string; + path: string; }; export type State = { @@ -31,14 +31,15 @@ export const { actions, reducer } = createUsecaseActions({ return { routeParams }; }, - locationUpdated: ( + s3UrlUpdated: ( state, - { payload }: { payload: { bucket: string; keyPrefix: string } } + { payload }: { payload: { s3Url_parsed: S3PrefixUrlParsed } } ) => { - const { bucket, keyPrefix } = payload; + const { s3Url_parsed } = payload; - state.routeParams.bucket = bucket; - state.routeParams.prefix = keyPrefix; + state.routeParams.path = S3PrefixUrlParsed.stringify(s3Url_parsed).slice( + "s3://".length + ); }, selectedS3ProfileUpdated: ( state, @@ -47,8 +48,7 @@ export const { actions, reducer } = createUsecaseActions({ const { s3ProfileId } = payload; state.routeParams.profile = s3ProfileId; - state.routeParams.bucket = undefined; - state.routeParams.prefix = undefined; + state.routeParams.path = ""; } } }); diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts index 0ad8d424f..faae14820 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts @@ -3,6 +3,7 @@ import { actions, type RouteParams } from "./state"; import { protectedSelectors } from "./selectors"; import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; import { evt } from "./evt"; +import type { S3PrefixUrlParsed } from "core/tools/S3PrefixUrlParsed"; export const thunks = { load: @@ -34,16 +35,14 @@ export const thunks = { return { routeParams_toSet: { profile: undefined, - bucket: undefined, - prefix: undefined + path: "" } }; } const routeParams_toSet: RouteParams = { profile: s3Profile.id, - bucket: undefined, - prefix: undefined + path: "" }; dispatch(actions.routeParamsSet({ routeParams: routeParams_toSet })); @@ -65,19 +64,14 @@ export const thunks = { }); } }, - updateLocation: - (params: { bucket: string; keyPrefix: string }) => + updateS3Url: + (params: { s3Url_parsed: S3PrefixUrlParsed }) => (...args) => { const [dispatch] = args; - const { bucket, keyPrefix } = params; + const { s3Url_parsed } = params; - dispatch( - actions.locationUpdated({ - bucket, - keyPrefix - }) - ); + dispatch(actions.s3UrlUpdated({ s3Url_parsed })); }, updateSelectedS3Profile: (params: { s3ProfileId: string }) => diff --git a/web/src/ui/App/LeftBar.tsx b/web/src/ui/App/LeftBar.tsx index b0bef8a57..899e4c112 100644 --- a/web/src/ui/App/LeftBar.tsx +++ b/web/src/ui/App/LeftBar.tsx @@ -125,7 +125,9 @@ export const LeftBar = memo((props: Props) => { itemId: "s3Explorer", icon: customIcons.filesSvgUrl, label: "S3 Explorer", - link: routes.s3Explorer().link, + link: routes.s3Explorer({ + path: "" + }).link, availability: isDevModeEnabled && isFileExplorerEnabled ? "available" diff --git a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx b/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx index 1d1198b1b..a49818908 100644 --- a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx +++ b/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx @@ -167,7 +167,9 @@ export const Explorer = memo((props: ExplorerProps) => { ); const onBreadcrumbNavigate = useConstCallback( - ({ upCount }: Param0) => { + ({ upCount, path }: Param0) => { + console.log(path); + onNavigate({ directoryPath: pathJoin(directoryPath, ...new Array(upCount).fill("..")) }); @@ -399,12 +401,14 @@ export const Explorer = memo((props: ExplorerProps) => {
part !== "") - .map((segment, i, arr) => - i === arr.length - 1 ? `${segment} /` : segment - )} + path={[ + "s3://", + ...directoryPath + .split("/") + .filter(segment => segment !== "") + .map(segment => `${segment}/`) + ]} + separatorChar="​" isNavigationDisabled={isNavigating} onNavigate={onBreadcrumbNavigate} evtAction={evtBreadcrumbAction} diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 409681e94..43cbdfed6 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useEffect } from "react"; import { routes, getRoute, session } from "ui/routes"; import { routeGroup } from "./route"; @@ -16,6 +16,7 @@ import { useEvt } from "evt/hooks"; import { Text } from "onyxia-ui/Text"; import MuiLink from "@mui/material/Link"; import { SearchBar } from "onyxia-ui/SearchBar"; +import { S3PrefixUrlParsed } from "core/tools/S3PrefixUrlParsed"; const Page = withLoader({ loader: async () => { @@ -44,7 +45,7 @@ function S3Explorer() { evts: { evtS3ExplorerRootUiController } } = getCoreSync(); - const { selectedS3ProfileId, availableS3Profiles, location } = useCoreState( + const { selectedS3ProfileId, availableS3Profiles, s3Url_parsed } = useCoreState( "s3ExplorerRootUiController", "view" ); @@ -113,7 +114,7 @@ function S3Explorer() { return

Create a profile

; } - if (location === undefined) { + if (s3Url_parsed === undefined) { return ( { - const [bucket, ...rest] = directoryPath.split("/"); - s3ExplorerRootUiController.updateLocation({ - bucket, - keyPrefix: rest.join("/") + const s3Url_parsed = S3PrefixUrlParsed.parse( + `s3://${directoryPath}` + ); + + s3ExplorerRootUiController.updateS3Url({ + s3Url_parsed }); }} - directoryPath={`${location.bucket}/${location.keyPrefix}`} + directoryPath={S3PrefixUrlParsed.stringify(s3Url_parsed).slice( + "s3://".length + )} isDirectoryPathBookmarked={false} onToggleIsDirectoryPathBookmarked={() => { alert("TODO: Implement this feature"); @@ -152,9 +157,17 @@ function DirectNavigation(props: { className?: string }) { functions: { s3ExplorerRootUiController } } = getCoreSync(); - const PREFIX = "s3://"; + const PROTOCOL = "s3://"; + + const [search, setSearch] = useState(PROTOCOL); - const [search, setSearch] = useState(PREFIX); + const s3Url_parsed = useMemo(() => { + try { + return S3PrefixUrlParsed.parse(search); + } catch { + return undefined; + } + }, [search]); return ( { e.preventDefault(); - s3ExplorerRootUiController.updateLocation({ - bucket: bookmark.bucket, - keyPrefix: bookmark.keyPrefix + s3ExplorerRootUiController.updateS3Url({ + s3Url_parsed: { + bucket: bookmark.bucket, + keyPrefix: bookmark.keyPrefix + } }); }} > diff --git a/web/src/ui/pages/s3Explorer/route.ts b/web/src/ui/pages/s3Explorer/route.ts index 36887d16c..a3b60a6b7 100644 --- a/web/src/ui/pages/s3Explorer/route.ts +++ b/web/src/ui/pages/s3Explorer/route.ts @@ -1,20 +1,15 @@ import { defineRoute, createGroup, param } from "type-route"; -import { type ValueSerializer } from "type-route"; -import { id } from "tsafe"; export const routeDefs = { s3Explorer: defineRoute( { - bucket: param.query.optional.string, - prefix: param.query.optional.ofType( - id>({ - parse: raw => raw.replace(/\u2044/g, "/"), - stringify: value => value.replace(/\//g, "\u2044") - }) - ), + path: param.path.trailing.ofType({ + parse: raw => decodeURIComponent(raw), // decode the path + stringify: value => encodeURI(value) // encode when creating URL + }), profile: param.query.optional.string }, - () => `/s3` + ({ path }) => `/s3/${path}` ) }; From 7de8c570e2b93c317459f9e648b92368a887f395 Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 5 Nov 2025 00:06:45 +0100 Subject: [PATCH 14/41] Provide a direct navigation also for the new (and for the legacy) s3 explorer --- .../_s3Next/s3ExplorerRootUiController/state.ts | 9 +++++---- .../_s3Next/s3ExplorerRootUiController/thunks.ts | 2 +- web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx | 8 +++++--- web/src/ui/pages/fileExplorer/Page.tsx | 13 ++++++++++++- web/src/ui/pages/s3Explorer/Page.tsx | 7 ++++--- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts index 93695e709..a65263736 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts @@ -33,13 +33,14 @@ export const { actions, reducer } = createUsecaseActions({ }, s3UrlUpdated: ( state, - { payload }: { payload: { s3Url_parsed: S3PrefixUrlParsed } } + { payload }: { payload: { s3Url_parsed: S3PrefixUrlParsed | undefined } } ) => { const { s3Url_parsed } = payload; - state.routeParams.path = S3PrefixUrlParsed.stringify(s3Url_parsed).slice( - "s3://".length - ); + state.routeParams.path = + s3Url_parsed === undefined + ? "" + : S3PrefixUrlParsed.stringify(s3Url_parsed).slice("s3://".length); }, selectedS3ProfileUpdated: ( state, diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts index faae14820..3dfd958c1 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts @@ -65,7 +65,7 @@ export const thunks = { } }, updateS3Url: - (params: { s3Url_parsed: S3PrefixUrlParsed }) => + (params: { s3Url_parsed: S3PrefixUrlParsed | undefined }) => (...args) => { const [dispatch] = args; diff --git a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx b/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx index a49818908..b3334b668 100644 --- a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx +++ b/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx @@ -167,11 +167,13 @@ export const Explorer = memo((props: ExplorerProps) => { ); const onBreadcrumbNavigate = useConstCallback( - ({ upCount, path }: Param0) => { - console.log(path); + ({ path }: Param0) => { + assert(path.length !== 0); + + const [, ...rest] = path; onNavigate({ - directoryPath: pathJoin(directoryPath, ...new Array(upCount).fill("..")) + directoryPath: rest.join("") }); } ); diff --git a/web/src/ui/pages/fileExplorer/Page.tsx b/web/src/ui/pages/fileExplorer/Page.tsx index 3dda7cf4c..e518e4d53 100644 --- a/web/src/ui/pages/fileExplorer/Page.tsx +++ b/web/src/ui/pages/fileExplorer/Page.tsx @@ -151,6 +151,17 @@ function FileExplorer() { }) ); + const onNavigate = useConstCallback( + ({ directoryPath }) => { + if (directoryPath === "") { + routes.fileExplorerEntry().push(); + return; + } + + fileExplorer.changeCurrentDirectory({ directoryPath }); + } + ); + if (!isCurrentWorkingDirectoryLoaded) { return null; } @@ -183,7 +194,7 @@ function FileExplorer() { currentWorkingDirectoryView.isBucketPolicyFeatureEnabled } changePolicy={fileExplorer.changePolicy} - onNavigate={fileExplorer.changeCurrentDirectory} + onNavigate={onNavigate} onRefresh={onRefresh} onDeleteItems={onDeleteItems} onCopyPath={onCopyPath} diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 43cbdfed6..8f87ba6b7 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -128,9 +128,10 @@ function S3Explorer() { { - const s3Url_parsed = S3PrefixUrlParsed.parse( - `s3://${directoryPath}` - ); + const s3Url_parsed = + directoryPath === "" + ? undefined + : S3PrefixUrlParsed.parse(`s3://${directoryPath}`); s3ExplorerRootUiController.updateS3Url({ s3Url_parsed From 0bd024ab84fa51e53d9c626f881df24d57093d8e Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 5 Nov 2025 04:39:29 +0100 Subject: [PATCH 15/41] Standardize S3 URI --- web/spec.md | 4 +- web/src/core/adapters/onyxiaApi/ApiTypes.ts | 10 +- web/src/core/adapters/onyxiaApi/onyxiaApi.ts | 149 +++++++++++++++--- web/src/core/adapters/s3Client/s3Client.ts | 70 +++++--- .../bucketNameAndObjectNameFromS3Path.ts | 16 -- .../core/ports/OnyxiaApi/DeploymentRegion.ts | 3 +- web/src/core/tools/S3PrefixUrlParsed.ts | 31 ---- web/src/core/tools/S3Uri.ts | 84 ++++++++++ .../s3ExplorerRootUiController/selectors.ts | 16 +- .../s3ExplorerRootUiController/state.ts | 10 +- .../s3ExplorerRootUiController/thunks.ts | 8 +- .../resolveTemplatedBookmark.ts | 29 ++-- .../decoupledLogic/s3Profiles.ts | 22 +-- .../_s3Next/s3ProfilesManagement/state.ts | 4 +- web/src/core/usecases/launcher/thunks.ts | 9 +- .../usecases/s3ConfigCreation/selectors.ts | 8 +- .../decoupledLogic/getS3Configs.ts | 8 +- web/src/ui/pages/s3Explorer/Page.tsx | 35 ++-- 18 files changed, 341 insertions(+), 175 deletions(-) delete mode 100644 web/src/core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path.ts delete mode 100644 web/src/core/tools/S3PrefixUrlParsed.ts create mode 100644 web/src/core/tools/S3Uri.ts diff --git a/web/spec.md b/web/spec.md index 22285b462..6624f2c1b 100644 --- a/web/spec.md +++ b/web/spec.md @@ -31,13 +31,13 @@ After: { fullPath: "$1/", title: "Personal", - description: "Personal storage", + description: "Personal Bucket", claimName: "preferred_username" }, { fullPath: "projet-$1/", title: "Group $1", - description: "Shared storage for project $1", + description: "Shared bucket among members of project $1", claimName: "groups", excludedClaimPattern: "^USER_ONYXIA$" }, diff --git a/web/src/core/adapters/onyxiaApi/ApiTypes.ts b/web/src/core/adapters/onyxiaApi/ApiTypes.ts index 798acfd8c..02432009a 100644 --- a/web/src/core/adapters/onyxiaApi/ApiTypes.ts +++ b/web/src/core/adapters/onyxiaApi/ApiTypes.ts @@ -116,14 +116,14 @@ export type ApiTypes = { bookmarkedDirectories?: ({ fullPath: string; title: LocalizedString; - description: LocalizedString | undefined; - tags: LocalizedString[] | undefined; + description?: LocalizedString; + tags?: LocalizedString[]; } & ( - | { claimName: undefined } + | { claimName?: undefined } | { claimName: string; - includedClaimPattern: string | undefined; - excludedClaimPattern: string | undefined; + includedClaimPattern?: string; + excludedClaimPattern?: string; } ))[]; }>; diff --git a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts index 15e826674..94f9d18c1 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -21,7 +21,9 @@ import { exclude } from "tsafe/exclude"; import type { ApiTypes } from "./ApiTypes"; import { Evt } from "evt"; import { id } from "tsafe/id"; -import { bucketNameAndObjectNameFromS3Path } from "core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path"; +import { decodeJwt } from "oidc-spa/tools/decodeJwt"; +import { parseS3UriPrefix } from "core/tools/S3Uri"; +import type { LocalizedString } from "core/ports/OnyxiaApi"; export function createOnyxiaApi(params: { url: string; @@ -188,6 +190,54 @@ export function createOnyxiaApi(params: { })() }); + const bookmarkedDirectories_test = await (async () => { + const isJoseph = await (async () => { + const accessToken = await getOidcAccessToken(); + + if (accessToken === undefined) { + return false; + } + + const { preferred_username } = decodeJwt(accessToken) as any; + + return preferred_username === "garronej"; + })(); + + if (!isJoseph) { + return []; + } + + return id< + ({ + fullPath: string; + title: LocalizedString; + description?: LocalizedString; + tags?: LocalizedString[]; + } & ( + | { claimName?: undefined } + | { + claimName: string; + includedClaimPattern?: string; + excludedClaimPattern?: string; + } + ))[] + >([ + { + fullPath: "$1/", + title: "Personal", + description: "Personal Bucket", + claimName: "preferred_username" + }, + { + fullPath: "projet-$1/", + title: "Group $1", + description: "Shared bucket among members of project $1", + claimName: "groups", + excludedClaimPattern: "^USER_ONYXIA$" + } + ]); + })(); + const regions = data.regions.map( (apiRegion): DeploymentRegion => id({ @@ -310,8 +360,40 @@ export function createOnyxiaApi(params: { workingDirectory: s3Config_api.workingDirectory, bookmarkedDirectories: - s3Config_api.bookmarkedDirectories ?? - [] + s3Config_api.bookmarkedDirectories?.map( + bookmarkedDirectory_api => { + const { + fullPath, + title, + description, + tags, + ...rest + } = bookmarkedDirectory_api; + + return id( + { + fullPath, + title, + description, + tags, + ...(rest.claimName === + undefined + ? { + claimName: + undefined + } + : { + claimName: + rest.claimName, + includedClaimPattern: + rest.includedClaimPattern, + excludedClaimPattern: + rest.excludedClaimPattern + }) + } + ); + } + ) ?? [] }) ); @@ -334,34 +416,49 @@ export function createOnyxiaApi(params: { pathStyleAccess, region, sts, - bookmarks: bookmarkedDirectories.map( - ({ + bookmarks: [ + ...bookmarkedDirectories_test, + ...bookmarkedDirectories + ].map(bookmarkedDirectory_api => { + const { fullPath, title, description, tags, ...rest - }) => { - const { - bucketName, - objectName - } = - bucketNameAndObjectNameFromS3Path( - fullPath - ); - - return id( - { - bucket: bucketName, - keyPrefix: objectName, - title, - description, - tags: tags ?? [], - ...rest - } - ); - } - ) + } = bookmarkedDirectory_api; + + const s3UriPrefix = `s3://${fullPath}`; + + // NOTE: Just for checking shape. + parseS3UriPrefix({ + s3UriPrefix, + strict: true + }); + + return id( + { + s3UriPrefix, + title, + description, + tags: tags ?? [], + ...(rest.claimName === + undefined + ? { + claimName: + undefined + } + : { + claimName: + rest.claimName, + includedClaimPattern: + rest.includedClaimPattern, + excludedClaimPattern: + rest.excludedClaimPattern + }) + } + ); + }) }) ) ), diff --git a/web/src/core/adapters/s3Client/s3Client.ts b/web/src/core/adapters/s3Client/s3Client.ts index 2bb831723..7dd76ab04 100644 --- a/web/src/core/adapters/s3Client/s3Client.ts +++ b/web/src/core/adapters/s3Client/s3Client.ts @@ -5,7 +5,7 @@ import { } from "core/tools/getNewlyRequestedOrCachedToken"; import { assert, is } from "tsafe/assert"; import type { Oidc } from "core/ports/Oidc"; -import { bucketNameAndObjectNameFromS3Path } from "./utils/bucketNameAndObjectNameFromS3Path"; +import { parseS3UriPrefix, getIsS3UriPrefix, parseS3Uri } from "core/tools/S3Uri"; import { exclude } from "tsafe/exclude"; import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; import { getPolicyAttributes } from "core/tools/getPolicyAttributes"; @@ -283,22 +283,10 @@ export function createS3Client( return getNewlyRequestedOrCachedToken(); }, listObjects: async ({ path }) => { - const { bucketName, prefix } = (() => { - const { bucketName, objectName } = - bucketNameAndObjectNameFromS3Path(path); - - const prefix = - objectName === "" - ? "" - : objectName.endsWith("/") - ? objectName - : `${objectName}/`; - - return { - bucketName, - prefix - }; - })(); + const { bucket: bucketName, keyPrefix: prefix } = parseS3UriPrefix({ + s3UriPrefix: `s3://${path}`, + strict: true + }); const { getAwsS3Client } = await prApi; @@ -471,11 +459,43 @@ export function createS3Client( isBucketPolicyAvailable }; }, + // TODO: @ddecrulle Please refactor this, objectName can either be a + // a keyPrefix or a fully qualified key but there is multiple level of + // indirection, the check is done deep instead of upfront. + // I'm pretty sure that having a * at the end of the resourceArn when setting access right + // for a specific object is not what we want. + // When extracting things to standalone utils the contract must be clearly + // defined, here it is not so it only give the feeling of decoupling but + // in reality it's impossible to guess what addResourceArnInGetObjectStatement is doing + // plus naming things is hard, bad names and bad abstractions are harmful because misleading. + // this function is not adding anything to anything it's returning something. + // Plus resourceArn already encapsulate the bucketName and objectName. + // Here we have 4 functions that are used once, that involve implicit coupling and with misleading name. + // SO, if you can't abstract away in a clean way, just don't and put everything inline + // in closures. At least we know where we stand. setPathAccessPolicy: async ({ currentBucketPolicy, policy, path }) => { const { getAwsS3Client } = await prApi; const { awsS3Client } = await getAwsS3Client(); - const { bucketName, objectName } = bucketNameAndObjectNameFromS3Path(path); + const { bucketName, objectName } = (() => { + if (getIsS3UriPrefix(`s3://${path}`)) { + const s3UriPrefixObj = parseS3UriPrefix({ + s3UriPrefix: `s3://${path}`, + strict: true + }); + return { + bucketName: s3UriPrefixObj.bucket, + objectName: s3UriPrefixObj.keyPrefix + }; + } + + const s3UriObj = parseS3Uri(`s3://${path}`); + + return { + bucketName: s3UriObj.bucket, + objectName: s3UriObj.key + }; + })(); const resourceArn = `arn:aws:s3:::${bucketName}/${objectName}*`; const bucketArn = `arn:aws:s3:::${bucketName}`; @@ -527,7 +547,7 @@ export function createS3Client( import("@aws-sdk/lib-storage").then(({ Upload }) => Upload) ]); - const { bucketName, objectName } = bucketNameAndObjectNameFromS3Path(path); + const { bucket: bucketName, key: objectName } = parseS3Uri(`s3://${path}`); const upload = new Upload({ client: awsS3Client, @@ -558,7 +578,7 @@ export function createS3Client( await upload.done(); }, deleteFile: async ({ path }) => { - const { bucketName, objectName } = bucketNameAndObjectNameFromS3Path(path); + const { bucket: bucketName, key: objectName } = parseS3Uri(`s3://${path}`); const { getAwsS3Client } = await prApi; @@ -573,7 +593,7 @@ export function createS3Client( }, deleteFiles: async ({ paths }) => { //bucketName is the same for all paths - const { bucketName } = bucketNameAndObjectNameFromS3Path(paths[0]); + const { bucket: bucketName } = parseS3Uri(`s3://${paths[0]}`); const { getAwsS3Client } = await prApi; @@ -582,7 +602,7 @@ export function createS3Client( const { DeleteObjectsCommand } = await import("@aws-sdk/client-s3"); const objects = paths.map(path => { - const { objectName } = bucketNameAndObjectNameFromS3Path(path); + const { key: objectName } = parseS3Uri(`s3://${path}`); return { Key: objectName }; }); @@ -599,7 +619,7 @@ export function createS3Client( } }, getFileDownloadUrl: async ({ path, validityDurationSecond }) => { - const { bucketName, objectName } = bucketNameAndObjectNameFromS3Path(path); + const { bucket: bucketName, key: objectName } = parseS3Uri(`s3://${path}`); const { getAwsS3Client } = await prApi; @@ -622,7 +642,7 @@ export function createS3Client( }, getFileContent: async ({ path, range }) => { - const { bucketName, objectName } = bucketNameAndObjectNameFromS3Path(path); + const { bucket: bucketName, key: objectName } = parseS3Uri(`s3://${path}`); const { getAwsS3Client } = await prApi; const { awsS3Client } = await getAwsS3Client(); @@ -648,7 +668,7 @@ export function createS3Client( }, getFileContentType: async ({ path }) => { - const { bucketName, objectName } = bucketNameAndObjectNameFromS3Path(path); + const { bucket: bucketName, key: objectName } = parseS3Uri(`s3://${path}`); const { getAwsS3Client } = await prApi; diff --git a/web/src/core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path.ts b/web/src/core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path.ts deleted file mode 100644 index 1603254d1..000000000 --- a/web/src/core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * "/bucket-name/object/name" => { bucketName: "bucket-name", objectName: "object/name" } - * "bucket-name/object/name" => { bucketName: "bucket-name", objectName: "object/name" } - * "bucket-name/object/name/" => { bucketName: "bucket-name", objectName: "object/name/" } - * "bucket-name/" => { bucketName: "bucket-name", objectName: "" } - * "bucket-name" => { bucketName: "bucket-name", objectName: "" } - * "s3://bucket-name/object/name" => { bucketName: "bucket-name", objectName: "object/name" } - */ -export function bucketNameAndObjectNameFromS3Path(path: string) { - const [bucketName, ...rest] = path.replace(/^(s3:)?\/+/, "").split("/"); - - return { - bucketName, - objectName: rest.join("/") - }; -} diff --git a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts index 3129f4bd3..f1f48812c 100644 --- a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts +++ b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts @@ -193,8 +193,7 @@ export namespace DeploymentRegion { export namespace S3Profile { export type Bookmark = { - bucket: string; - keyPrefix: string; + s3UriPrefix: string; title: LocalizedString; description: LocalizedString | undefined; tags: LocalizedString[]; diff --git a/web/src/core/tools/S3PrefixUrlParsed.ts b/web/src/core/tools/S3PrefixUrlParsed.ts deleted file mode 100644 index 0c5583074..000000000 --- a/web/src/core/tools/S3PrefixUrlParsed.ts +++ /dev/null @@ -1,31 +0,0 @@ -export type S3PrefixUrlParsed = { - bucket: string; - /** "" | `${string}/` */ - keyPrefix: string; -}; - -export namespace S3PrefixUrlParsed { - export function parse(str: string): S3PrefixUrlParsed { - const match = str.match(/^s3:\/\/([^/]+)(\/?.*)$/); - - if (match === null) { - throw new Error(`Malformed s3 prefix url: ${str}`); - } - - const bucket = match[1]; - - let keyPrefix = match[2].replace(/^\//, ""); - - if (keyPrefix !== "" && !keyPrefix.endsWith("/")) { - keyPrefix += "/"; - } - - return { bucket, keyPrefix }; - } - - export function stringify(obj: S3PrefixUrlParsed): string { - const { bucket, keyPrefix } = obj; - - return `s3://${bucket}/${keyPrefix}`; - } -} diff --git a/web/src/core/tools/S3Uri.ts b/web/src/core/tools/S3Uri.ts new file mode 100644 index 000000000..dbf1e931e --- /dev/null +++ b/web/src/core/tools/S3Uri.ts @@ -0,0 +1,84 @@ +export type S3UriPrefixObj = { + bucket: string; + /** "" | `${string}/` */ + keyPrefix: string; +}; + +export function parseS3UriPrefix(params: { + s3UriPrefix: string; + strict: boolean; +}): S3UriPrefixObj { + const { s3UriPrefix, strict } = params; + + const match = s3UriPrefix.match(/^s3:\/\/([^/]+)(\/?.*)$/); + + if (match === null) { + throw new Error(`Malformed S3 URI Prefix: ${s3UriPrefix}`); + } + + const bucket = match[1]; + + let keyPrefix = match[2]; + + if (strict && !keyPrefix.endsWith("/")) { + throw new Error( + [ + `Invalid S3 URI Prefix: "${s3UriPrefix}".`, + `A S3 URI Prefix should end with a "/" character.` + ].join(" ") + ); + } + + keyPrefix = match[2].replace(/^\//, ""); + + if (keyPrefix !== "" && !keyPrefix.endsWith("/")) { + keyPrefix += "/"; + } + + const s3UriPrefixObj = { bucket, keyPrefix }; + + return s3UriPrefixObj; +} + +export function stringifyS3UriPrefixObj(s3UriPrefixObj: S3UriPrefixObj): string { + return `s3://${s3UriPrefixObj.bucket}/${s3UriPrefixObj.keyPrefix}`; +} + +export function getIsS3UriPrefix(str: string): boolean { + try { + parseS3UriPrefix({ + s3UriPrefix: str, + strict: true + }); + } catch { + return false; + } + + return true; +} + +export type S3UriObj = { + bucket: string; + key: string; +}; + +export function parseS3Uri(s3Uri: string): S3UriObj { + if (getIsS3UriPrefix(s3Uri)) { + throw new Error(`${s3Uri} is a S3 URI Prefix, not a fully qualified S3 URI.`); + } + + let s3UriPrefixObj: S3UriPrefixObj; + + try { + s3UriPrefixObj = parseS3UriPrefix({ s3UriPrefix: s3Uri, strict: false }); + } catch { + throw new Error(`Malformed S3 URI: ${s3Uri}`); + } + + const s3UriObj: S3UriObj = { + bucket: s3UriPrefixObj.bucket, + key: s3UriPrefixObj.keyPrefix.replace(/\/$/, "") + }; + + return s3UriObj; +} diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts index 1a6443ed3..e4293249f 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts @@ -4,7 +4,7 @@ import { isObjectThatThrowIfAccessed, createSelector } from "clean-architecture" import { assert } from "tsafe"; import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; import type { LocalizedString } from "core/ports/OnyxiaApi"; -import { S3PrefixUrlParsed } from "core/tools/S3PrefixUrlParsed"; +import { type S3UriPrefixObj, parseS3UriPrefix } from "core/tools/S3Uri"; const state = (rootState: RootState) => rootState[name]; @@ -24,10 +24,9 @@ export type View = { }[]; bookmarks: { displayName: LocalizedString | undefined; - bucket: string; - keyPrefix: string; + s3UriPrefixObj: S3UriPrefixObj; }[]; - s3Url_parsed: S3PrefixUrlParsed | undefined; + s3UriPrefixObj: S3UriPrefixObj | undefined; }; const view = createSelector( @@ -42,7 +41,7 @@ const view = createSelector( selectedS3ProfileId: undefined, availableS3Profiles: [], bookmarks: [], - s3Url_parsed: undefined + s3UriPrefixObj: undefined }; } @@ -61,10 +60,13 @@ const view = createSelector( displayName: s3Profile.paramsOfCreateS3Client.url })), bookmarks: s3Profile.bookmarks, - s3Url_parsed: + s3UriPrefixObj: routeParams.path === "" ? undefined - : S3PrefixUrlParsed.parse(`s3://${routeParams.path}`) + : parseS3UriPrefix({ + s3UriPrefix: `s3://${routeParams.path}`, + strict: false + }) }; } ); diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts index a65263736..4fa0b41a6 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts @@ -1,6 +1,6 @@ import { createUsecaseActions } from "clean-architecture"; import { createObjectThatThrowsIfAccessed } from "clean-architecture"; -import { S3PrefixUrlParsed } from "core/tools/S3PrefixUrlParsed"; +import { type S3UriPrefixObj, stringifyS3UriPrefixObj } from "core/tools/S3Uri"; export const name = "s3ExplorerRootUiController"; @@ -33,14 +33,14 @@ export const { actions, reducer } = createUsecaseActions({ }, s3UrlUpdated: ( state, - { payload }: { payload: { s3Url_parsed: S3PrefixUrlParsed | undefined } } + { payload }: { payload: { s3UriPrefixObj: S3UriPrefixObj | undefined } } ) => { - const { s3Url_parsed } = payload; + const { s3UriPrefixObj } = payload; state.routeParams.path = - s3Url_parsed === undefined + s3UriPrefixObj === undefined ? "" - : S3PrefixUrlParsed.stringify(s3Url_parsed).slice("s3://".length); + : stringifyS3UriPrefixObj(s3UriPrefixObj).slice("s3://".length); }, selectedS3ProfileUpdated: ( state, diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts index 3dfd958c1..7c5cc8ac5 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts @@ -3,7 +3,7 @@ import { actions, type RouteParams } from "./state"; import { protectedSelectors } from "./selectors"; import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; import { evt } from "./evt"; -import type { S3PrefixUrlParsed } from "core/tools/S3PrefixUrlParsed"; +import type { S3UriPrefixObj } from "core/tools/S3Uri"; export const thunks = { load: @@ -65,13 +65,13 @@ export const thunks = { } }, updateS3Url: - (params: { s3Url_parsed: S3PrefixUrlParsed | undefined }) => + (params: { s3UriPrefixObj: S3UriPrefixObj | undefined }) => (...args) => { const [dispatch] = args; - const { s3Url_parsed } = params; + const { s3UriPrefixObj } = params; - dispatch(actions.s3UrlUpdated({ s3Url_parsed })); + dispatch(actions.s3UrlUpdated({ s3UriPrefixObj })); }, updateSelectedS3Profile: (params: { s3ProfileId: string }) => diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts index bd2e1c4f0..eca2d34bd 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts @@ -1,24 +1,33 @@ import type { DeploymentRegion } from "core/ports/OnyxiaApi"; import { id } from "tsafe/id"; import type { LocalizedString } from "ui/i18n"; -import type { S3Profile } from "./s3Profiles"; import { z } from "zod"; import { getValueAtPath } from "core/tools/Stringifyable"; +import { type S3UriPrefixObj, parseS3UriPrefix } from "core/tools/S3Uri"; + +export type ResolvedTemplateBookmark = { + title: LocalizedString; + description: LocalizedString | undefined; + tags: LocalizedString[]; + s3UriPrefixObj: S3UriPrefixObj; +}; export async function resolveTemplatedBookmark(params: { bookmark_region: DeploymentRegion.S3Next.S3Profile.Bookmark; getDecodedIdToken: () => Promise>; -}): Promise { +}): Promise { const { bookmark_region, getDecodedIdToken } = params; if (bookmark_region.claimName === undefined) { return [ - id({ + id({ + s3UriPrefixObj: parseS3UriPrefix({ + s3UriPrefix: bookmark_region.s3UriPrefix, + strict: true + }), title: bookmark_region.title, description: bookmark_region.description, - tags: bookmark_region.tags, - bucket: bookmark_region.bucket, - keyPrefix: bookmark_region.keyPrefix + tags: bookmark_region.tags }) ]; } @@ -108,9 +117,11 @@ export async function resolveTemplatedBookmark(params: { ); }; - return id({ - bucket: substituteTemplateString(bookmark_region.bucket), - keyPrefix: substituteTemplateString(bookmark_region.keyPrefix), + return id({ + s3UriPrefixObj: parseS3UriPrefix({ + s3UriPrefix: substituteTemplateString(bookmark_region.s3UriPrefix), + strict: true + }), title: substituteLocalizedString(bookmark_region.title), description: bookmark_region.description === undefined diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts index 6fc6d1ea4..b07adfa8b 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -6,6 +6,8 @@ import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; import { assert, type Equals } from "tsafe"; import type * as s3CredentialsTest from "core/usecases/_s3Next/s3CredentialsTest"; import type { LocalizedString } from "core/ports/OnyxiaApi"; +import type { ResolvedTemplateBookmark } from "./resolveTemplatedBookmark"; +import type { S3UriPrefixObj } from "core/tools/S3Uri"; export type S3Profile = S3Profile.DefinedInRegion | S3Profile.CreatedByUser; @@ -27,16 +29,6 @@ export namespace S3Profile { bookmarks: Bookmark[]; }; - export namespace DefinedInRegion { - export type Bookmark = { - title: LocalizedString; - description: LocalizedString | undefined; - tags: LocalizedString[]; - bucket: string; - keyPrefix: string; - }; - } - export type CreatedByUser = Common & { origin: "created by user (or group project member)"; creationTime: number; @@ -47,15 +39,14 @@ export namespace S3Profile { export type Bookmark = { displayName: LocalizedString | undefined; - bucket: string; - keyPrefix: string; + s3UriPrefixObj: S3UriPrefixObj; }; } export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { fromVault: projectManagement.ProjectConfigs["s3"]; fromRegion: (Omit & { - bookmarks: S3Profile.DefinedInRegion.Bookmark[]; + bookmarks: ResolvedTemplateBookmark[]; })[]; credentialsTestState: s3CredentialsTest.State; }): S3Profile[] { @@ -151,10 +142,9 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { ) ) ), - bookmarks: c.bookmarks.map(({ title, bucket, keyPrefix }) => ({ + bookmarks: c.bookmarks.map(({ title, s3UriPrefixObj }) => ({ displayName: title, - bucket, - keyPrefix + s3UriPrefixObj })), paramsOfCreateS3Client, credentialsTestStatus: getCredentialsTestStatus({ diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts index ac2988936..39f1f16c0 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts @@ -2,12 +2,12 @@ import { createUsecaseActions, createObjectThatThrowsIfAccessed } from "clean-architecture"; -import type { S3Profile } from "./decoupledLogic/s3Profiles"; +import type { ResolvedTemplateBookmark } from "./decoupledLogic/resolveTemplatedBookmark"; type State = { resolvedTemplatedBookmarks: { correspondingS3ConfigIndexInRegion: number; - bookmarks: S3Profile.DefinedInRegion.Bookmark[]; + bookmarks: ResolvedTemplateBookmark[]; }[]; }; diff --git a/web/src/core/usecases/launcher/thunks.ts b/web/src/core/usecases/launcher/thunks.ts index bc5da3e6f..576a0238c 100644 --- a/web/src/core/usecases/launcher/thunks.ts +++ b/web/src/core/usecases/launcher/thunks.ts @@ -6,7 +6,6 @@ import * as projectManagement from "core/usecases/projectManagement"; import * as s3ConfigManagement from "core/usecases/s3ConfigManagement"; import * as userConfigsUsecase from "core/usecases/userConfigs"; import * as userProfileForm from "core/usecases/userProfileForm"; -import { bucketNameAndObjectNameFromS3Path } from "core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path"; import { parseUrl } from "core/tools/parseUrl"; import * as secretExplorer from "../secretExplorer"; import { actions } from "./state"; @@ -19,6 +18,7 @@ import { createUsecaseContextApi } from "clean-architecture"; import { computeHelmValues, type FormFieldValue } from "./decoupledLogic"; import { computeRootForm } from "./decoupledLogic"; import type { DeepPartial } from "core/tools/DeepPartial"; +import { parseS3UriPrefix } from "core/tools/S3Uri"; type RestorableServiceConfig = projectManagement.ProjectConfigs.RestorableServiceConfig; @@ -701,8 +701,11 @@ export const protectedThunks = { ? parseUrl(s3Config.paramsOfCreateS3Client.url) : {}; - const { bucketName, objectName: objectNamePrefix } = - bucketNameAndObjectNameFromS3Path(s3Config.workingDirectoryPath); + const { bucket: bucketName, keyPrefix: objectNamePrefix } = + parseS3UriPrefix({ + s3UriPrefix: `s3://${s3Config.workingDirectoryPath}`, + strict: false + }); const s3: XOnyxiaContext["s3"] = { isEnabled: true, diff --git a/web/src/core/usecases/s3ConfigCreation/selectors.ts b/web/src/core/usecases/s3ConfigCreation/selectors.ts index b9811873e..09399f8fb 100644 --- a/web/src/core/usecases/s3ConfigCreation/selectors.ts +++ b/web/src/core/usecases/s3ConfigCreation/selectors.ts @@ -3,7 +3,7 @@ import { createSelector } from "clean-architecture"; import { name } from "./state"; import { objectKeys } from "tsafe/objectKeys"; import { assert, type Equals } from "tsafe/assert"; -import { bucketNameAndObjectNameFromS3Path } from "core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path"; +import { parseS3UriPrefix } from "core/tools/S3Uri"; import { id } from "tsafe/id"; import type { ProjectConfigs } from "core/usecases/projectManagement"; import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; @@ -331,8 +331,10 @@ const urlStylesExamples = createSelector( const urlObject = new URL(formattedFormValuesUrl); - const { bucketName, objectName: objectNamePrefix } = - bucketNameAndObjectNameFromS3Path(formattedFormValuesWorkingDirectoryPath); + const { bucket: bucketName, keyPrefix: objectNamePrefix } = parseS3UriPrefix({ + s3UriPrefix: `s3://${formattedFormValuesWorkingDirectoryPath}`, + strict: false + }); const domain = formattedFormValuesUrl .split(urlObject.protocol)[1] diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts index e737e9b3b..9ca8d564e 100644 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts +++ b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts @@ -1,6 +1,6 @@ import * as projectManagement from "core/usecases/projectManagement"; import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; -import { bucketNameAndObjectNameFromS3Path } from "core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path"; +import { parseS3UriPrefix } from "core/tools/S3Uri"; import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; import { same } from "evt/tools/inDepth/same"; import { getWorkingDirectoryPath } from "./getWorkingDirectoryPath"; @@ -108,8 +108,10 @@ export function getS3Configs(params: { out = out.replace(/^https?:\/\//, "").replace(/\/$/, ""); - const { bucketName, objectName } = - bucketNameAndObjectNameFromS3Path(workingDirectoryPath); + const { bucket: bucketName, keyPrefix: objectName } = parseS3UriPrefix({ + s3UriPrefix: `s3://${workingDirectoryPath}`, + strict: false + }); out = pathStyleAccess ? `${out}/${bucketName}/${objectName}` diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 8f87ba6b7..73fa28799 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -16,7 +16,7 @@ import { useEvt } from "evt/hooks"; import { Text } from "onyxia-ui/Text"; import MuiLink from "@mui/material/Link"; import { SearchBar } from "onyxia-ui/SearchBar"; -import { S3PrefixUrlParsed } from "core/tools/S3PrefixUrlParsed"; +import { parseS3UriPrefix, stringifyS3UriPrefixObj } from "core/tools/S3Uri"; const Page = withLoader({ loader: async () => { @@ -45,7 +45,7 @@ function S3Explorer() { evts: { evtS3ExplorerRootUiController } } = getCoreSync(); - const { selectedS3ProfileId, availableS3Profiles, s3Url_parsed } = useCoreState( + const { selectedS3ProfileId, availableS3Profiles, s3UriPrefixObj } = useCoreState( "s3ExplorerRootUiController", "view" ); @@ -114,7 +114,7 @@ function S3Explorer() { return

Create a profile

; } - if (s3Url_parsed === undefined) { + if (s3UriPrefixObj === undefined) { return ( { - const s3Url_parsed = + const s3UriPrefixObj = directoryPath === "" ? undefined - : S3PrefixUrlParsed.parse(`s3://${directoryPath}`); + : parseS3UriPrefix({ + s3UriPrefix: `s3://${directoryPath}`, + strict: false + }); s3ExplorerRootUiController.updateS3Url({ - s3Url_parsed + s3UriPrefixObj }); }} - directoryPath={S3PrefixUrlParsed.stringify(s3Url_parsed).slice( + directoryPath={stringifyS3UriPrefixObj(s3UriPrefixObj).slice( "s3://".length )} isDirectoryPathBookmarked={false} @@ -162,9 +165,12 @@ function DirectNavigation(props: { className?: string }) { const [search, setSearch] = useState(PROTOCOL); - const s3Url_parsed = useMemo(() => { + const s3UriPrefixObj = useMemo(() => { try { - return S3PrefixUrlParsed.parse(search); + return parseS3UriPrefix({ + s3UriPrefix: search, + strict: false + }); } catch { return undefined; } @@ -179,12 +185,12 @@ function DirectNavigation(props: { className?: string }) { switch (keyId) { case "Enter": { - if (s3Url_parsed === undefined) { + if (s3UriPrefixObj === undefined) { return; } s3ExplorerRootUiController.updateS3Url({ - s3Url_parsed + s3UriPrefixObj }); } break; @@ -243,14 +249,11 @@ function BookmarkBar(props: { className?: string }) { onClick={e => { e.preventDefault(); s3ExplorerRootUiController.updateS3Url({ - s3Url_parsed: { - bucket: bookmark.bucket, - keyPrefix: bookmark.keyPrefix - } + s3UriPrefixObj: bookmark.s3UriPrefixObj }); }} > - {`s3://${bookmark.bucket}/${bookmark.keyPrefix}`} + {stringifyS3UriPrefixObj(bookmark.s3UriPrefixObj)} ))}
From e9be421332d2ac9b5426b16fec1f41aaca09833a Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 5 Nov 2025 06:41:04 +0100 Subject: [PATCH 16/41] Implement access denied state support --- web/src/core/adapters/s3Client/s3Client.ts | 29 ++++++--- web/src/core/ports/S3Client.ts | 14 ++-- .../core/usecases/fileExplorer/selectors.ts | 12 ++-- web/src/core/usecases/fileExplorer/state.ts | 28 ++++++-- web/src/core/usecases/fileExplorer/thunks.ts | 54 ++++++++++------ web/src/ui/pages/fileExplorer/Page.tsx | 48 ++++++++++---- web/src/ui/pages/s3Explorer/Explorer.tsx | 64 +++++++++++++++---- 7 files changed, 181 insertions(+), 68 deletions(-) diff --git a/web/src/core/adapters/s3Client/s3Client.ts b/web/src/core/adapters/s3Client/s3Client.ts index 7dd76ab04..10b6c8c91 100644 --- a/web/src/core/adapters/s3Client/s3Client.ts +++ b/web/src/core/adapters/s3Client/s3Client.ts @@ -406,14 +406,26 @@ export function createS3Client( let continuationToken: string | undefined; do { - const resp = await awsS3Client.send( - new (await import("@aws-sdk/client-s3")).ListObjectsV2Command({ - Bucket: bucketName, - Prefix: prefix, - Delimiter: "/", - ContinuationToken: continuationToken - }) - ); + const listObjectsV2Command = new ( + await import("@aws-sdk/client-s3") + ).ListObjectsV2Command({ + Bucket: bucketName, + Prefix: prefix, + Delimiter: "/", + ContinuationToken: continuationToken + }); + + let resp: import("@aws-sdk/client-s3").ListObjectsV2CommandOutput; + + try { + resp = await awsS3Client.send(listObjectsV2Command); + } catch (error) { + if (!String(error).includes("Access Denied")) { + throw error; + } + + return { isAccessDenied: true }; + } Contents.push(...(resp.Contents ?? [])); @@ -454,6 +466,7 @@ export function createS3Client( ); return { + isAccessDenied: false, objects: [...directories, ...files], bucketPolicy, isBucketPolicyAvailable diff --git a/web/src/core/ports/S3Client.ts b/web/src/core/ports/S3Client.ts index d560c19a8..64121b93a 100644 --- a/web/src/core/ports/S3Client.ts +++ b/web/src/core/ports/S3Client.ts @@ -34,11 +34,15 @@ export type S3Client = { /** * In charge of creating bucket if doesn't exist. */ - listObjects: (params: { path: string }) => Promise<{ - objects: S3Object[]; - bucketPolicy: S3BucketPolicy | undefined; - isBucketPolicyAvailable: boolean; - }>; + listObjects: (params: { path: string }) => Promise< + | { isAccessDenied: true } + | { + isAccessDenied: false; + objects: S3Object[]; + bucketPolicy: S3BucketPolicy | undefined; + isBucketPolicyAvailable: boolean; + } + >; setPathAccessPolicy: (params: { path: string; diff --git a/web/src/core/usecases/fileExplorer/selectors.ts b/web/src/core/usecases/fileExplorer/selectors.ts index b5d5e160a..d1783da3a 100644 --- a/web/src/core/usecases/fileExplorer/selectors.ts +++ b/web/src/core/usecases/fileExplorer/selectors.ts @@ -70,18 +70,20 @@ export namespace CurrentWorkingDirectoryView { const currentWorkingDirectoryView = createSelector( createSelector(state, state => state.directoryPath), + createSelector(state, state => state.accessDenied_directoryPath), createSelector(state, state => state.objects), createSelector(state, state => state.ongoingOperations), createSelector(state, state => state.s3FilesBeingUploaded), createSelector(state, state => state.isBucketPolicyAvailable), ( directoryPath, + accessDenied_directoryPath, objects, ongoingOperations, s3FilesBeingUploaded, isBucketPolicyAvailable ): CurrentWorkingDirectoryView | null => { - if (directoryPath === undefined) { + if (directoryPath === undefined || accessDenied_directoryPath !== undefined) { return null; } const items = [ @@ -303,7 +305,7 @@ const pathMinDepth = createSelector(workingDirectoryPath, workingDirectoryPath = }); const main = createSelector( - createSelector(state, state => state.directoryPath), + createSelector(state, state => state.accessDenied_directoryPath), uploadProgress, commandLogsEntries, currentWorkingDirectoryView, @@ -313,7 +315,7 @@ const main = createSelector( shareView, isDownloadPreparing, ( - directoryPath, + accessDenied_directoryPath, uploadProgress, commandLogsEntries, currentWorkingDirectoryView, @@ -323,9 +325,10 @@ const main = createSelector( shareView, isDownloadPreparing ) => { - if (directoryPath === undefined) { + if (currentWorkingDirectoryView === null) { return { isCurrentWorkingDirectoryLoaded: false as const, + accessDenied_directoryPath, isNavigationOngoing, uploadProgress, commandLogsEntries, @@ -335,7 +338,6 @@ const main = createSelector( }; } - assert(currentWorkingDirectoryView !== null); assert(shareView !== null); return { diff --git a/web/src/core/usecases/fileExplorer/state.ts b/web/src/core/usecases/fileExplorer/state.ts index 255b4e1a7..3d47e35a9 100644 --- a/web/src/core/usecases/fileExplorer/state.ts +++ b/web/src/core/usecases/fileExplorer/state.ts @@ -8,6 +8,7 @@ import type { S3FilesBeingUploaded } from "./decoupledLogic/uploadProgress"; //All explorer paths are expected to be absolute (start with /) export type State = { + accessDenied_directoryPath: string | undefined; directoryPath: string | undefined; viewMode: "list" | "block"; objects: S3Object[]; @@ -54,7 +55,8 @@ export const { reducer, actions } = createUsecaseActions({ Statement: [] }, isBucketPolicyAvailable: true, - share: undefined + share: undefined, + accessDenied_directoryPath: undefined }), reducers: { fileUploadStarted: ( @@ -120,17 +122,29 @@ export const { reducer, actions } = createUsecaseActions({ { payload }: { - payload: { - directoryPath: string; - objects: S3Object[]; - bucketPolicy: S3BucketPolicy | undefined; - isBucketPolicyAvailable: boolean; - }; + payload: + | { + isAccessDenied: true; + directoryPath: string; + } + | { + isAccessDenied: false; + directoryPath: string; + objects: S3Object[]; + bucketPolicy: S3BucketPolicy | undefined; + isBucketPolicyAvailable: boolean; + }; } ) => { + if (payload.isAccessDenied) { + state.accessDenied_directoryPath = payload.directoryPath; + return; + } + const { directoryPath, objects, bucketPolicy, isBucketPolicyAvailable } = payload; + state.accessDenied_directoryPath = undefined; state.directoryPath = directoryPath; state.objects = objects; state.isNavigationOngoing = false; diff --git a/web/src/core/usecases/fileExplorer/thunks.ts b/web/src/core/usecases/fileExplorer/thunks.ts index 2a9d7b46a..2c5b8be1f 100644 --- a/web/src/core/usecases/fileExplorer/thunks.ts +++ b/web/src/core/usecases/fileExplorer/thunks.ts @@ -160,10 +160,10 @@ const privateThunks = { return r.s3Client; }); - const { objects, bucketPolicy, isBucketPolicyAvailable } = - await s3Client.listObjects({ - path: directoryPath - }); + //const { objects, bucketPolicy, isBucketPolicyAvailable } = + const listObjectResult = await s3Client.listObjects({ + path: directoryPath + }); if (ctx.completionStatus !== undefined) { dispatch(actions.commandLogCancelled({ cmdId })); @@ -175,21 +175,32 @@ const privateThunks = { dispatch( actions.commandLogResponseReceived({ cmdId, - resp: objects - .map(({ kind, basename }) => - kind === "directory" ? `${basename}/` : basename - ) - .join("\n") + resp: listObjectResult.isAccessDenied + ? "Access Denied" + : listObjectResult.objects + .map(({ kind, basename }) => + kind === "directory" ? `${basename}/` : basename + ) + .join("\n") }) ); dispatch( - actions.navigationCompleted({ - directoryPath, - objects, - bucketPolicy, - isBucketPolicyAvailable - }) + actions.navigationCompleted( + listObjectResult.isAccessDenied + ? { + isAccessDenied: true, + directoryPath + } + : { + isAccessDenied: false, + directoryPath, + objects: listObjectResult.objects, + bucketPolicy: listObjectResult.bucketPolicy, + isBucketPolicyAvailable: + listObjectResult.isBucketPolicyAvailable + } + ) ); }, downloadObject: @@ -285,11 +296,13 @@ const privateThunks = { const { crawl } = crawlFactory({ list: async ({ directoryPath }) => { - const { objects } = await s3Client.listObjects({ + const listObjectResult = await s3Client.listObjects({ path: directoryPath }); - return objects.reduce<{ + assert(!listObjectResult.isAccessDenied); + + return listObjectResult.objects.reduce<{ fileBasenames: string[]; directoryBasenames: string[]; }>( @@ -786,9 +799,14 @@ export const thunks = { const { crawl } = crawlFactory({ list: async ({ directoryPath }) => { - const { objects } = await s3Client.listObjects({ + const listObjectsResult = await s3Client.listObjects({ path: directoryPath }); + + assert(!listObjectsResult.isAccessDenied); + + const { objects } = listObjectsResult; + return { fileBasenames: objects .filter(obj => obj.kind === "file") diff --git a/web/src/ui/pages/fileExplorer/Page.tsx b/web/src/ui/pages/fileExplorer/Page.tsx index e518e4d53..89aa0560f 100644 --- a/web/src/ui/pages/fileExplorer/Page.tsx +++ b/web/src/ui/pages/fileExplorer/Page.tsx @@ -7,7 +7,6 @@ import { useCoreState, getCoreSync } from "core"; import { Explorer, type ExplorerProps } from "./Explorer"; import { routes, useRoute } from "ui/routes"; import { routeGroup } from "./route"; -import { useSplashScreen } from "onyxia-ui"; import { Evt } from "evt"; import type { Param0 } from "tsafe"; import { useConst } from "powerhooks/useConst"; @@ -18,6 +17,9 @@ import { triggerBrowserDownload } from "ui/tools/triggerBrowserDonwload"; import { useTranslation } from "ui/i18n"; import { withLoader } from "ui/tools/withLoader"; import { enforceLogin } from "ui/shared/enforceLogin"; +import CircularProgress from "@mui/material/CircularProgress"; +import { Text } from "onyxia-ui/Text"; +import { Button } from "onyxia-ui/Button"; const Page = withLoader({ loader: enforceLogin, @@ -33,6 +35,7 @@ function FileExplorer() { const { isCurrentWorkingDirectoryLoaded, + accessDenied_directoryPath, commandLogsEntries, isNavigationOngoing, uploadProgress, @@ -97,17 +100,7 @@ function FileExplorer() { } ); - const { classes } = useStyles(); - - const { showSplashScreen, hideSplashScreen } = useSplashScreen(); - - useEffect(() => { - if (currentWorkingDirectoryView === undefined) { - showSplashScreen({ enableTransparency: true }); - } else { - hideSplashScreen(); - } - }, [currentWorkingDirectoryView === undefined]); + const { classes, cx, css } = useStyles(); useEffect(() => { if (currentWorkingDirectoryView === undefined) { @@ -163,7 +156,36 @@ function FileExplorer() { ); if (!isCurrentWorkingDirectoryLoaded) { - return null; + return ( +
+ {(() => { + if (accessDenied_directoryPath !== undefined) { + return ( + <> + + You do not have read permission on s3:// + {accessDenied_directoryPath} + with this S3 Profile. + + + + ); + } + + return ; + })()} +
+ ); } return ( diff --git a/web/src/ui/pages/s3Explorer/Explorer.tsx b/web/src/ui/pages/s3Explorer/Explorer.tsx index 4126040ff..4a0f52a52 100644 --- a/web/src/ui/pages/s3Explorer/Explorer.tsx +++ b/web/src/ui/pages/s3Explorer/Explorer.tsx @@ -7,12 +7,16 @@ import { type ExplorerProps as HeadlessExplorerProps } from "../fileExplorer/Explorer"; import { routes } from "ui/routes"; -import { useSplashScreen } from "onyxia-ui"; import { Evt } from "evt"; import type { Param0 } from "tsafe"; import { useConst } from "powerhooks/useConst"; import { assert } from "tsafe/assert"; import { triggerBrowserDownload } from "ui/tools/triggerBrowserDonwload"; +import CircularProgress from "@mui/material/CircularProgress"; +import { Text } from "onyxia-ui/Text"; +import { Button } from "onyxia-ui/Button"; +import { useStyles } from "tss"; +import { getIconUrlByName } from "lazy-icons"; type Props = { className?: string; @@ -33,6 +37,7 @@ export function Explorer(props: Props) { const { isCurrentWorkingDirectoryLoaded, + accessDenied_directoryPath, commandLogsEntries, isNavigationOngoing, uploadProgress, @@ -96,16 +101,6 @@ export function Explorer(props: Props) { } ); - const { showSplashScreen, hideSplashScreen } = useSplashScreen(); - - useEffect(() => { - if (currentWorkingDirectoryView === undefined) { - showSplashScreen({ enableTransparency: true }); - } else { - hideSplashScreen(); - } - }, [currentWorkingDirectoryView === undefined]); - const evtExplorerAction = useConst(() => Evt.create() ); @@ -138,8 +133,53 @@ export function Explorer(props: Props) { }) ); + const { cx, css, theme } = useStyles(); + if (!isCurrentWorkingDirectoryLoaded) { - return null; + return ( +
+ {(() => { + if (accessDenied_directoryPath !== undefined) { + return ( +
+ + You do not have read permission on s3:// + {accessDenied_directoryPath} + with this S3 Profile. + + +
+ ); + } + + return ; + })()} +
+ ); } return ( From e453f9e16249f2efea668ccd0776ffd8c9e13edb Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 5 Nov 2025 07:49:14 +0100 Subject: [PATCH 17/41] Direct navigation tracks state --- web/src/ui/pages/s3Explorer/Page.tsx | 41 +++++++++++++++++++--------- 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 73fa28799..e1a2d8be0 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -108,6 +108,16 @@ function S3Explorer() { })} />
+ {/* Not conditionally mounted to track state */} + {(() => { if (selectedS3ProfileId === undefined) { @@ -115,13 +125,7 @@ function S3Explorer() { } if (s3UriPrefixObj === undefined) { - return ( - - ); + return null; } return ( @@ -161,11 +165,20 @@ function DirectNavigation(props: { className?: string }) { functions: { s3ExplorerRootUiController } } = getCoreSync(); - const PROTOCOL = "s3://"; + const { s3UriPrefixObj } = useCoreState("s3ExplorerRootUiController", "view"); + + const search_external = + s3UriPrefixObj === undefined ? "s3://" : stringifyS3UriPrefixObj(s3UriPrefixObj); - const [search, setSearch] = useState(PROTOCOL); + const [search, setSearch] = useState(search_external); - const s3UriPrefixObj = useMemo(() => { + useEffect(() => { + if (search_external !== "s3://") { + setSearch(search_external); + } + }, [search_external]); + + const s3UriPrefixObj_search = useMemo(() => { try { return parseS3UriPrefix({ s3UriPrefix: search, @@ -178,6 +191,8 @@ function DirectNavigation(props: { className?: string }) { return ( Date: Wed, 5 Nov 2025 07:56:24 +0100 Subject: [PATCH 18/41] Fix routing bug --- web/src/ui/App/LeftBar.tsx | 1 + web/src/ui/pages/s3Explorer/route.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/web/src/ui/App/LeftBar.tsx b/web/src/ui/App/LeftBar.tsx index 899e4c112..84860bb8a 100644 --- a/web/src/ui/App/LeftBar.tsx +++ b/web/src/ui/App/LeftBar.tsx @@ -181,6 +181,7 @@ export const LeftBar = memo((props: Props) => { case "dataCollection": return "dataCollection"; case "s3Explorer": + case "s3Explorer_root": return "s3Explorer"; case "page404": return null; diff --git a/web/src/ui/pages/s3Explorer/route.ts b/web/src/ui/pages/s3Explorer/route.ts index a3b60a6b7..920d31076 100644 --- a/web/src/ui/pages/s3Explorer/route.ts +++ b/web/src/ui/pages/s3Explorer/route.ts @@ -10,6 +10,13 @@ export const routeDefs = { profile: param.query.optional.string }, ({ path }) => `/s3/${path}` + ), + s3Explorer_root: defineRoute( + { + path: param.query.optional.string.default(""), + profile: param.query.optional.string + }, + () => "/s3" ) }; From fed5b1f82f7c89a83ae9bccb5cb92ae0dcc7ea1b Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 5 Nov 2025 08:08:42 +0100 Subject: [PATCH 19/41] Update tsafe --- web/package.json | 2 +- web/yarn.lock | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index 3fb0a3feb..afffa6a5a 100644 --- a/web/package.json +++ b/web/package.json @@ -78,7 +78,7 @@ "react-dom": "^18.3.1", "run-exclusive": "^2.2.19", "screen-scaler": "^2.0.0", - "tsafe": "^1.8.5", + "tsafe": "^1.8.12", "tss-react": "^4.9.18", "type-route": "1.1.0", "xterm": "^5.3.0", diff --git a/web/yarn.lock b/web/yarn.lock index e21bf7c78..73eeaaac8 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -8097,6 +8097,11 @@ tsafe@^1.8.10: resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.8.10.tgz#98e28f0beeca4e6632a8481a300fa85dd92826c0" integrity sha512-2bBiNHk6Ts4LZQ4+6OxF/BtkJ8YWqo1VMbMo6qrRIZoqAwM8xuwWUx9g3C/p6cCdUmNWeOWIaiJzgO5zWy1Cdg== +tsafe@^1.8.12: + version "1.8.12" + resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.8.12.tgz#68a410b4b7687ef497b1c85904b1215532335c3e" + integrity sha512-nFRqW0ttu/2o6XTXsHiVZWJBCOaxhVqZLg7dgs3coZNsCMPXPfwz+zPHAQA+70fNnVJLAPg1EgGIqK9Q84tvAw== + tsafe@^1.8.5: version "1.8.5" resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.8.5.tgz#cdf9fa3111974ac480d7ee519f8241815e5d22ea" From 02d9599d7c2648c26d444f1b00200807731b2895 Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 5 Nov 2025 09:10:50 +0100 Subject: [PATCH 20/41] Generate ids for config in a consistent way --- .../_s3Next/s3ExplorerRootUiController/selectors.ts | 6 +++++- .../decoupledLogic/s3Profiles.ts | 12 +++--------- .../decoupledLogic/getS3Configs.ts | 8 +------- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts index e4293249f..17415245e 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts @@ -51,7 +51,11 @@ const view = createSelector( s3Profile => s3Profile.id === selectedS3ProfileId ); - assert(s3Profile !== undefined); + // TODO: Handle this case gratefully + assert( + s3Profile !== undefined, + "The profile in the root url does not exist in configuration" + ); return { selectedS3ProfileId, diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts index b07adfa8b..e808ebf55 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -133,15 +133,9 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { return { origin: "defined in region", - id: fnv1aHashToHex( - JSON.stringify( - Object.fromEntries( - Object.entries(c).sort(([key1], [key2]) => - key1.localeCompare(key2) - ) - ) - ) - ), + id: `region-${fnv1aHashToHex( + JSON.stringify([c.url, c.sts.oidcParams.clientId ?? ""]) + )}`, bookmarks: c.bookmarks.map(({ title, s3UriPrefixObj }) => ({ displayName: title, s3UriPrefixObj diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts index 9ca8d564e..ee28c0dac 100644 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts +++ b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts @@ -200,13 +200,7 @@ export function getS3Configs(params: { .sort((a, b) => b.creationTime - a.creationTime), ...s3RegionConfigs.map((c, i): S3Config.FromDeploymentRegion => { const id = `region-${fnv1aHashToHex( - JSON.stringify( - Object.fromEntries( - Object.entries(c).sort(([key1], [key2]) => - key1.localeCompare(key2) - ) - ) - ) + JSON.stringify([c.url, c.sts.oidcParams.clientId ?? ""]) )}`; const workingDirectoryContext = From 7037ad2a5beab379b2482b3d6728db331943528e Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 5 Nov 2025 09:14:00 +0100 Subject: [PATCH 21/41] Enable aspirational bookmarks for localhost --- web/src/core/adapters/onyxiaApi/onyxiaApi.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts index 94f9d18c1..0a8f19577 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -21,7 +21,6 @@ import { exclude } from "tsafe/exclude"; import type { ApiTypes } from "./ApiTypes"; import { Evt } from "evt"; import { id } from "tsafe/id"; -import { decodeJwt } from "oidc-spa/tools/decodeJwt"; import { parseS3UriPrefix } from "core/tools/S3Uri"; import type { LocalizedString } from "core/ports/OnyxiaApi"; @@ -191,19 +190,7 @@ export function createOnyxiaApi(params: { }); const bookmarkedDirectories_test = await (async () => { - const isJoseph = await (async () => { - const accessToken = await getOidcAccessToken(); - - if (accessToken === undefined) { - return false; - } - - const { preferred_username } = decodeJwt(accessToken) as any; - - return preferred_username === "garronej"; - })(); - - if (!isJoseph) { + if (!window.location.href.includes("localhost")) { return []; } From db2ee688a451726332dfdf4d2181f2ceb1214442 Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 5 Nov 2025 09:25:43 +0100 Subject: [PATCH 22/41] Propagate bookmark state --- .../s3ExplorerRootUiController/selectors.ts | 27 +++++++++++++------ web/src/ui/pages/s3Explorer/Page.tsx | 12 +++++---- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts index 17415245e..d6db3de6f 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts @@ -5,6 +5,7 @@ import { assert } from "tsafe"; import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; import type { LocalizedString } from "core/ports/OnyxiaApi"; import { type S3UriPrefixObj, parseS3UriPrefix } from "core/tools/S3Uri"; +import { same } from "evt/tools/inDepth/same"; const state = (rootState: RootState) => rootState[name]; @@ -27,6 +28,7 @@ export type View = { s3UriPrefixObj: S3UriPrefixObj; }[]; s3UriPrefixObj: S3UriPrefixObj | undefined; + isS3UriPrefixBookmarked: boolean; }; const view = createSelector( @@ -41,7 +43,8 @@ const view = createSelector( selectedS3ProfileId: undefined, availableS3Profiles: [], bookmarks: [], - s3UriPrefixObj: undefined + s3UriPrefixObj: undefined, + isS3UriPrefixBookmarked: false }; } @@ -57,6 +60,14 @@ const view = createSelector( "The profile in the root url does not exist in configuration" ); + const s3UriPrefixObj = + routeParams.path === "" + ? undefined + : parseS3UriPrefix({ + s3UriPrefix: `s3://${routeParams.path}`, + strict: false + }); + return { selectedS3ProfileId, availableS3Profiles: s3Profiles.map(s3Profile => ({ @@ -64,13 +75,13 @@ const view = createSelector( displayName: s3Profile.paramsOfCreateS3Client.url })), bookmarks: s3Profile.bookmarks, - s3UriPrefixObj: - routeParams.path === "" - ? undefined - : parseS3UriPrefix({ - s3UriPrefix: `s3://${routeParams.path}`, - strict: false - }) + s3UriPrefixObj, + isS3UriPrefixBookmarked: + s3UriPrefixObj === undefined + ? false + : s3Profile.bookmarks.some(bookmark => + same(bookmark.s3UriPrefixObj, s3UriPrefixObj) + ) }; } ); diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index e1a2d8be0..2f468b7e4 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -45,10 +45,12 @@ function S3Explorer() { evts: { evtS3ExplorerRootUiController } } = getCoreSync(); - const { selectedS3ProfileId, availableS3Profiles, s3UriPrefixObj } = useCoreState( - "s3ExplorerRootUiController", - "view" - ); + const { + selectedS3ProfileId, + availableS3Profiles, + s3UriPrefixObj, + isS3UriPrefixBookmarked + } = useCoreState("s3ExplorerRootUiController", "view"); const { classes, css, theme } = useStyles(); @@ -147,7 +149,7 @@ function S3Explorer() { directoryPath={stringifyS3UriPrefixObj(s3UriPrefixObj).slice( "s3://".length )} - isDirectoryPathBookmarked={false} + isDirectoryPathBookmarked={isS3UriPrefixBookmarked} onToggleIsDirectoryPathBookmarked={() => { alert("TODO: Implement this feature"); }} From 607f785ebbcba467b0c9dcb5037b0f14e51b25cf Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 5 Nov 2025 09:51:03 +0100 Subject: [PATCH 23/41] Pannel for the bookmarks --- web/src/ui/pages/s3Explorer/Page.tsx | 68 +++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 2f468b7e4..5f7088b56 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -17,6 +17,9 @@ import { Text } from "onyxia-ui/Text"; import MuiLink from "@mui/material/Link"; import { SearchBar } from "onyxia-ui/SearchBar"; import { parseS3UriPrefix, stringifyS3UriPrefixObj } from "core/tools/S3Uri"; +import { useResolveLocalizedString } from "ui/i18n"; +import { Icon } from "onyxia-ui/Icon"; +import { getIconUrlByName } from "lazy-icons"; const Page = withLoader({ loader: async () => { @@ -127,7 +130,7 @@ function S3Explorer() { } if (s3UriPrefixObj === undefined) { - return null; + return ; } return ( @@ -160,6 +163,69 @@ function S3Explorer() { ); } +function BookmarkPanel(props: { className?: string }) { + const { className } = props; + + const { resolveLocalizedString } = useResolveLocalizedString(); + + const { bookmarks } = useCoreState("s3ExplorerRootUiController", "view"); + + const { + functions: { s3ExplorerRootUiController } + } = getCoreSync(); + + const { cx, css, theme } = useStyles(); + + return ( +
+ + + Bookmarks + + {bookmarks.map((bookmark, i) => ( +
+ { + e.preventDefault(); + s3ExplorerRootUiController.updateS3Url({ + s3UriPrefixObj: bookmark.s3UriPrefixObj + }); + }} + > + {stringifyS3UriPrefixObj(bookmark.s3UriPrefixObj)} + + + {bookmark.displayName !== undefined && ( + + - {resolveLocalizedString(bookmark.displayName)} + + )} +
+ ))} +
+ ); +} + function DirectNavigation(props: { className?: string }) { const { className } = props; From a9362885ff2f590b589dd7e61724375c6ef46347 Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 5 Nov 2025 11:08:47 +0100 Subject: [PATCH 24/41] Adjust spacing --- web/src/ui/pages/s3Explorer/Page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 5f7088b56..17b67af8d 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -116,7 +116,7 @@ function S3Explorer() { {/* Not conditionally mounted to track state */} Date: Wed, 5 Nov 2025 11:39:38 +0100 Subject: [PATCH 25/41] Externalize s3Profile select --- web/src/ui/pages/s3Explorer/Page.tsx | 69 +++++++++++++++++----------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 17b67af8d..cfbd9cf23 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -48,12 +48,10 @@ function S3Explorer() { evts: { evtS3ExplorerRootUiController } } = getCoreSync(); - const { - selectedS3ProfileId, - availableS3Profiles, - s3UriPrefixObj, - isS3UriPrefixBookmarked - } = useCoreState("s3ExplorerRootUiController", "view"); + const { selectedS3ProfileId, s3UriPrefixObj, isS3UriPrefixBookmarked } = useCoreState( + "s3ExplorerRootUiController", + "view" + ); const { classes, css, theme } = useStyles(); @@ -86,27 +84,7 @@ function S3Explorer() { gap: theme.spacing(3) }} > - - S3 Profile - - + + S3 Profile + + + ); +} + const useStyles = tss.withName({ S3Explorer }).create(({ theme }) => ({ root: {}, explorer: { From 1d1604abb1b580c7d05ddace792095ff89c1c772 Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 5 Nov 2025 12:18:27 +0100 Subject: [PATCH 26/41] Fix but not relisting after no access --- web/src/core/usecases/fileExplorer/thunks.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/core/usecases/fileExplorer/thunks.ts b/web/src/core/usecases/fileExplorer/thunks.ts index 2c5b8be1f..97466df1f 100644 --- a/web/src/core/usecases/fileExplorer/thunks.ts +++ b/web/src/core/usecases/fileExplorer/thunks.ts @@ -115,7 +115,8 @@ const privateThunks = { if ( !doListAgainIfSamePath && - getState()[name].directoryPath === directoryPath + getState()[name].directoryPath === directoryPath && + getState()[name].accessDenied_directoryPath === undefined ) { return; } From 5e2a2d18af0103de6ec429e179db786594381391 Mon Sep 17 00:00:00 2001 From: garronej Date: Fri, 21 Nov 2025 19:42:54 +0100 Subject: [PATCH 27/41] Avoid layout shifts --- web/src/ui/pages/s3Explorer/Explorer.tsx | 24 +++++++++++++++++++++++- web/src/ui/pages/s3Explorer/Page.tsx | 4 +++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/web/src/ui/pages/s3Explorer/Explorer.tsx b/web/src/ui/pages/s3Explorer/Explorer.tsx index 4a0f52a52..d0704c79d 100644 --- a/web/src/ui/pages/s3Explorer/Explorer.tsx +++ b/web/src/ui/pages/s3Explorer/Explorer.tsx @@ -135,6 +135,27 @@ export function Explorer(props: Props) { const { cx, css, theme } = useStyles(); + if ( + isCurrentWorkingDirectoryLoaded && + currentWorkingDirectoryView.directoryPath !== directoryPath + ) { + return ( +
+ +
+ ); + } + if (!isCurrentWorkingDirectoryLoaded) { return (
({ - root: {}, + root: { + height: "100%" + }, explorer: { marginTop: theme.spacing(4) } From 8f6a720f400555f1f95673754f1e3b3c721fa81e Mon Sep 17 00:00:00 2001 From: garronej Date: Fri, 28 Nov 2025 19:17:25 +0100 Subject: [PATCH 28/41] Templates STS Roles (Ceph Story) #1048 --- web/src/core/adapters/onyxiaApi/ApiTypes.ts | 20 +- web/src/core/adapters/onyxiaApi/onyxiaApi.ts | 272 +++++++++++++----- .../core/ports/OnyxiaApi/DeploymentRegion.ts | 24 +- .../s3ExplorerRootUiController/thunks.ts | 11 +- .../resolveTemplatedBookmark.ts | 9 +- .../decoupledLogic/resolveTemplatedStsRole.ts | 104 +++++++ .../decoupledLogic/s3Profiles.ts | 153 +++++++--- ...DefaultS3ProfilesAfterPotentialDeletion.ts | 11 +- .../_s3Next/s3ProfilesManagement/selectors.ts | 25 +- .../_s3Next/s3ProfilesManagement/state.ts | 11 +- .../_s3Next/s3ProfilesManagement/thunks.ts | 113 +++++--- 11 files changed, 568 insertions(+), 185 deletions(-) create mode 100644 web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedStsRole.ts diff --git a/web/src/core/adapters/onyxiaApi/ApiTypes.ts b/web/src/core/adapters/onyxiaApi/ApiTypes.ts index 02432009a..6375eb3c5 100644 --- a/web/src/core/adapters/onyxiaApi/ApiTypes.ts +++ b/web/src/core/adapters/onyxiaApi/ApiTypes.ts @@ -90,12 +90,19 @@ export type ApiTypes = { sts?: { URL?: string; durationSeconds?: number; - role: - | { - roleARN: string; - roleSessionName: string; - } - | undefined; + role?: ArrayOrNot< + { + roleARN: string; + roleSessionName: string; + } & ( + | { claimName?: undefined } + | { + claimName: string; + includedClaimPattern?: string; + excludedClaimPattern?: string; + } + ) + >; oidcConfiguration?: Partial; }; @@ -118,6 +125,7 @@ export type ApiTypes = { title: LocalizedString; description?: LocalizedString; tags?: LocalizedString[]; + forStsRoleSessionName?: string | string[]; } & ( | { claimName?: undefined } | { diff --git a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts index 0a8f19577..585d60c44 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -200,6 +200,7 @@ export function createOnyxiaApi(params: { title: LocalizedString; description?: LocalizedString; tags?: LocalizedString[]; + forStsRoleSessionName?: string | string[]; } & ( | { claimName?: undefined } | { @@ -213,14 +214,16 @@ export function createOnyxiaApi(params: { fullPath: "$1/", title: "Personal", description: "Personal Bucket", - claimName: "preferred_username" + claimName: "preferred_username", + forStsRoleSessionName: undefined }, { fullPath: "projet-$1/", title: "Group $1", description: "Shared bucket among members of project $1", claimName: "groups", - excludedClaimPattern: "^USER_ONYXIA$" + excludedClaimPattern: "^USER_ONYXIA$", + forStsRoleSessionName: undefined } ]); })(); @@ -337,7 +340,26 @@ export function createOnyxiaApi(params: { url: s3Config_api.sts.URL, durationSeconds: s3Config_api.sts.durationSeconds, - role: s3Config_api.sts.role, + role: (() => { + if ( + s3Config_api.sts.role === + undefined + ) { + return undefined; + } + + const entry = + s3Config_api.sts + .role instanceof Array + ? s3Config_api.sts.role[0] + : s3Config_api.sts.role; + + return { + roleARN: entry.roleARN, + roleSessionName: + entry.roleSessionName + }; + })(), oidcParams: apiTypesOidcConfigurationToOidcParams_Partial( s3Config_api.sts @@ -384,73 +406,191 @@ export function createOnyxiaApi(params: { }) ); + const s3Profiles: DeploymentRegion.S3Next.S3Profile[] = + s3Configs_api + .filter( + s3Configs_api => + s3Configs_api.sts !== undefined + ) + .map( + ( + s3Config_api + ): DeploymentRegion.S3Next.S3Profile => { + return { + url: s3Config_api.URL, + pathStyleAccess: + s3Config_api.pathStyleAccess ?? + true, + region: s3Config_api.region, + sts: (() => { + const sts_api = s3Config_api.sts; + + assert(sts_api !== undefined); + + return { + url: sts_api.URL, + durationSeconds: + sts_api.durationSeconds, + roles: (() => { + if ( + sts_api.role === + undefined + ) { + return []; + } + + const rolesArray = + sts_api.role instanceof + Array + ? sts_api.role + : [sts_api.role]; + + return rolesArray.map( + ( + role_api + ): DeploymentRegion.S3Next.S3Profile.StsRole => ({ + roleARN: + role_api.roleARN, + roleSessionName: + role_api.roleSessionName, + ...(role_api.claimName === + undefined + ? { + claimName: + undefined + } + : { + claimName: + role_api.claimName, + includedClaimPattern: + role_api.includedClaimPattern, + excludedClaimPattern: + role_api.excludedClaimPattern + }) + }) + ); + })(), + oidcParams: + apiTypesOidcConfigurationToOidcParams_Partial( + sts_api.oidcConfiguration + ) + } as any; + })(), + bookmarks: [ + ...bookmarkedDirectories_test, + ...(s3Config_api.bookmarkedDirectories ?? + []) + ].map( + ( + bookmarkedDirectory_api + ): DeploymentRegion.S3Next.S3Profile.Bookmark => { + return id( + { + s3UriPrefix: (() => { + const s3UriPrefix = `s3://${bookmarkedDirectory_api.fullPath}`; + + // NOTE: Just for checking shape. + parseS3UriPrefix({ + s3UriPrefix, + strict: true + }); + + return s3UriPrefix; + })(), + title: bookmarkedDirectory_api.title, + description: + bookmarkedDirectory_api.description, + tags: + bookmarkedDirectory_api.tags ?? + [], + forStsRoleSessionNames: + (() => { + const v = + bookmarkedDirectory_api.forStsRoleSessionName; + + if ( + v === + undefined + ) { + return []; + } + + if ( + typeof v === + "string" + ) { + return [ + v + ]; + } + + return v; + })(), + ...(bookmarkedDirectory_api.claimName === + undefined + ? { + claimName: + undefined + } + : { + claimName: + bookmarkedDirectory_api.claimName, + includedClaimPattern: + bookmarkedDirectory_api.includedClaimPattern, + excludedClaimPattern: + bookmarkedDirectory_api.excludedClaimPattern + }) + } + ); + } + ) + }; + } + ); + + const s3Profiles_defaultValuesOfCreationForm: DeploymentRegion["_s3Next"]["s3Profiles_defaultValuesOfCreationForm"] = + (() => { + const s3Config_api = (() => { + config_without_sts: { + const s3Config_api = s3Configs_api.find( + s3Config_api => + s3Config_api.sts === undefined + ); + + if (s3Config_api === undefined) { + break config_without_sts; + } + + return s3Config_api; + } + + if (s3Configs_api.length === 0) { + return undefined; + } + + const [s3Config_api] = s3Configs_api; + + return s3Config_api; + })(); + + if (s3Config_api === undefined) { + return undefined; + } + + return { + url: s3Config_api.URL, + pathStyleAccess: + s3Config_api.pathStyleAccess ?? true, + region: s3Config_api.region + }; + })(); + return { s3Configs, s3ConfigCreationFormDefaults, _s3Next: id({ - s3Profiles: id< - DeploymentRegion.S3Next.S3Profile[] - >( - s3Configs.map( - ({ - url, - pathStyleAccess, - region, - sts, - bookmarkedDirectories - }) => ({ - url, - pathStyleAccess, - region, - sts, - bookmarks: [ - ...bookmarkedDirectories_test, - ...bookmarkedDirectories - ].map(bookmarkedDirectory_api => { - const { - fullPath, - title, - description, - tags, - ...rest - } = bookmarkedDirectory_api; - - const s3UriPrefix = `s3://${fullPath}`; - - // NOTE: Just for checking shape. - parseS3UriPrefix({ - s3UriPrefix, - strict: true - }); - - return id( - { - s3UriPrefix, - title, - description, - tags: tags ?? [], - ...(rest.claimName === - undefined - ? { - claimName: - undefined - } - : { - claimName: - rest.claimName, - includedClaimPattern: - rest.includedClaimPattern, - excludedClaimPattern: - rest.excludedClaimPattern - }) - } - ); - }) - }) - ) - ), - s3Profiles_defaultValuesOfCreationForm: - s3ConfigCreationFormDefaults + s3Profiles, + s3Profiles_defaultValuesOfCreationForm }) }; })(), diff --git a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts index f1f48812c..5463f65c4 100644 --- a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts +++ b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts @@ -180,23 +180,35 @@ export namespace DeploymentRegion { sts: { url: string | undefined; durationSeconds: number | undefined; - role: - | { - roleARN: string; - roleSessionName: string; - } - | undefined; + roles: S3Profile.StsRole[]; oidcParams: OidcParams_Partial; }; bookmarks: S3Profile.Bookmark[]; }; export namespace S3Profile { + export type StsRole = { + roleARN: string; + roleSessionName: string; + } & ( + | { + claimName: undefined; + includedClaimPattern?: never; + excludedClaimPattern?: never; + } + | { + claimName: string; + includedClaimPattern: string | undefined; + excludedClaimPattern: string | undefined; + } + ); + export type Bookmark = { s3UriPrefix: string; title: LocalizedString; description: LocalizedString | undefined; tags: LocalizedString[]; + forStsRoleSessionNames: string[]; } & ( | { claimName: undefined; diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts index 7c5cc8ac5..61c6e4899 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts @@ -31,17 +31,8 @@ export const thunks = { s3Profiles.find(s3Profile => s3Profile.origin === "defined in region") ?? s3Profiles[0]; - if (s3Profile === undefined) { - return { - routeParams_toSet: { - profile: undefined, - path: "" - } - }; - } - const routeParams_toSet: RouteParams = { - profile: s3Profile.id, + profile: s3Profile === undefined ? undefined : s3Profile.id, path: "" }; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts index eca2d34bd..85fa029dc 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts @@ -10,6 +10,7 @@ export type ResolvedTemplateBookmark = { description: LocalizedString | undefined; tags: LocalizedString[]; s3UriPrefixObj: S3UriPrefixObj; + forStsRoleSessionNames: string[]; }; export async function resolveTemplatedBookmark(params: { @@ -27,7 +28,8 @@ export async function resolveTemplatedBookmark(params: { }), title: bookmark_region.title, description: bookmark_region.description, - tags: bookmark_region.tags + tags: bookmark_region.tags, + forStsRoleSessionNames: bookmark_region.forStsRoleSessionNames }) ]; } @@ -127,7 +129,10 @@ export async function resolveTemplatedBookmark(params: { bookmark_region.description === undefined ? undefined : substituteLocalizedString(bookmark_region.description), - tags: bookmark_region.tags.map(tag => substituteLocalizedString(tag)) + tags: bookmark_region.tags.map(tag => substituteLocalizedString(tag)), + forStsRoleSessionNames: bookmark_region.forStsRoleSessionNames.map( + stsRoleSessionName => substituteTemplateString(stsRoleSessionName) + ) }); }) .filter(x => x !== undefined); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedStsRole.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedStsRole.ts new file mode 100644 index 000000000..0e8186a07 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedStsRole.ts @@ -0,0 +1,104 @@ +import type { DeploymentRegion } from "core/ports/OnyxiaApi"; +import { id } from "tsafe/id"; +import { z } from "zod"; +import { getValueAtPath } from "core/tools/Stringifyable"; + +export type ResolvedTemplateStsRole = { + roleARN: string; + roleSessionName: string; +}; + +export async function resolveTemplatedStsRole(params: { + stsRole_region: DeploymentRegion.S3Next.S3Profile.StsRole; + getDecodedIdToken: () => Promise>; +}): Promise { + const { stsRole_region, getDecodedIdToken } = params; + + if (stsRole_region.claimName === undefined) { + return [ + id({ + roleARN: stsRole_region.roleARN, + roleSessionName: stsRole_region.roleSessionName + }) + ]; + } + + const { claimName, excludedClaimPattern, includedClaimPattern } = stsRole_region; + + const decodedIdToken = await getDecodedIdToken(); + + const claimValue_arr: string[] = (() => { + let claimValue_untrusted: unknown = (() => { + const candidate = decodedIdToken[claimName]; + + if (candidate !== undefined) { + return candidate; + } + + const claimPath = claimName.split("."); + + if (claimPath.length === 1) { + return undefined; + } + + return getValueAtPath({ + // @ts-expect-error: We know decodedIdToken is Stringifyable + stringifyableObjectOrArray: decodedIdToken, + doDeleteFromSource: false, + doFailOnUnresolved: false, + path: claimPath + }); + })(); + + if (!claimValue_untrusted) { + return []; + } + + let claimValue: string | string[]; + + try { + claimValue = z + .union([z.string(), z.array(z.string())]) + .parse(claimValue_untrusted); + } catch (error) { + throw new Error( + [ + `decodedIdToken -> ${claimName} is supposed to be`, + `string or array of string`, + `The decoded id token is:`, + JSON.stringify(decodedIdToken, null, 2) + ].join(" "), + { cause: error } + ); + } + + return claimValue instanceof Array ? claimValue : [claimValue]; + })(); + + const includedRegex = + includedClaimPattern !== undefined ? new RegExp(includedClaimPattern) : /^(.+)$/; + const excludedRegex = + excludedClaimPattern !== undefined ? new RegExp(excludedClaimPattern) : undefined; + + return claimValue_arr + .map(value => { + if (excludedRegex !== undefined && excludedRegex.test(value)) { + return undefined; + } + + const match = includedRegex.exec(value); + + if (match === null) { + return undefined; + } + + const substituteTemplateString = (str: string) => + str.replace(/\$(\d+)/g, (_, i) => match[parseInt(i)] ?? ""); + + return id({ + roleARN: substituteTemplateString(stsRole_region.roleARN), + roleSessionName: substituteTemplateString(stsRole_region.roleSessionName) + }); + }) + .filter(x => x !== undefined); +} diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts index e808ebf55..9cf96230b 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -7,6 +7,7 @@ import { assert, type Equals } from "tsafe"; import type * as s3CredentialsTest from "core/usecases/_s3Next/s3CredentialsTest"; import type { LocalizedString } from "core/ports/OnyxiaApi"; import type { ResolvedTemplateBookmark } from "./resolveTemplatedBookmark"; +import type { ResolvedTemplateStsRole } from "./resolveTemplatedStsRole"; import type { S3UriPrefixObj } from "core/tools/S3Uri"; export type S3Profile = S3Profile.DefinedInRegion | S3Profile.CreatedByUser; @@ -45,9 +46,17 @@ export namespace S3Profile { export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { fromVault: projectManagement.ProjectConfigs["s3"]; - fromRegion: (Omit & { - bookmarks: ResolvedTemplateBookmark[]; - })[]; + fromRegion: { + s3Profiles: DeploymentRegion.S3Next.S3Profile[]; + resolvedTemplatedBookmarks: { + correspondingS3ConfigIndexInRegion: number; + bookmarks: ResolvedTemplateBookmark[]; + }[]; + resolvedTemplatedStsRoles: { + correspondingS3ConfigIndexInRegion: number; + stsRoles: ResolvedTemplateStsRole[]; + }[]; + }; credentialsTestState: s3CredentialsTest.State; }): S3Profile[] { const { fromVault, fromRegion, credentialsTestState } = params; @@ -114,40 +123,110 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { }; }) .sort((a, b) => b.creationTime - a.creationTime), - ...fromRegion.map((c): S3Profile.DefinedInRegion => { - const url = c.url; - const pathStyleAccess = c.pathStyleAccess; - const region = c.region; - - const paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts = { - url, - pathStyleAccess, - isStsEnabled: true, - stsUrl: c.sts.url, - region, - oidcParams: c.sts.oidcParams, - durationSeconds: c.sts.durationSeconds, - role: c.sts.role, - nameOfBucketToCreateIfNotExist: undefined - }; - - return { - origin: "defined in region", - id: `region-${fnv1aHashToHex( - JSON.stringify([c.url, c.sts.oidcParams.clientId ?? ""]) - )}`, - bookmarks: c.bookmarks.map(({ title, s3UriPrefixObj }) => ({ - displayName: title, - s3UriPrefixObj - })), - paramsOfCreateS3Client, - credentialsTestStatus: getCredentialsTestStatus({ - paramsOfCreateS3Client - }), - isXOnyxiaDefault: false, - isExplorerConfig: false - }; - }) + ...fromRegion.s3Profiles + .map((c, index): S3Profile.DefinedInRegion[] => { + const resolvedTemplatedBookmarks_forThisProfile = (() => { + const entry = fromRegion.resolvedTemplatedBookmarks.find( + e => e.correspondingS3ConfigIndexInRegion === index + ); + + assert(entry !== undefined); + + return entry.bookmarks; + })(); + + const buildFromRole = (params: { + resolvedTemplatedStsRole: ResolvedTemplateStsRole | undefined; + }): S3Profile.DefinedInRegion => { + const { resolvedTemplatedStsRole } = params; + + const paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts = { + url: c.url, + pathStyleAccess: c.pathStyleAccess, + isStsEnabled: true, + stsUrl: c.sts.url, + region: c.region, + oidcParams: c.sts.oidcParams, + durationSeconds: c.sts.durationSeconds, + role: resolvedTemplatedStsRole, + nameOfBucketToCreateIfNotExist: undefined + }; + + return { + origin: "defined in region", + id: `region-${fnv1aHashToHex( + JSON.stringify([c.url, c.sts.oidcParams.clientId ?? ""]) + )}`, + bookmarks: resolvedTemplatedBookmarks_forThisProfile + .filter(({ forStsRoleSessionNames }) => { + if (forStsRoleSessionNames.length === 0) { + return true; + } + + if (resolvedTemplatedStsRole === undefined) { + return false; + } + + const getDoMatch = (params: { + stringWithWildcards: string; + candidate: string; + }): boolean => { + const { stringWithWildcards, candidate } = params; + + if (!stringWithWildcards.includes("*")) { + return stringWithWildcards === candidate; + } + + const escapedRegex = stringWithWildcards + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + .replace(/\\\*/g, ".*"); + + return new RegExp(`^${escapedRegex}$`).test( + candidate + ); + }; + + return forStsRoleSessionNames.some(stsRoleSessionName => + getDoMatch({ + stringWithWildcards: stsRoleSessionName, + candidate: + resolvedTemplatedStsRole.roleSessionName + }) + ); + }) + .map(({ title, s3UriPrefixObj }) => ({ + displayName: title, + s3UriPrefixObj + })), + paramsOfCreateS3Client, + credentialsTestStatus: getCredentialsTestStatus({ + paramsOfCreateS3Client + }), + isXOnyxiaDefault: false, + isExplorerConfig: false + }; + }; + + const resolvedTemplatedStsRoles_forThisProfile = (() => { + const entry = fromRegion.resolvedTemplatedStsRoles.find( + e => e.correspondingS3ConfigIndexInRegion === index + ); + + assert(entry !== undefined); + + return entry.stsRoles; + })(); + + if (resolvedTemplatedStsRoles_forThisProfile.length === 0) { + return [buildFromRole({ resolvedTemplatedStsRole: undefined })]; + } + + return resolvedTemplatedStsRoles_forThisProfile.map( + resolvedTemplatedStsRole => + buildFromRole({ resolvedTemplatedStsRole }) + ); + }) + .flat() ]; ( diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts index 95dc0f996..2f7a18411 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts @@ -14,16 +14,17 @@ type R = Record< >; export function updateDefaultS3ProfilesAfterPotentialDeletion(params: { - fromRegion: DeploymentRegion.S3Next.S3Profile[]; + fromRegion: { s3Profiles: DeploymentRegion.S3Next.S3Profile[] }; fromVault: projectManagement.ProjectConfigs["s3"]; }): R { const { fromRegion, fromVault } = params; const s3Profiles = aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ - fromRegion: fromRegion.map(s3Profile => ({ - ...s3Profile, - bookmarks: [] - })), + fromRegion: { + s3Profiles: fromRegion.s3Profiles, + resolvedTemplatedBookmarks: [], + resolvedTemplatedStsRoles: [] + }, fromVault, credentialsTestState: { ongoingTests: [], diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts index 272137d99..eaf5ccadf 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts @@ -2,7 +2,6 @@ import { createSelector } from "clean-architecture"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import * as projectManagement from "core/usecases/projectManagement"; import * as s3CredentialsTest from "core/usecases/_s3Next/s3CredentialsTest"; -import { assert } from "tsafe/assert"; import { type S3Profile, aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet @@ -15,6 +14,11 @@ const resolvedTemplatedBookmarks = createSelector( state => state.resolvedTemplatedBookmarks ); +const resolvedTemplatedStsRoles = createSelector( + (state: RootState) => state[name], + state => state.resolvedTemplatedStsRoles +); + const s3Profiles = createSelector( createSelector( projectManagement.protectedSelectors.projectConfig, @@ -25,27 +29,22 @@ const s3Profiles = createSelector( deploymentRegion => deploymentRegion._s3Next.s3Profiles ), resolvedTemplatedBookmarks, + resolvedTemplatedStsRoles, s3CredentialsTest.protectedSelectors.credentialsTestState, ( projectConfigS3, s3Profiles_region, resolvedTemplatedBookmarks, + resolvedTemplatedStsRoles, credentialsTestState ): S3Profile[] => aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ fromVault: projectConfigS3, - fromRegion: s3Profiles_region.map((s3Profile, i) => ({ - ...s3Profile, - bookmarks: (() => { - const entry = resolvedTemplatedBookmarks.find( - entry => entry.correspondingS3ConfigIndexInRegion === i - ); - - assert(entry !== undefined); - - return entry.bookmarks; - })() - })), + fromRegion: { + s3Profiles: s3Profiles_region, + resolvedTemplatedBookmarks, + resolvedTemplatedStsRoles + }, credentialsTestState }) ); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts index 39f1f16c0..ad4f77ebd 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts @@ -3,12 +3,17 @@ import { createObjectThatThrowsIfAccessed } from "clean-architecture"; import type { ResolvedTemplateBookmark } from "./decoupledLogic/resolveTemplatedBookmark"; +import type { ResolvedTemplateStsRole } from "./decoupledLogic/resolveTemplatedStsRole"; type State = { resolvedTemplatedBookmarks: { correspondingS3ConfigIndexInRegion: number; bookmarks: ResolvedTemplateBookmark[]; }[]; + resolvedTemplatedStsRoles: { + correspondingS3ConfigIndexInRegion: number; + stsRoles: ResolvedTemplateStsRole[]; + }[]; }; export const name = "s3ProfilesManagement"; @@ -24,13 +29,15 @@ export const { reducer, actions } = createUsecaseActions({ }: { payload: { resolvedTemplatedBookmarks: State["resolvedTemplatedBookmarks"]; + resolvedTemplatedStsRoles: State["resolvedTemplatedStsRoles"]; }; } ) => { - const { resolvedTemplatedBookmarks } = payload; + const { resolvedTemplatedBookmarks, resolvedTemplatedStsRoles } = payload; const state: State = { - resolvedTemplatedBookmarks + resolvedTemplatedBookmarks, + resolvedTemplatedStsRoles }; return state; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts index dfef62fb1..08ba69559 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts @@ -10,8 +10,10 @@ import structuredClone from "@ungap/structured-clone"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; import { resolveTemplatedBookmark } from "./decoupledLogic/resolveTemplatedBookmark"; +import { resolveTemplatedStsRole } from "./decoupledLogic/resolveTemplatedStsRole"; import { actions } from "./state"; import type { S3Profile } from "./decoupledLogic/s3Profiles"; +import type { OidcParams_Partial } from "core/ports/OnyxiaApi/OidcParams"; export const thunks = { testS3ProfileCredentials: @@ -53,10 +55,12 @@ export const thunks = { { const actions = updateDefaultS3ProfilesAfterPotentialDeletion({ - fromRegion: - deploymentRegionManagement.selectors.currentDeploymentRegion( - getState() - )._s3Next.s3Profiles, + fromRegion: { + s3Profiles: + deploymentRegionManagement.selectors.currentDeploymentRegion( + getState() + )._s3Next.s3Profiles + }, fromVault: fromVault }); @@ -307,49 +311,52 @@ export const protectedThunks = { const deploymentRegion = deploymentRegionManagement.selectors.currentDeploymentRegion(getState()); + const getDecodedIdToken = async (params: { + oidcParams_partial: OidcParams_Partial; + }) => { + const { oidcParams_partial } = params; + + const { createOidc, mergeOidcParams } = await import( + "core/adapters/oidc" + ); + + const { oidcParams } = await onyxiaApi.getAvailableRegionsAndOidcParams(); + + assert(oidcParams !== undefined); + + const oidc = await createOidc({ + ...mergeOidcParams({ + oidcParams, + oidcParams_partial + }), + autoLogin: true, + transformBeforeRedirectForKeycloakTheme: + paramsOfBootstrapCore.transformBeforeRedirectForKeycloakTheme, + getCurrentLang: paramsOfBootstrapCore.getCurrentLang, + enableDebugLogs: paramsOfBootstrapCore.enableOidcDebugLogs + }); + + const { decodedIdToken } = await oidc.getTokens(); + + return decodedIdToken; + }; + const resolvedTemplatedBookmarks = await Promise.all( deploymentRegion._s3Next.s3Profiles.map( async (s3Config, s3ConfigIndex) => { - const { - bookmarks, - sts: { oidcParams: oidcParams_partial } - } = s3Config; - - const getDecodedIdToken = async () => { - const { createOidc, mergeOidcParams } = await import( - "core/adapters/oidc" - ); - - const { oidcParams } = - await onyxiaApi.getAvailableRegionsAndOidcParams(); - - assert(oidcParams !== undefined); - - const oidc = await createOidc({ - ...mergeOidcParams({ - oidcParams, - oidcParams_partial - }), - autoLogin: true, - transformBeforeRedirectForKeycloakTheme: - paramsOfBootstrapCore.transformBeforeRedirectForKeycloakTheme, - getCurrentLang: paramsOfBootstrapCore.getCurrentLang, - enableDebugLogs: paramsOfBootstrapCore.enableOidcDebugLogs - }); - - const { decodedIdToken } = await oidc.getTokens(); - - return decodedIdToken; - }; + const { bookmarks: bookmarks_region, sts } = s3Config; return { correspondingS3ConfigIndexInRegion: s3ConfigIndex, bookmarks: ( await Promise.all( - bookmarks.map(bookmark => + bookmarks_region.map(bookmark => resolveTemplatedBookmark({ bookmark_region: bookmark, - getDecodedIdToken + getDecodedIdToken: () => + getDecodedIdToken({ + oidcParams_partial: sts.oidcParams + }) }) ) ) @@ -359,7 +366,37 @@ export const protectedThunks = { ) ); - dispatch(actions.initialized({ resolvedTemplatedBookmarks })); + const resolvedTemplatedStsRoles = await Promise.all( + deploymentRegion._s3Next.s3Profiles.map( + async (s3Config, s3ConfigIndex) => { + const { sts } = s3Config; + + return { + correspondingS3ConfigIndexInRegion: s3ConfigIndex, + stsRoles: ( + await Promise.all( + sts.roles.map(stsRole_region => + resolveTemplatedStsRole({ + stsRole_region, + getDecodedIdToken: () => + getDecodedIdToken({ + oidcParams_partial: sts.oidcParams + }) + }) + ) + ) + ).flat() + }; + } + ) + ); + + dispatch( + actions.initialized({ + resolvedTemplatedBookmarks, + resolvedTemplatedStsRoles + }) + ); } } satisfies Thunks; From e564af0eaefef04daa0e38f8346a012a8c2db2e5 Mon Sep 17 00:00:00 2001 From: garronej Date: Mon, 1 Dec 2025 23:16:14 +0100 Subject: [PATCH 29/41] Fix rooting issue (enable to navigate back in new s3 explorer) --- .../core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts index c1d0dd6d3..95de61d5d 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts @@ -32,9 +32,10 @@ export const createEvt = (({ evtAction, getState }) => { method: (() => { switch (actionName) { case "routeParamsSet": - case "s3UrlUpdated": case "selectedS3ProfileUpdated": return "replace" as const; + case "s3UrlUpdated": + return "push" as const; } })(), routeParams From c41c8a01411de02a29df49cf5ebe6dbaaad23ecc Mon Sep 17 00:00:00 2001 From: garronej Date: Tue, 2 Dec 2025 05:00:27 +0100 Subject: [PATCH 30/41] Enable to create/update/delete bookmarks, store in vault --- .../s3ExplorerRootUiController/selectors.ts | 41 ++++- .../s3ExplorerRootUiController/thunks.ts | 41 ++++- .../selectors.ts | 3 +- .../decoupledLogic/s3Profiles.ts | 118 ++++++++------ ...DefaultS3ProfilesAfterPotentialDeletion.ts | 11 +- .../decoupledLogic/userConfigsS3Bookmarks.ts | 27 ++++ .../_s3Next/s3ProfilesManagement/selectors.ts | 17 +- .../_s3Next/s3ProfilesManagement/thunks.ts | 148 +++++++++++++++++- .../decoupledLogic/ProjectConfigs.ts | 29 +++- .../projectConfigsMigration/v0ToV1.ts | 12 +- .../usecases/projectManagement/selectors.ts | 1 + .../usecases/s3ConfigCreation/selectors.ts | 3 +- web/src/core/usecases/userConfigs.ts | 4 +- .../pages/fileExplorer/Explorer/Explorer.tsx | 27 +++- web/src/ui/pages/fileExplorer/Page.tsx | 2 +- web/src/ui/pages/s3Explorer/Explorer.tsx | 13 +- web/src/ui/pages/s3Explorer/Page.tsx | 10 +- 17 files changed, 420 insertions(+), 87 deletions(-) create mode 100644 web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts index d6db3de6f..e81e8be10 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts @@ -28,7 +28,14 @@ export type View = { s3UriPrefixObj: S3UriPrefixObj; }[]; s3UriPrefixObj: S3UriPrefixObj | undefined; - isS3UriPrefixBookmarked: boolean; + bookmarkStatus: + | { + isBookmarked: false; + } + | { + isBookmarked: true; + isReadonly: boolean; + }; }; const view = createSelector( @@ -44,7 +51,9 @@ const view = createSelector( availableS3Profiles: [], bookmarks: [], s3UriPrefixObj: undefined, - isS3UriPrefixBookmarked: false + bookmarkStatus: { + isBookmarked: false + } }; } @@ -76,12 +85,28 @@ const view = createSelector( })), bookmarks: s3Profile.bookmarks, s3UriPrefixObj, - isS3UriPrefixBookmarked: - s3UriPrefixObj === undefined - ? false - : s3Profile.bookmarks.some(bookmark => - same(bookmark.s3UriPrefixObj, s3UriPrefixObj) - ) + bookmarkStatus: (() => { + if (s3UriPrefixObj === undefined) { + return { + isBookmarked: false + }; + } + + const bookmark = s3Profile.bookmarks.find(bookmark => + same(bookmark.s3UriPrefixObj, s3UriPrefixObj) + ); + + if (bookmark === undefined) { + return { + isBookmarked: false + }; + } + + return { + isBookmarked: true, + isReadonly: bookmark.isReadonly + }; + })() }; } ); diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts index 61c6e4899..3904f6f38 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts @@ -2,8 +2,10 @@ import type { Thunks } from "core/bootstrap"; import { actions, type RouteParams } from "./state"; import { protectedSelectors } from "./selectors"; import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; +import { selectors } from "./selectors"; import { evt } from "./evt"; import type { S3UriPrefixObj } from "core/tools/S3Uri"; +import { assert } from "tsafe/assert"; export const thunks = { load: @@ -76,5 +78,42 @@ export const thunks = { s3ProfileId }) ); - } + }, + toggleIsDirectoryPathBookmarked: (() => { + let isRunning = false; + + return () => + async (...args) => { + if (isRunning) { + return; + } + + isRunning = true; + + const [dispatch, getState] = args; + + const { selectedS3ProfileId, s3UriPrefixObj, bookmarkStatus } = + selectors.view(getState()); + + assert(selectedS3ProfileId !== undefined); + assert(s3UriPrefixObj !== undefined); + + await dispatch( + s3ProfilesManagement.protectedThunks.createDeleteOrUpdateBookmark({ + s3ProfileId: selectedS3ProfileId, + s3UriPrefixObj, + action: bookmarkStatus.isBookmarked + ? { + type: "delete" + } + : { + type: "create or update", + displayName: undefined + } + }) + ); + + isRunning = false; + }; + })() } satisfies Thunks; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts index 7934a5a28..41fae25e4 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts @@ -169,7 +169,8 @@ const submittableFormValuesAsProjectS3Config = createSelector( }; })(), // TODO: Delete once we move on - workingDirectoryPath: "mybucket/my/prefix/" + workingDirectoryPath: "mybucket/my/prefix/", + bookmarks: [] }); } ); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts index 9cf96230b..b9861e635 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -9,6 +9,7 @@ import type { LocalizedString } from "core/ports/OnyxiaApi"; import type { ResolvedTemplateBookmark } from "./resolveTemplatedBookmark"; import type { ResolvedTemplateStsRole } from "./resolveTemplatedStsRole"; import type { S3UriPrefixObj } from "core/tools/S3Uri"; +import { parseUserConfigsS3BookmarksStr } from "./userConfigsS3Bookmarks"; export type S3Profile = S3Profile.DefinedInRegion | S3Profile.CreatedByUser; @@ -22,12 +23,12 @@ export namespace S3Profile { | { status: "test ongoing" } | { status: "test failed"; errorMessage: string } | { status: "test succeeded" }; + bookmarks: Bookmark[]; }; export type DefinedInRegion = Common & { origin: "defined in region"; paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts; - bookmarks: Bookmark[]; }; export type CreatedByUser = Common & { @@ -35,17 +36,20 @@ export namespace S3Profile { creationTime: number; paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts; friendlyName: string; - bookmarks: Bookmark[]; }; export type Bookmark = { + isReadonly: boolean; displayName: LocalizedString | undefined; s3UriPrefixObj: S3UriPrefixObj; }; } export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { - fromVault: projectManagement.ProjectConfigs["s3"]; + fromVault: { + projectConfigs_s3: projectManagement.ProjectConfigs["s3"]; + userConfigs_s3BookmarksStr: string | null; + }; fromRegion: { s3Profiles: DeploymentRegion.S3Next.S3Profile[]; resolvedTemplatedBookmarks: { @@ -93,7 +97,7 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { }; const s3Profiles: S3Profile[] = [ - ...fromVault.s3Configs + ...fromVault.projectConfigs_s3.s3Configs .map((c): S3Profile.CreatedByUser => { const url = c.url; const pathStyleAccess = c.pathStyleAccess; @@ -116,7 +120,13 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { isXOnyxiaDefault: false, isExplorerConfig: false, // TODO: Actually store custom bookmarks - bookmarks: [], + bookmarks: (c.bookmarks ?? []).map( + ({ displayName, s3UriPrefixObj }) => ({ + displayName, + s3UriPrefixObj, + isReadonly: false + }) + ), credentialsTestStatus: getCredentialsTestStatus({ paramsOfCreateS3Client }) @@ -152,52 +162,68 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { nameOfBucketToCreateIfNotExist: undefined }; + const id = `region-${fnv1aHashToHex( + JSON.stringify([c.url, c.sts.oidcParams.clientId ?? ""]) + )}`; + return { origin: "defined in region", - id: `region-${fnv1aHashToHex( - JSON.stringify([c.url, c.sts.oidcParams.clientId ?? ""]) - )}`, - bookmarks: resolvedTemplatedBookmarks_forThisProfile - .filter(({ forStsRoleSessionNames }) => { - if (forStsRoleSessionNames.length === 0) { - return true; - } - - if (resolvedTemplatedStsRole === undefined) { - return false; - } - - const getDoMatch = (params: { - stringWithWildcards: string; - candidate: string; - }): boolean => { - const { stringWithWildcards, candidate } = params; - - if (!stringWithWildcards.includes("*")) { - return stringWithWildcards === candidate; + id, + bookmarks: [ + ...resolvedTemplatedBookmarks_forThisProfile + .filter(({ forStsRoleSessionNames }) => { + if (forStsRoleSessionNames.length === 0) { + return true; } - const escapedRegex = stringWithWildcards - .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - .replace(/\\\*/g, ".*"); + if (resolvedTemplatedStsRole === undefined) { + return false; + } - return new RegExp(`^${escapedRegex}$`).test( - candidate + const getDoMatch = (params: { + stringWithWildcards: string; + candidate: string; + }): boolean => { + const { stringWithWildcards, candidate } = params; + + if (!stringWithWildcards.includes("*")) { + return stringWithWildcards === candidate; + } + + const escapedRegex = stringWithWildcards + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + .replace(/\\\*/g, ".*"); + + return new RegExp(`^${escapedRegex}$`).test( + candidate + ); + }; + + return forStsRoleSessionNames.some( + stsRoleSessionName => + getDoMatch({ + stringWithWildcards: stsRoleSessionName, + candidate: + resolvedTemplatedStsRole.roleSessionName + }) ); - }; - - return forStsRoleSessionNames.some(stsRoleSessionName => - getDoMatch({ - stringWithWildcards: stsRoleSessionName, - candidate: - resolvedTemplatedStsRole.roleSessionName - }) - ); + }) + .map(({ title, s3UriPrefixObj }) => ({ + isReadonly: true, + displayName: title, + s3UriPrefixObj + })), + ...parseUserConfigsS3BookmarksStr({ + userConfigs_s3BookmarksStr: + fromVault.userConfigs_s3BookmarksStr }) - .map(({ title, s3UriPrefixObj }) => ({ - displayName: title, - s3UriPrefixObj - })), + .filter(entry => entry.s3ProfileId === id) + .map(entry => ({ + isReadonly: false, + displayName: entry.displayName, + s3UriPrefixObj: entry.s3UriPrefixObj + })) + ], paramsOfCreateS3Client, credentialsTestStatus: getCredentialsTestStatus({ paramsOfCreateS3Client @@ -231,8 +257,8 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { ( [ - ["defaultXOnyxia", fromVault.s3ConfigId_defaultXOnyxia], - ["explorer", fromVault.s3ConfigId_explorer] + ["defaultXOnyxia", fromVault.projectConfigs_s3.s3ConfigId_defaultXOnyxia], + ["explorer", fromVault.projectConfigs_s3.s3ConfigId_explorer] ] as const ).forEach(([prop, s3ProfileId]) => { if (s3ProfileId === undefined) { diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts index 2f7a18411..dd0d90bda 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts @@ -15,7 +15,9 @@ type R = Record< export function updateDefaultS3ProfilesAfterPotentialDeletion(params: { fromRegion: { s3Profiles: DeploymentRegion.S3Next.S3Profile[] }; - fromVault: projectManagement.ProjectConfigs["s3"]; + fromVault: { + projectConfigs_s3: projectManagement.ProjectConfigs["s3"]; + }; }): R { const { fromRegion, fromVault } = params; @@ -25,7 +27,10 @@ export function updateDefaultS3ProfilesAfterPotentialDeletion(params: { resolvedTemplatedBookmarks: [], resolvedTemplatedStsRoles: [] }, - fromVault, + fromVault: { + projectConfigs_s3: fromVault.projectConfigs_s3, + userConfigs_s3BookmarksStr: null + }, credentialsTestState: { ongoingTests: [], testResults: [] @@ -45,7 +50,7 @@ export function updateDefaultS3ProfilesAfterPotentialDeletion(params: { "s3ConfigId_defaultXOnyxia", "s3ConfigId_explorer" ] as const) { - const s3ConfigId_default = fromVault[propertyName]; + const s3ConfigId_default = fromVault.projectConfigs_s3[propertyName]; if (s3ConfigId_default === undefined) { continue; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts new file mode 100644 index 000000000..392b66b34 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts @@ -0,0 +1,27 @@ +import type { S3UriPrefixObj } from "core/tools/S3Uri"; + +export type UserProfileS3Bookmark = { + s3ProfileId: string; + displayName: string | undefined; + s3UriPrefixObj: S3UriPrefixObj; +}; + +export function parseUserConfigsS3BookmarksStr(params: { + userConfigs_s3BookmarksStr: string | null; +}): UserProfileS3Bookmark[] { + const { userConfigs_s3BookmarksStr } = params; + + if (userConfigs_s3BookmarksStr === null) { + return []; + } + + return JSON.parse(userConfigs_s3BookmarksStr); +} + +export function serializeUserConfigsS3Bookmarks(params: { + userConfigs_s3Bookmarks: UserProfileS3Bookmark[]; +}) { + const { userConfigs_s3Bookmarks } = params; + + return JSON.stringify(userConfigs_s3Bookmarks); +} diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts index eaf5ccadf..cc2080306 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts @@ -2,6 +2,7 @@ import { createSelector } from "clean-architecture"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import * as projectManagement from "core/usecases/projectManagement"; import * as s3CredentialsTest from "core/usecases/_s3Next/s3CredentialsTest"; +import * as userConfigs from "core/usecases/userConfigs"; import { type S3Profile, aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet @@ -19,6 +20,11 @@ const resolvedTemplatedStsRoles = createSelector( state => state.resolvedTemplatedStsRoles ); +const userConfigs_s3BookmarksStr = createSelector( + userConfigs.selectors.userConfigs, + userConfigs => userConfigs.s3BookmarksStr +); + const s3Profiles = createSelector( createSelector( projectManagement.protectedSelectors.projectConfig, @@ -31,15 +37,20 @@ const s3Profiles = createSelector( resolvedTemplatedBookmarks, resolvedTemplatedStsRoles, s3CredentialsTest.protectedSelectors.credentialsTestState, + userConfigs_s3BookmarksStr, ( - projectConfigS3, + projectConfigs_s3, s3Profiles_region, resolvedTemplatedBookmarks, resolvedTemplatedStsRoles, - credentialsTestState + credentialsTestState, + userConfigs_s3BookmarksStr ): S3Profile[] => aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ - fromVault: projectConfigS3, + fromVault: { + projectConfigs_s3, + userConfigs_s3BookmarksStr + }, fromRegion: { s3Profiles: s3Profiles_region, resolvedTemplatedBookmarks, diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts index 08ba69559..700a3ab05 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts @@ -14,6 +14,13 @@ import { resolveTemplatedStsRole } from "./decoupledLogic/resolveTemplatedStsRol import { actions } from "./state"; import type { S3Profile } from "./decoupledLogic/s3Profiles"; import type { OidcParams_Partial } from "core/ports/OnyxiaApi/OidcParams"; +import type { S3UriPrefixObj } from "core/tools/S3Uri"; +import { same } from "evt/tools/inDepth/same"; +import { + parseUserConfigsS3BookmarksStr, + serializeUserConfigsS3Bookmarks +} from "./decoupledLogic/userConfigsS3Bookmarks"; +import * as userConfigs from "core/usecases/userConfigs"; export const thunks = { testS3ProfileCredentials: @@ -41,17 +48,17 @@ export const thunks = { const [dispatch, getState] = args; - const fromVault = structuredClone( + const projectConfigs_s3 = structuredClone( projectManagement.protectedSelectors.projectConfig(getState()).s3 ); - const i = fromVault.s3Configs.findIndex( + const i = projectConfigs_s3.s3Configs.findIndex( ({ creationTime }) => creationTime === s3ProfileCreationTime ); assert(i !== -1); - fromVault.s3Configs.splice(i, 1); + projectConfigs_s3.s3Configs.splice(i, 1); { const actions = updateDefaultS3ProfilesAfterPotentialDeletion({ @@ -61,7 +68,9 @@ export const thunks = { getState() )._s3Next.s3Profiles }, - fromVault: fromVault + fromVault: { + projectConfigs_s3 + } }); await Promise.all( @@ -73,7 +82,7 @@ export const thunks = { return; } - fromVault[propertyName] = action.s3ProfileId; + projectConfigs_s3[propertyName] = action.s3ProfileId; } ) ); @@ -82,7 +91,7 @@ export const thunks = { await dispatch( projectManagement.protectedThunks.updateConfigValue({ key: "s3", - value: fromVault + value: projectConfigs_s3 }) ); }, @@ -302,7 +311,134 @@ export const protectedThunks = { }) ); }, + createDeleteOrUpdateBookmark: + (params: { + s3ProfileId: string; + s3UriPrefixObj: S3UriPrefixObj; + action: + | { + type: "create or update"; + displayName: string | undefined; + } + | { + type: "delete"; + }; + }) => + async (...args) => { + const { s3ProfileId, s3UriPrefixObj, action } = params; + + const [dispatch, getState] = args; + + const s3Profiles = selectors.s3Profiles(getState()); + + const s3Profile = s3Profiles.find(s3Profile => s3Profile.id === s3ProfileId); + + assert(s3Profile !== undefined); + switch (s3Profile.origin) { + case "created by user (or group project member)": + { + const projectConfigs_s3 = structuredClone( + projectManagement.protectedSelectors.projectConfig(getState()) + .s3 + ); + + const s3Config_vault = projectConfigs_s3.s3Configs.find( + s3Config => s3Config.creationTime === s3Profile.creationTime + ); + + assert(s3Config_vault !== undefined); + + s3Config_vault.bookmarks ??= []; + + const index = s3Config_vault.bookmarks.findIndex(bookmark => + same(bookmark.s3UriPrefixObj, s3UriPrefixObj) + ); + + switch (action.type) { + case "create or update": + { + const bookmark_new = { + displayName: action.displayName, + s3UriPrefixObj + }; + + if (index === -1) { + s3Config_vault.bookmarks.push(bookmark_new); + } else { + s3Config_vault.bookmarks[index] = bookmark_new; + } + } + break; + case "delete": + { + assert(index !== -1); + + s3Config_vault.bookmarks.splice(index, 1); + } + break; + } + + await dispatch( + projectManagement.protectedThunks.updateConfigValue({ + key: "s3", + value: projectConfigs_s3 + }) + ); + } + break; + case "defined in region": + { + const { s3BookmarksStr } = + userConfigs.selectors.userConfigs(getState()); + + const userConfigs_s3Bookmarks = parseUserConfigsS3BookmarksStr({ + userConfigs_s3BookmarksStr: s3BookmarksStr + }); + + const index = userConfigs_s3Bookmarks.findIndex( + entry => + entry.s3ProfileId === s3Profile.id && + same(entry.s3UriPrefixObj, s3UriPrefixObj) + ); + + switch (action.type) { + case "create or update": + { + const bookmark_new = { + s3ProfileId: s3Profile.id, + displayName: action.displayName, + s3UriPrefixObj + }; + + if (index === -1) { + userConfigs_s3Bookmarks.push(bookmark_new); + } else { + userConfigs_s3Bookmarks[index] = bookmark_new; + } + } + break; + case "delete": + { + assert(index !== -1); + + userConfigs_s3Bookmarks.splice(index, 1); + } + break; + } + + await dispatch( + userConfigs.thunks.changeValue({ + key: "s3BookmarksStr", + value: serializeUserConfigsS3Bookmarks({ + userConfigs_s3Bookmarks + }) + }) + ); + } + break; + } + }, initialize: () => async (...args) => { diff --git a/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts b/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts index 7e90cc574..7b0c151b9 100644 --- a/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts +++ b/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { id } from "tsafe/id"; import type { OptionalIfCanBeUndefined } from "core/tools/OptionalIfCanBeUndefined"; import { zStringifyableAtomic } from "core/tools/Stringifyable"; +import type { S3UriPrefixObj } from "core/tools/S3Uri"; export type ProjectConfigs = { __modelVersion: 1; @@ -33,8 +34,16 @@ export namespace ProjectConfigs { sessionToken: string | undefined; } | undefined; + bookmarks: S3Config.Bookmark[] | undefined; }; + export namespace S3Config { + export type Bookmark = { + displayName: string | undefined; + s3UriPrefixObj: S3UriPrefixObj; + }; + } + export type RestorableServiceConfig = { friendlyName: string; isShared: boolean | undefined; @@ -100,6 +109,23 @@ const zS3Credentials = (() => { return id>(zTargetType); })(); +const zS3ConfigBookmark = (() => { + type TargetType = ProjectConfigs.S3Config.Bookmark; + + const zTargetType = z.object({ + displayName: z.union([z.string(), z.undefined()]), + s3UriPrefixObj: z.object({ + bucket: z.string(), + keyPrefix: z.string() + }) + }); + + assert, OptionalIfCanBeUndefined>>(); + + // @ts-expect-error + return id>(zTargetType); +})(); + const zS3Config = (() => { type TargetType = ProjectConfigs.S3Config; @@ -110,7 +136,8 @@ const zS3Config = (() => { region: z.union([z.string(), z.undefined()]), workingDirectoryPath: z.string(), pathStyleAccess: z.boolean(), - credentials: z.union([zS3Credentials, z.undefined()]) + credentials: z.union([zS3Credentials, z.undefined()]), + bookmarks: z.union([z.array(zS3ConfigBookmark), z.undefined()]) }); assert, OptionalIfCanBeUndefined>>(); diff --git a/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/v0ToV1.ts b/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/v0ToV1.ts index affa7943a..1c62cddd5 100644 --- a/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/v0ToV1.ts +++ b/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/v0ToV1.ts @@ -5,6 +5,7 @@ import { join as pathJoin } from "pathe"; import { secretToValue, valueToSecret } from "../secretParsing"; import YAML from "yaml"; import { getS3Configs } from "core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs"; +import type { S3UriPrefixObj } from "core/tools/S3Uri"; namespace v0 { export type ProjectConfigs = { @@ -89,8 +90,16 @@ export namespace v1 { sessionToken: string | undefined; } | undefined; + bookmarks: S3Config.Bookmark[] | undefined; }; + export namespace S3Config { + export type Bookmark = { + displayName: string | undefined; + s3UriPrefixObj: S3UriPrefixObj; + }; + } + export type RestorableServiceConfig = { friendlyName: string; isShared: boolean | undefined; @@ -255,7 +264,8 @@ export async function v0ToV1(params: { workingDirectoryPath: customS3Config_legacy.workingDirectoryPath, pathStyleAccess: customS3Config_legacy.pathStyleAccess, - credentials: customS3Config_legacy.credentials + credentials: customS3Config_legacy.credentials, + bookmarks: [] }); }); diff --git a/web/src/core/usecases/projectManagement/selectors.ts b/web/src/core/usecases/projectManagement/selectors.ts index 09c00408d..aaf31f994 100644 --- a/web/src/core/usecases/projectManagement/selectors.ts +++ b/web/src/core/usecases/projectManagement/selectors.ts @@ -5,6 +5,7 @@ import { assert } from "tsafe/assert"; const state = (rootState: RootState) => rootState[name]; +// TODO: Here this selector should take a s const projectConfig = createSelector(state, state => state.currentProjectConfigs); export const protectedSelectors = { diff --git a/web/src/core/usecases/s3ConfigCreation/selectors.ts b/web/src/core/usecases/s3ConfigCreation/selectors.ts index 09399f8fb..7b8f264ee 100644 --- a/web/src/core/usecases/s3ConfigCreation/selectors.ts +++ b/web/src/core/usecases/s3ConfigCreation/selectors.ts @@ -214,7 +214,8 @@ const submittableFormValuesAsProjectS3Config = createSelector( secretAccessKey: formValues.secretAccessKey, sessionToken: formValues.sessionToken }; - })() + })(), + bookmarks: undefined }); } ); diff --git a/web/src/core/usecases/userConfigs.ts b/web/src/core/usecases/userConfigs.ts index 5667c6bef..b3a3f7463 100644 --- a/web/src/core/usecases/userConfigs.ts +++ b/web/src/core/usecases/userConfigs.ts @@ -32,6 +32,7 @@ export type UserConfigs = Id< selectedProjectId: string | null; isCommandBarEnabled: boolean; userProfileStr: string | null; + s3BookmarksStr: string | null; } >; @@ -153,7 +154,8 @@ export const protectedThunks = { doDisplayAcknowledgeConfigVolatilityDialogIfNoVault: true, selectedProjectId: null, isCommandBarEnabled: paramsOfBootstrapCore.isCommandBarEnabledByDefault, - userProfileStr: null + userProfileStr: null, + s3BookmarksStr: null }; const dirPath = await dispatch(privateThunks.getDirPath()); diff --git a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx b/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx index b3334b668..5ac4cdf01 100644 --- a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx +++ b/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx @@ -45,6 +45,7 @@ import { ShareDialog } from "../ShareFile/ShareDialog"; import type { ShareView } from "core/usecases/fileExplorer"; import { ExplorerDownloadSnackbar } from "./ExplorerDownloadSnackbar"; import { IconButton } from "onyxia-ui/IconButton"; +import { Icon } from "onyxia-ui/Icon"; import { getIconUrlByName } from "lazy-icons"; export type ExplorerProps = { @@ -99,7 +100,15 @@ export type ExplorerProps = { }[]; }) => void; - isDirectoryPathBookmarked: boolean | undefined; + bookmarkStatus: + | { + isBookmarked: false; + } + | { + isBookmarked: true; + isReadonly: boolean; + } + | undefined; onToggleIsDirectoryPathBookmarked: (() => void) | undefined; }; @@ -144,7 +153,7 @@ export const Explorer = memo((props: ExplorerProps) => { onChangeShareSelectedValidityDuration, onDownloadItems, evtIsDownloadSnackbarOpen, - isDirectoryPathBookmarked, + bookmarkStatus, onToggleIsDirectoryPathBookmarked } = props; @@ -423,16 +432,22 @@ export const Explorer = memo((props: ExplorerProps) => { /> )} {(() => { - if (isDirectoryPathBookmarked === undefined) { + if (bookmarkStatus === undefined) { return null; } assert(onToggleIsDirectoryPathBookmarked !== undefined); + const icon = getIconUrlByName( + bookmarkStatus.isBookmarked ? "Star" : "StarBorder" + ); + + if (bookmarkStatus.isBookmarked && bookmarkStatus.isReadonly) { + return ; + } + return ( ); diff --git a/web/src/ui/pages/fileExplorer/Page.tsx b/web/src/ui/pages/fileExplorer/Page.tsx index 89aa0560f..17c6edb97 100644 --- a/web/src/ui/pages/fileExplorer/Page.tsx +++ b/web/src/ui/pages/fileExplorer/Page.tsx @@ -233,7 +233,7 @@ function FileExplorer() { } onDownloadItems={onDownloadItems} evtIsDownloadSnackbarOpen={evtIsSnackbarOpen} - isDirectoryPathBookmarked={undefined} + bookmarkStatus={undefined} onToggleIsDirectoryPathBookmarked={undefined} />
diff --git a/web/src/ui/pages/s3Explorer/Explorer.tsx b/web/src/ui/pages/s3Explorer/Explorer.tsx index d0704c79d..ef271f6c7 100644 --- a/web/src/ui/pages/s3Explorer/Explorer.tsx +++ b/web/src/ui/pages/s3Explorer/Explorer.tsx @@ -22,7 +22,14 @@ type Props = { className?: string; directoryPath: string; changeCurrentDirectory: (params: { directoryPath: string }) => void; - isDirectoryPathBookmarked: boolean; + bookmarkStatus: + | { + isBookmarked: false; + } + | { + isBookmarked: true; + isReadonly: boolean; + }; onToggleIsDirectoryPathBookmarked: () => void; }; @@ -31,7 +38,7 @@ export function Explorer(props: Props) { className, directoryPath, changeCurrentDirectory, - isDirectoryPathBookmarked, + bookmarkStatus, onToggleIsDirectoryPathBookmarked } = props; @@ -237,7 +244,7 @@ export function Explorer(props: Props) { } onDownloadItems={onDownloadItems} evtIsDownloadSnackbarOpen={evtIsSnackbarOpen} - isDirectoryPathBookmarked={isDirectoryPathBookmarked} + bookmarkStatus={bookmarkStatus} onToggleIsDirectoryPathBookmarked={onToggleIsDirectoryPathBookmarked} /> ); diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index b997006d7..8525c59a1 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -48,7 +48,7 @@ function S3Explorer() { evts: { evtS3ExplorerRootUiController } } = getCoreSync(); - const { selectedS3ProfileId, s3UriPrefixObj, isS3UriPrefixBookmarked } = useCoreState( + const { selectedS3ProfileId, s3UriPrefixObj, bookmarkStatus } = useCoreState( "s3ExplorerRootUiController", "view" ); @@ -130,10 +130,10 @@ function S3Explorer() { directoryPath={stringifyS3UriPrefixObj(s3UriPrefixObj).slice( "s3://".length )} - isDirectoryPathBookmarked={isS3UriPrefixBookmarked} - onToggleIsDirectoryPathBookmarked={() => { - alert("TODO: Implement this feature"); - }} + bookmarkStatus={bookmarkStatus} + onToggleIsDirectoryPathBookmarked={ + s3ExplorerRootUiController.toggleIsDirectoryPathBookmarked + } /> ); })()} From 73d2765b6a38568bd6711228a300badcff1e7f1c Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 3 Dec 2025 03:21:06 +0100 Subject: [PATCH 31/41] Incorporate config creation/update into the new S3 explorer page --- .../s3ExplorerRootUiController/selectors.ts | 6 + .../selectors.ts | 16 +- .../s3ProfilesCreationUiController/thunks.ts | 2 +- web/src/ui/pages/s3Explorer/Page.tsx | 120 ++++- .../AddCustomS3ConfigDialog.tsx | 432 ++++++++++++++++++ .../ConfirmCustomS3ConfigDeletionDialog.tsx | 59 +++ .../S3ConfigDialogs/S3ConfigDialogs.tsx | 27 ++ .../TestS3ConnectionButton.tsx | 109 +++++ .../pages/s3Explorer/S3ConfigDialogs/index.ts | 1 + 9 files changed, 739 insertions(+), 33 deletions(-) create mode 100644 web/src/ui/pages/s3Explorer/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx create mode 100644 web/src/ui/pages/s3Explorer/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog.tsx create mode 100644 web/src/ui/pages/s3Explorer/S3ConfigDialogs/S3ConfigDialogs.tsx create mode 100644 web/src/ui/pages/s3Explorer/S3ConfigDialogs/TestS3ConnectionButton.tsx create mode 100644 web/src/ui/pages/s3Explorer/S3ConfigDialogs/index.ts diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts index e81e8be10..1d84aa910 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts @@ -19,6 +19,7 @@ export const protectedSelectors = { export type View = { selectedS3ProfileId: string | undefined; + selectedS3Profile_creationTime: number | undefined; availableS3Profiles: { id: string; displayName: string; @@ -48,6 +49,7 @@ const view = createSelector( if (routeParams.profile === undefined) { return { selectedS3ProfileId: undefined, + selectedS3Profile_creationTime: undefined, availableS3Profiles: [], bookmarks: [], s3UriPrefixObj: undefined, @@ -79,6 +81,10 @@ const view = createSelector( return { selectedS3ProfileId, + selectedS3Profile_creationTime: + s3Profile.origin !== "created by user (or group project member)" + ? undefined + : s3Profile.creationTime, availableS3Profiles: s3Profiles.map(s3Profile => ({ id: s3Profile.id, displayName: s3Profile.paramsOfCreateS3Client.url diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts index 41fae25e4..2587cdbc2 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts @@ -199,13 +199,13 @@ const paramsOfCreateS3Client = createSelector( } ); -type ConnectionTestStatus = +type CredentialsTestStatus = | { status: "test ongoing" } | { status: "test succeeded" } | { status: "test failed"; errorMessage: string } | { status: "not tested" }; -const connectionTestStatus = createSelector( +const credentialsTestStatus = createSelector( isReady, isFormSubmittable, paramsOfCreateS3Client, @@ -215,7 +215,7 @@ const connectionTestStatus = createSelector( isFormSubmittable, paramsOfCreateS3Client, credentialsTestState - ): ConnectionTestStatus | null => { + ): CredentialsTestStatus | null => { if (!isReady) { return null; } @@ -252,7 +252,7 @@ const connectionTestStatus = createSelector( : { status: "test failed", errorMessage: result.errorMessage }; } - return { status: "not tested" } as ConnectionTestStatus; + return { status: "not tested" } as CredentialsTestStatus; } ); @@ -301,7 +301,7 @@ const main = createSelector( isFormSubmittable, urlStylesExamples, isEditionOfAnExistingConfig, - connectionTestStatus, + credentialsTestStatus, ( isReady, formValues, @@ -309,7 +309,7 @@ const main = createSelector( isFormSubmittable, urlStylesExamples, isEditionOfAnExistingConfig, - connectionTestStatus + credentialsTestStatus ) => { if (!isReady) { return { @@ -322,7 +322,7 @@ const main = createSelector( assert(isFormSubmittable !== null); assert(urlStylesExamples !== null); assert(isEditionOfAnExistingConfig !== null); - assert(connectionTestStatus !== null); + assert(credentialsTestStatus !== null); return { isReady: true, @@ -331,7 +331,7 @@ const main = createSelector( isFormSubmittable, urlStylesExamples, isEditionOfAnExistingConfig, - connectionTestStatus + credentialsTestStatus }; } ); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts index fbeb86fab..54395f7f5 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts @@ -186,7 +186,7 @@ export const thunks = { } } }, - testConnection: + testCredentials: () => async (...args) => { const [dispatch, getState] = args; diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 8525c59a1..f2546508b 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -20,6 +20,15 @@ import { parseS3UriPrefix, stringifyS3UriPrefixObj } from "core/tools/S3Uri"; import { useResolveLocalizedString } from "ui/i18n"; import { Icon } from "onyxia-ui/Icon"; import { getIconUrlByName } from "lazy-icons"; +import { S3ConfigDialogs, type S3ConfigDialogsProps } from "./S3ConfigDialogs"; +import { useConst } from "powerhooks/useConst"; +import { Evt, type UnpackEvt } from "evt"; +import { + MaybeAcknowledgeConfigVolatilityDialog, + type MaybeAcknowledgeConfigVolatilityDialogProps +} from "ui/shared/MaybeAcknowledgeConfigVolatilityDialog"; +import { Deferred } from "evt/tools/Deferred"; +import { Button } from "onyxia-ui/Button"; const Page = withLoader({ loader: async () => { @@ -326,35 +335,98 @@ function S3ProfileSelect() { functions: { s3ExplorerRootUiController } } = getCoreSync(); - const { selectedS3ProfileId, availableS3Profiles } = useCoreState( - "s3ExplorerRootUiController", - "view" - ); + const { selectedS3ProfileId, selectedS3Profile_creationTime, availableS3Profiles } = + useCoreState("s3ExplorerRootUiController", "view"); const { css } = useStyles(); + const { + evtConfirmCustomS3ConfigDeletionDialogOpen, + evtAddCustomS3ConfigDialogOpen, + evtMaybeAcknowledgeConfigVolatilityDialogOpen + } = useConst(() => ({ + evtConfirmCustomS3ConfigDeletionDialogOpen: + Evt.create< + UnpackEvt< + S3ConfigDialogsProps["evtConfirmCustomS3ConfigDeletionDialogOpen"] + > + >(), + evtAddCustomS3ConfigDialogOpen: + Evt.create< + UnpackEvt + >(), + evtMaybeAcknowledgeConfigVolatilityDialogOpen: + Evt.create() + })); + return ( - - S3 Profile - { + const { value } = event.target; + + if (value === "__create__") { + const dDoProceed = new Deferred(); + + evtMaybeAcknowledgeConfigVolatilityDialogOpen.post({ + resolve: ({ doProceed }) => dDoProceed.resolve(doProceed) + }); + + if (!(await dDoProceed.pr)) { + return; + } + + evtAddCustomS3ConfigDialogOpen.post({ + creationTimeOfS3ProfileToEdit: undefined + }); + + return; + } + s3ExplorerRootUiController.updateSelectedS3Profile({ + s3ProfileId: value + }); + }} + className={css({ + fontSize: "small" + })} + > + {availableS3Profiles.map(s3Profile => ( + + {s3Profile.displayName} + + ))} + + + Create New S3 Profile - ))} - - + + + {selectedS3Profile_creationTime !== undefined && ( + + )} + + + ); } diff --git a/web/src/ui/pages/s3Explorer/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx new file mode 100644 index 000000000..ad59f2de8 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx @@ -0,0 +1,432 @@ +import { memo } from "react"; +import { Dialog } from "onyxia-ui/Dialog"; +import { Button } from "onyxia-ui/Button"; +import { symToStr } from "tsafe/symToStr"; +import { useCallbackFactory } from "powerhooks/useCallbackFactory"; +import { type NonPostableEvt } from "evt"; +import { useEvt } from "evt/hooks"; +import { TextField } from "onyxia-ui/TextField"; +import Radio from "@mui/material/Radio"; +import RadioGroup from "@mui/material/RadioGroup"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import FormControl from "@mui/material/FormControl"; +import FormLabel from "@mui/material/FormLabel"; +import FormGroup from "@mui/material/FormGroup"; +import { tss } from "tss"; +import { useCoreState, getCoreSync } from "core"; +import { declareComponentKeys, useTranslation } from "ui/i18n"; +import { Text } from "onyxia-ui/Text"; +import { TestS3ConnectionButton } from "./TestS3ConnectionButton"; +import FormHelperText from "@mui/material/FormHelperText"; +import Switch from "@mui/material/Switch"; + +export type AddCustomS3ConfigDialogProps = { + evtOpen: NonPostableEvt<{ + creationTimeOfS3ProfileToEdit: number | undefined; + }>; +}; + +export const AddCustomS3ConfigDialog = memo((props: AddCustomS3ConfigDialogProps) => { + const { evtOpen } = props; + + const { t } = useTranslation({ AddCustomS3ConfigDialog }); + + const { + functions: { s3ProfilesCreationUiController } + } = getCoreSync(); + + const { isReady } = useCoreState("s3ProfilesCreationUiController", "main"); + + useEvt( + ctx => + evtOpen.attach(ctx, ({ creationTimeOfS3ProfileToEdit }) => + s3ProfilesCreationUiController.initialize({ + creationTimeOfS3ProfileToEdit + }) + ), + [evtOpen] + ); + + const onCloseFactory = useCallbackFactory(([isSubmit]: [boolean]) => { + if (isSubmit) { + s3ProfilesCreationUiController.submit(); + } else { + s3ProfilesCreationUiController.reset(); + } + }); + + const { classes } = useStyles(); + + return ( + } + buttons={ + + } + onClose={onCloseFactory(false)} + /> + ); +}); + +AddCustomS3ConfigDialog.displayName = symToStr({ + AddCustomS3ConfigDialog +}); + +const useStyles = tss.withName({ AddCustomS3ConfigDialog }).create({ + buttons: { + display: "flex" + } +}); + +type ButtonsProps = { + onCloseCancel: () => void; + onCloseSubmit: () => void; +}; + +const Buttons = memo((props: ButtonsProps) => { + const { onCloseCancel, onCloseSubmit } = props; + + const { + isReady, + credentialsTestStatus, + isFormSubmittable, + isEditionOfAnExistingConfig + } = useCoreState("s3ProfilesCreationUiController", "main"); + + const { + functions: { s3ProfilesCreationUiController } + } = getCoreSync(); + + const { css } = useButtonsStyles(); + + const { t } = useTranslation({ AddCustomS3ConfigDialog }); + + if (!isReady) { + return null; + } + + return ( + <> + +
+ + + + ); +}); + +const useButtonsStyles = tss + .withName(`${symToStr({ AddCustomS3ConfigDialog })}${symToStr({ Buttons })}`) + .create({}); + +const Body = memo(() => { + const { isReady, formValues, formValuesErrors, urlStylesExamples } = useCoreState( + "s3ProfilesCreationUiController", + "main" + ); + + const { + functions: { s3ConfigCreation } + } = getCoreSync(); + + const { classes, css, theme } = useBodyStyles(); + + const { t } = useTranslation({ AddCustomS3ConfigDialog }); + + if (!isReady) { + return null; + } + + return ( + <> + + + s3ConfigCreation.changeValue({ + key: "friendlyName", + value + }) + } + /> + + s3ConfigCreation.changeValue({ + key: "url", + value + }) + } + /> + + s3ConfigCreation.changeValue({ + key: "region", + value + }) + } + /> + + {t("url style")} + + {t("url style helper text")} + + + s3ConfigCreation.changeValue({ + key: "pathStyleAccess", + value: value === "path" + }) + } + > + } + label={t("path style label", { + example: urlStylesExamples?.pathStyle + })} + /> + } + label={t("virtual-hosted style label", { + example: urlStylesExamples?.virtualHostedStyle + })} + /> + + + + + + {t("account credentials")} + + + + + s3ConfigCreation.changeValue({ + key: "isAnonymous", + value: isChecked + }) + } + /> + } + label={t("isAnonymous switch label")} + /> + + {t("isAnonymous switch helper text")} + + {!formValues.isAnonymous && ( + <> + + s3ConfigCreation.changeValue({ + key: "accessKeyId", + value: value || undefined + }) + } + /> + + s3ConfigCreation.changeValue({ + key: "secretAccessKey", + value: value || undefined + }) + } + /> + + s3ConfigCreation.changeValue({ + key: "sessionToken", + value: value || undefined + }) + } + /> + + )} + + + ); +}); + +const useBodyStyles = tss + .withName(`${symToStr({ AddCustomS3ConfigDialog })}${symToStr({ Body })}`) + .create(({ theme }) => ({ + serverConfigFormGroup: { + display: "flex", + flexDirection: "column", + overflow: "visible", + gap: theme.spacing(6), + marginBottom: theme.spacing(4) + }, + accountCredentialsFormGroup: { + borderRadius: 5, + padding: theme.spacing(3), + + backgroundColor: theme.colors.useCases.surfaces.surface1, + boxShadow: theme.shadows[3], + "&:hover": { + boxShadow: theme.shadows[6] + } + } + })); + +const { i18n } = declareComponentKeys< + | "dialog title" + | "dialog subtitle" + | "cancel" + | "save config" + | "update config" + | "is required" + | "must be an url" + | "not a valid access key id" + | "url textField label" + | "url textField helper text" + | "region textField label" + | "region textField helper text" + | "workingDirectoryPath textField label" + | { + K: "workingDirectoryPath textField helper text"; + R: JSX.Element; + } + | "account credentials" + | "friendlyName textField label" + | "friendlyName textField helper text" + | "isAnonymous switch label" + | "isAnonymous switch helper text" + | "accessKeyId textField label" + | "accessKeyId textField helper text" + | "secretAccessKey textField label" + | "sessionToken textField label" + | "sessionToken textField helper text" + | "url style" + | "url style helper text" + | { + K: "path style label"; + P: { example: string | undefined }; + R: JSX.Element; + } + | { + K: "virtual-hosted style label"; + P: { example: string | undefined }; + R: JSX.Element; + } +>()({ AddCustomS3ConfigDialog }); +export type I18n = typeof i18n; diff --git a/web/src/ui/pages/s3Explorer/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog.tsx b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog.tsx new file mode 100644 index 000000000..b3295584b --- /dev/null +++ b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog.tsx @@ -0,0 +1,59 @@ +import { useState, memo } from "react"; +import { Dialog } from "onyxia-ui/Dialog"; +import { Button } from "onyxia-ui/Button"; +import { symToStr } from "tsafe/symToStr"; +import { useCallbackFactory } from "powerhooks/useCallbackFactory"; +import { assert } from "tsafe/assert"; +import type { NonPostableEvt, UnpackEvt } from "evt"; +import { useEvt } from "evt/hooks"; + +export type Props = { + evtOpen: NonPostableEvt<{ + resolveDoProceed: (doProceed: boolean) => void; + }>; +}; + +export const ConfirmCustomS3ConfigDeletionDialog = memo((props: Props) => { + const { evtOpen } = props; + + const [state, setState] = useState | undefined>( + undefined + ); + + useEvt( + ctx => { + evtOpen.attach(ctx, ({ resolveDoProceed }) => setState({ resolveDoProceed })); + }, + [evtOpen] + ); + + const onCloseFactory = useCallbackFactory(([doProceed]: [boolean]) => { + assert(state !== undefined); + + state.resolveDoProceed(doProceed); + + setState(undefined); + }); + + return ( + + + + + } + isOpen={state !== undefined} + onClose={onCloseFactory(false)} + /> + ); +}); + +ConfirmCustomS3ConfigDeletionDialog.displayName = symToStr({ + ConfirmCustomS3ConfigDeletionDialog +}); diff --git a/web/src/ui/pages/s3Explorer/S3ConfigDialogs/S3ConfigDialogs.tsx b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/S3ConfigDialogs.tsx new file mode 100644 index 000000000..6580ca930 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/S3ConfigDialogs.tsx @@ -0,0 +1,27 @@ +import { + ConfirmCustomS3ConfigDeletionDialog, + type Props as ConfirmCustomS3ConfigDeletionDialogProps +} from "./ConfirmCustomS3ConfigDeletionDialog"; +import { + AddCustomS3ConfigDialog, + type AddCustomS3ConfigDialogProps +} from "./AddCustomS3ConfigDialog"; + +export type S3ConfigDialogsProps = { + evtConfirmCustomS3ConfigDeletionDialogOpen: ConfirmCustomS3ConfigDeletionDialogProps["evtOpen"]; + evtAddCustomS3ConfigDialogOpen: AddCustomS3ConfigDialogProps["evtOpen"]; +}; + +export function S3ConfigDialogs(props: S3ConfigDialogsProps) { + const { evtConfirmCustomS3ConfigDeletionDialogOpen, evtAddCustomS3ConfigDialogOpen } = + props; + + return ( + <> + + + + ); +} diff --git a/web/src/ui/pages/s3Explorer/S3ConfigDialogs/TestS3ConnectionButton.tsx b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/TestS3ConnectionButton.tsx new file mode 100644 index 000000000..e9cebb5da --- /dev/null +++ b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/TestS3ConnectionButton.tsx @@ -0,0 +1,109 @@ +import { Button } from "onyxia-ui/Button"; +import type { S3Profile } from "core/usecases/_s3Next/s3ProfilesManagement"; +import { tss } from "tss"; +import { declareComponentKeys, useTranslation } from "ui/i18n"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { getIconUrlByName } from "lazy-icons"; +import { Icon } from "onyxia-ui/Icon"; +import Tooltip from "@mui/material/Tooltip"; +import { assert, type Equals } from "tsafe/assert"; + +export type Props = { + className?: string; + credentialsTestStatus: S3Profile.CreatedByUser["credentialsTestStatus"]; + onTestConnection: (() => void) | undefined; +}; + +export function TestS3ConnectionButton(props: Props) { + const { className, credentialsTestStatus, onTestConnection } = props; + + const { cx, classes, css, theme } = useStyles(); + + const { t } = useTranslation({ TestS3ConnectionButton }); + + return ( +
+ + {(() => { + if (credentialsTestStatus.status === "test ongoing") { + return ; + } + + switch (credentialsTestStatus.status) { + case "not tested": + return null; + case "test succeeded": + return ( + + ); + case "test failed": + return ( + <> + + + + + ); + } + assert>(false); + })()} +
+ ); +} + +const useStyles = tss.withName({ TestS3ConnectionButton }).create(({ theme }) => ({ + root: { + display: "flex", + alignItems: "center", + gap: theme.spacing(3) + }, + icon: { + fontSize: "inherit", + ...(() => { + const factor = 1.6; + return { width: `${factor}em`, height: `${factor}em` }; + })() + } +})); + +const { i18n } = declareComponentKeys< + | "test connection" + | { + K: "test connection failed"; + P: { errorMessage: string }; + R: JSX.Element; + } +>()({ TestS3ConnectionButton }); +export type I18n = typeof i18n; diff --git a/web/src/ui/pages/s3Explorer/S3ConfigDialogs/index.ts b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/index.ts new file mode 100644 index 000000000..ca3ec4149 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/index.ts @@ -0,0 +1 @@ +export * from "./S3ConfigDialogs"; From a3d6ab0f0ab6ca013536d9bcb3dc06ad02f27ba2 Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 3 Dec 2025 16:56:29 +0100 Subject: [PATCH 32/41] Link explorer to new s3 impl --- web/src/core/bootstrap.ts | 8 +- .../s3ExplorerRootUiController/thunks.ts | 10 +- .../_s3Next/s3ProfilesManagement/thunks.ts | 94 +++++++++---------- web/src/core/usecases/fileExplorer/thunks.ts | 22 ++--- 4 files changed, 71 insertions(+), 63 deletions(-) diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index 5ad93b4f2..d170d9b5f 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -157,7 +157,7 @@ export async function bootstrapCore( } const result = await dispatch( - usecases.s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + usecases.s3ProfilesManagement.protectedThunks.getS3ConfigAndClientForExplorer() ); if (result === undefined) { @@ -166,12 +166,12 @@ export async function bootstrapCore( }; } - const { s3Config, s3Client } = result; + const { s3Profile, s3Client } = result; return { s3Client, - s3_endpoint: s3Config.paramsOfCreateS3Client.url, - s3_url_style: s3Config.paramsOfCreateS3Client.pathStyleAccess + s3_endpoint: s3Profile.paramsOfCreateS3Client.url, + s3_url_style: s3Profile.paramsOfCreateS3Client.pathStyleAccess ? "path" : "vhost", s3_region: s3Config.region diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts index 3904f6f38..14467b706 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts @@ -68,11 +68,19 @@ export const thunks = { }, updateSelectedS3Profile: (params: { s3ProfileId: string }) => - (...args) => { + async (...args) => { const [dispatch] = args; const { s3ProfileId } = params; + await dispatch( + s3ProfilesManagement.protectedThunks.changeIsDefault({ + s3ProfileId, + usecase: "explorer", + value: true + }) + ); + dispatch( actions.selectedS3ProfileUpdated({ s3ProfileId diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts index 700a3ab05..67cc788ef 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts @@ -94,53 +94,6 @@ export const thunks = { value: projectConfigs_s3 }) ); - }, - changeIsDefault: - (params: { - s3ProfileId: string; - usecase: "defaultXOnyxia" | "explorer"; - value: boolean; - }) => - async (...args) => { - const { s3ProfileId, usecase, value } = params; - - const [dispatch, getState] = args; - - const fromVault = structuredClone( - projectManagement.protectedSelectors.projectConfig(getState()).s3 - ); - - const propertyName = (() => { - switch (usecase) { - case "defaultXOnyxia": - return "s3ConfigId_defaultXOnyxia"; - case "explorer": - return "s3ConfigId_explorer"; - } - })(); - - { - const s3ProfileId_currentDefault = fromVault[propertyName]; - - if (value) { - if (s3ProfileId_currentDefault === s3ProfileId) { - return; - } - } else { - if (s3ProfileId_currentDefault !== s3ProfileId) { - return; - } - } - } - - fromVault[propertyName] = value ? s3ProfileId : undefined; - - await dispatch( - projectManagement.protectedThunks.updateConfigValue({ - key: "s3", - value: fromVault - }) - ); } } satisfies Thunks; @@ -439,6 +392,53 @@ export const protectedThunks = { break; } }, + changeIsDefault: + (params: { + s3ProfileId: string; + usecase: "defaultXOnyxia" | "explorer"; + value: boolean; + }) => + async (...args) => { + const { s3ProfileId, usecase, value } = params; + + const [dispatch, getState] = args; + + const fromVault = structuredClone( + projectManagement.protectedSelectors.projectConfig(getState()).s3 + ); + + const propertyName = (() => { + switch (usecase) { + case "defaultXOnyxia": + return "s3ConfigId_defaultXOnyxia"; + case "explorer": + return "s3ConfigId_explorer"; + } + })(); + + { + const s3ProfileId_currentDefault = fromVault[propertyName]; + + if (value) { + if (s3ProfileId_currentDefault === s3ProfileId) { + return; + } + } else { + if (s3ProfileId_currentDefault !== s3ProfileId) { + return; + } + } + } + + fromVault[propertyName] = value ? s3ProfileId : undefined; + + await dispatch( + projectManagement.protectedThunks.updateConfigValue({ + key: "s3", + value: fromVault + }) + ); + }, initialize: () => async (...args) => { diff --git a/web/src/core/usecases/fileExplorer/thunks.ts b/web/src/core/usecases/fileExplorer/thunks.ts index 97466df1f..f24ac7ca3 100644 --- a/web/src/core/usecases/fileExplorer/thunks.ts +++ b/web/src/core/usecases/fileExplorer/thunks.ts @@ -6,7 +6,7 @@ import { name, actions } from "./state"; import { protectedSelectors } from "./selectors"; import { join as pathJoin, basename as pathBasename } from "pathe"; import { crawlFactory } from "core/tools/crawl"; -import * as s3ConfigManagement from "core/usecases/s3ConfigManagement"; +import * as s3ProfileManagement from "core/usecases/_s3Next/s3ProfilesManagement"; import type { S3Object } from "core/ports/S3Client"; import { formatDuration } from "core/tools/timeFormat/formatDuration"; import { relative as pathRelative } from "pathe"; @@ -155,7 +155,7 @@ const privateThunks = { ); const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -215,7 +215,7 @@ const privateThunks = { const { s3Object } = params; const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -257,7 +257,7 @@ const privateThunks = { const { s3Objects } = params; const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -458,7 +458,7 @@ const privateThunks = { ); const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -554,7 +554,7 @@ export const thunks = { }) ); const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -787,7 +787,7 @@ export const thunks = { ); const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -888,7 +888,7 @@ export const thunks = { ); const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -923,8 +923,8 @@ export const thunks = { assert(directoryPath !== undefined); - const { s3Client, s3Config } = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + const { s3Client, s3Profile } = await dispatch( + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); return r; @@ -940,7 +940,7 @@ export const thunks = { dispatch( actions.shareOpened({ fileBasename, - url: `${s3Config.paramsOfCreateS3Client.url}/${pathJoin(directoryPath, fileBasename)}`, + url: `${s3Profile.paramsOfCreateS3Client.url}/${pathJoin(directoryPath, fileBasename)}`, validityDurationSecondOptions: undefined }) ); From 78bbc8b339e32661a554c2069a5657c000eac903 Mon Sep 17 00:00:00 2001 From: garronej Date: Mon, 8 Dec 2025 11:15:20 +0100 Subject: [PATCH 33/41] Use the new S3 logic in the launcher --- web/src/core/ports/OnyxiaApi/XOnyxia.ts | 11 --------- web/src/core/usecases/launcher/selectors.ts | 12 ++++++---- web/src/core/usecases/launcher/thunks.ts | 26 ++++++++------------- 3 files changed, 17 insertions(+), 32 deletions(-) diff --git a/web/src/core/ports/OnyxiaApi/XOnyxia.ts b/web/src/core/ports/OnyxiaApi/XOnyxia.ts index 275a85090..ed76d4d3a 100644 --- a/web/src/core/ports/OnyxiaApi/XOnyxia.ts +++ b/web/src/core/ports/OnyxiaApi/XOnyxia.ts @@ -115,19 +115,8 @@ export type XOnyxiaContext = { AWS_SESSION_TOKEN: string | undefined; AWS_DEFAULT_REGION: string; AWS_S3_ENDPOINT: string; - AWS_BUCKET_NAME: string; port: number; pathStyleAccess: boolean; - /** - * The user is assumed to have read/write access on every - * object starting with this prefix on the bucket - **/ - objectNamePrefix: string; - /** - * Only for making it easier for charts editors. - * / - * */ - workingDirectoryPath: string; /** * If true the bucket's (directory) should be accessible without any credentials. * In this case s3.AWS_ACCESS_KEY_ID, s3.AWS_SECRET_ACCESS_KEY and s3.AWS_SESSION_TOKEN diff --git a/web/src/core/usecases/launcher/selectors.ts b/web/src/core/usecases/launcher/selectors.ts index 546d4a0ac..2f9400a50 100644 --- a/web/src/core/usecases/launcher/selectors.ts +++ b/web/src/core/usecases/launcher/selectors.ts @@ -7,7 +7,7 @@ import * as projectManagement from "core/usecases/projectManagement"; import * as userConfigs from "core/usecases/userConfigs"; import { exclude } from "tsafe/exclude"; import { createSelector } from "clean-architecture"; -import * as s3ConfigManagement from "core/usecases/s3ConfigManagement"; +import * as s3ConfigManagement from "core/usecases/_s3Next/s3ProfilesManagement"; import { id } from "tsafe/id"; import { computeRootForm } from "./decoupledLogic"; import { computeDiff } from "core/tools/Stringifyable"; @@ -155,7 +155,7 @@ const chartVersion = createSelector(readyState, state => { }); const s3ConfigSelect = createSelector( - s3ConfigManagement.selectors.s3Configs, + s3ConfigManagement.selectors.s3Profiles, isReady, projectManagement.selectors.canInjectPersonalInfos, createSelector(readyState, state => { @@ -177,7 +177,7 @@ const s3ConfigSelect = createSelector( } const availableConfigs = s3Configs.filter( - config => canInjectPersonalInfos || config.origin !== "deploymentRegion" + config => canInjectPersonalInfos || config.origin !== "defined in region" ); // We don't display the s3 config selector if there is no config or only one @@ -189,9 +189,11 @@ const s3ConfigSelect = createSelector( options: availableConfigs.map(s3Config => ({ optionValue: s3Config.id, label: { - dataSource: s3Config.dataSource, + dataSource: new URL(s3Config.paramsOfCreateS3Client.url).hostname, friendlyName: - s3Config.origin === "project" ? s3Config.friendlyName : undefined + s3Config.origin === "created by user (or group project member)" + ? s3Config.friendlyName + : undefined } })), selectedOptionValue: s3Config.s3ConfigId diff --git a/web/src/core/usecases/launcher/thunks.ts b/web/src/core/usecases/launcher/thunks.ts index 576a0238c..c8ebf7961 100644 --- a/web/src/core/usecases/launcher/thunks.ts +++ b/web/src/core/usecases/launcher/thunks.ts @@ -3,7 +3,7 @@ import { assert, type Equals, is } from "tsafe/assert"; import * as userAuthentication from "../userAuthentication"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import * as projectManagement from "core/usecases/projectManagement"; -import * as s3ConfigManagement from "core/usecases/s3ConfigManagement"; +import * as s3ConfigManagement from "core/usecases/_s3Next/s3ProfilesManagement"; import * as userConfigsUsecase from "core/usecases/userConfigs"; import * as userProfileForm from "core/usecases/userProfileForm"; import { parseUrl } from "core/tools/parseUrl"; @@ -18,7 +18,6 @@ import { createUsecaseContextApi } from "clean-architecture"; import { computeHelmValues, type FormFieldValue } from "./decoupledLogic"; import { computeRootForm } from "./decoupledLogic"; import type { DeepPartial } from "core/tools/DeepPartial"; -import { parseS3UriPrefix } from "core/tools/S3Uri"; type RestorableServiceConfig = projectManagement.ProjectConfigs.RestorableServiceConfig; @@ -174,9 +173,12 @@ export const thunks = { const { s3ConfigId, s3ConfigId_default } = (() => { const s3Configs = s3ConfigManagement.selectors - .s3Configs(getState()) + .s3Profiles(getState()) .filter(s3Config => - doInjectPersonalInfos ? true : s3Config.origin === "project" + doInjectPersonalInfos + ? true + : s3Config.origin === + "created by user (or group project member)" ); const s3ConfigId_default = (() => { @@ -681,7 +683,7 @@ export const protectedThunks = { } const s3Configs = - s3ConfigManagement.selectors.s3Configs(getState()); + s3ConfigManagement.selectors.s3Profiles(getState()); const s3Config = s3Configs.find( s3Config => s3Config.id === s3ConfigId @@ -701,24 +703,16 @@ export const protectedThunks = { ? parseUrl(s3Config.paramsOfCreateS3Client.url) : {}; - const { bucket: bucketName, keyPrefix: objectNamePrefix } = - parseS3UriPrefix({ - s3UriPrefix: `s3://${s3Config.workingDirectoryPath}`, - strict: false - }); - const s3: XOnyxiaContext["s3"] = { isEnabled: true, AWS_ACCESS_KEY_ID: undefined, AWS_SECRET_ACCESS_KEY: undefined, AWS_SESSION_TOKEN: undefined, - AWS_BUCKET_NAME: bucketName, - AWS_DEFAULT_REGION: s3Config.region ?? "us-east-1", + AWS_DEFAULT_REGION: + s3Config.paramsOfCreateS3Client.region ?? "us-east-1", AWS_S3_ENDPOINT: host, port, pathStyleAccess: s3Config.paramsOfCreateS3Client.pathStyleAccess, - objectNamePrefix, - workingDirectoryPath: s3Config.workingDirectoryPath, isAnonymous: false }; @@ -726,7 +720,7 @@ export const protectedThunks = { const s3Client = await dispatch( s3ConfigManagement.protectedThunks.getS3ClientForSpecificConfig( { - s3ConfigId: s3Config.id + s3ProfileId: s3Config.id } ) ); From 21b023a7612144fcd229221f1c4ccabac7451485 Mon Sep 17 00:00:00 2001 From: garronej Date: Mon, 8 Dec 2025 11:47:42 +0100 Subject: [PATCH 34/41] Remove connexion test --- .../_s3Next/s3CredentialsTest/index.ts | 3 - .../_s3Next/s3CredentialsTest/selectors.ts | 6 - .../_s3Next/s3CredentialsTest/state.ts | 97 ---------------- .../_s3Next/s3CredentialsTest/thunks.ts | 49 -------- .../selectors.ts | 67 +---------- .../s3ProfilesCreationUiController/thunks.ts | 24 ---- .../decoupledLogic/s3Profiles.ts | 49 +------- ...DefaultS3ProfilesAfterPotentialDeletion.ts | 4 - .../_s3Next/s3ProfilesManagement/selectors.ts | 6 +- .../_s3Next/s3ProfilesManagement/thunks.ts | 19 --- web/src/core/usecases/index.ts | 2 - .../AddCustomS3ConfigDialog.tsx | 23 +--- .../TestS3ConnectionButton.tsx | 109 ------------------ 13 files changed, 9 insertions(+), 449 deletions(-) delete mode 100644 web/src/core/usecases/_s3Next/s3CredentialsTest/index.ts delete mode 100644 web/src/core/usecases/_s3Next/s3CredentialsTest/selectors.ts delete mode 100644 web/src/core/usecases/_s3Next/s3CredentialsTest/state.ts delete mode 100644 web/src/core/usecases/_s3Next/s3CredentialsTest/thunks.ts delete mode 100644 web/src/ui/pages/s3Explorer/S3ConfigDialogs/TestS3ConnectionButton.tsx diff --git a/web/src/core/usecases/_s3Next/s3CredentialsTest/index.ts b/web/src/core/usecases/_s3Next/s3CredentialsTest/index.ts deleted file mode 100644 index 3f3843384..000000000 --- a/web/src/core/usecases/_s3Next/s3CredentialsTest/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./state"; -export * from "./selectors"; -export * from "./thunks"; diff --git a/web/src/core/usecases/_s3Next/s3CredentialsTest/selectors.ts b/web/src/core/usecases/_s3Next/s3CredentialsTest/selectors.ts deleted file mode 100644 index 1d7233b0f..000000000 --- a/web/src/core/usecases/_s3Next/s3CredentialsTest/selectors.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { State as RootState } from "core/bootstrap"; -import { name } from "./state"; - -export const protectedSelectors = { - credentialsTestState: (rootState: RootState) => rootState[name] -}; diff --git a/web/src/core/usecases/_s3Next/s3CredentialsTest/state.ts b/web/src/core/usecases/_s3Next/s3CredentialsTest/state.ts deleted file mode 100644 index c3ad1a485..000000000 --- a/web/src/core/usecases/_s3Next/s3CredentialsTest/state.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { createUsecaseActions } from "clean-architecture"; -import { id } from "tsafe/id"; -import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; -import { same } from "evt/tools/inDepth/same"; - -export type State = { - testResults: State.TestResult[]; - ongoingTests: State.OngoingTest[]; -}; - -export namespace State { - export type OngoingTest = { - paramsOfCreateS3Client: ParamsOfCreateS3Client; - }; - - export type TestResult = { - paramsOfCreateS3Client: ParamsOfCreateS3Client; - result: - | { - isSuccess: true; - } - | { - isSuccess: false; - errorMessage: string; - }; - }; -} - -export const name = "s3CredentialsTest"; - -export const { actions, reducer } = createUsecaseActions({ - name, - initialState: id({ - testResults: [], - ongoingTests: [] - }), - reducers: { - testStarted: ( - state, - { - payload - }: { - payload: State["ongoingTests"][number]; - } - ) => { - const { paramsOfCreateS3Client } = payload; - - if ( - state.ongoingTests.find(e => same(e, { paramsOfCreateS3Client })) !== - undefined - ) { - return; - } - - state.ongoingTests.push({ paramsOfCreateS3Client }); - }, - testCompleted: ( - state, - { - payload - }: { - payload: State["testResults"][number]; - } - ) => { - const { paramsOfCreateS3Client, result } = payload; - - remove_from_ongoing: { - const entry = state.ongoingTests.find(e => - same(e, { paramsOfCreateS3Client }) - ); - - if (entry === undefined) { - break remove_from_ongoing; - } - - state.ongoingTests.splice(state.ongoingTests.indexOf(entry), 1); - } - - remove_existing_result: { - const entry = state.testResults.find(e => - same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) - ); - - if (entry === undefined) { - break remove_existing_result; - } - - state.testResults.splice(state.testResults.indexOf(entry), 1); - } - - state.testResults.push({ - paramsOfCreateS3Client, - result - }); - } - } -}); diff --git a/web/src/core/usecases/_s3Next/s3CredentialsTest/thunks.ts b/web/src/core/usecases/_s3Next/s3CredentialsTest/thunks.ts deleted file mode 100644 index db7c5c165..000000000 --- a/web/src/core/usecases/_s3Next/s3CredentialsTest/thunks.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { Thunks } from "core/bootstrap"; -import { actions } from "./state"; -import { assert } from "tsafe/assert"; - -import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; - -export const thunks = {} satisfies Thunks; - -export const protectedThunks = { - testS3Credentials: - (params: { paramsOfCreateS3Client: ParamsOfCreateS3Client }) => - async (...args) => { - const { paramsOfCreateS3Client } = params; - - const [dispatch] = args; - - dispatch(actions.testStarted({ paramsOfCreateS3Client })); - - const result = await (async () => { - const { createS3Client } = await import("core/adapters/s3Client"); - - const getOidc = () => { - // TODO: Fix, since we allow testing sts connection - assert(false); - }; - - const s3Client = createS3Client(paramsOfCreateS3Client, getOidc); - - try { - console.log("Find a way to test only s3 credential", s3Client); - throw new Error("TODO: Not implemented yet"); - } catch (error) { - return { - isSuccess: false as const, - errorMessage: String(error) - }; - } - - return { isSuccess: true as const }; - })(); - - dispatch( - actions.testCompleted({ - paramsOfCreateS3Client, - result - }) - ); - } -} satisfies Thunks; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts index 2587cdbc2..39a22c86a 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts @@ -6,8 +6,6 @@ import { assert } from "tsafe/assert"; import { id } from "tsafe/id"; import type { ProjectConfigs } from "core/usecases/projectManagement"; import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; -import * as s3CredentialsTest from "core/usecases/_s3Next/s3CredentialsTest"; -import { same } from "evt/tools/inDepth/same"; const readyState = (rootState: RootState) => { const state = rootState[name]; @@ -199,63 +197,6 @@ const paramsOfCreateS3Client = createSelector( } ); -type CredentialsTestStatus = - | { status: "test ongoing" } - | { status: "test succeeded" } - | { status: "test failed"; errorMessage: string } - | { status: "not tested" }; - -const credentialsTestStatus = createSelector( - isReady, - isFormSubmittable, - paramsOfCreateS3Client, - s3CredentialsTest.protectedSelectors.credentialsTestState, - ( - isReady, - isFormSubmittable, - paramsOfCreateS3Client, - credentialsTestState - ): CredentialsTestStatus | null => { - if (!isReady) { - return null; - } - - assert(isFormSubmittable !== null); - assert(paramsOfCreateS3Client !== null); - - if (!isFormSubmittable) { - return { status: "not tested" }; - } - - assert(paramsOfCreateS3Client !== undefined); - - if ( - credentialsTestState.ongoingTests.find(e => - same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) - ) !== undefined - ) { - return { status: "test ongoing" }; - } - - has_result: { - const { result } = - credentialsTestState.testResults.find(e => - same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) - ) ?? {}; - - if (result === undefined) { - break has_result; - } - - return result.isSuccess - ? { status: "test succeeded" } - : { status: "test failed", errorMessage: result.errorMessage }; - } - - return { status: "not tested" } as CredentialsTestStatus; - } -); - const urlStylesExamples = createSelector( isReady, formattedFormValuesUrl, @@ -301,15 +242,13 @@ const main = createSelector( isFormSubmittable, urlStylesExamples, isEditionOfAnExistingConfig, - credentialsTestStatus, ( isReady, formValues, formValuesErrors, isFormSubmittable, urlStylesExamples, - isEditionOfAnExistingConfig, - credentialsTestStatus + isEditionOfAnExistingConfig ) => { if (!isReady) { return { @@ -322,7 +261,6 @@ const main = createSelector( assert(isFormSubmittable !== null); assert(urlStylesExamples !== null); assert(isEditionOfAnExistingConfig !== null); - assert(credentialsTestStatus !== null); return { isReady: true, @@ -330,8 +268,7 @@ const main = createSelector( formValuesErrors, isFormSubmittable, urlStylesExamples, - isEditionOfAnExistingConfig, - credentialsTestStatus + isEditionOfAnExistingConfig }; } ); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts index 54395f7f5..214f838c8 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts @@ -4,7 +4,6 @@ import { assert } from "tsafe/assert"; import { privateSelectors } from "./selectors"; import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; -import * as s3CredentialsTest from "core/usecases/_s3Next/s3CredentialsTest"; export const thunks = { initialize: @@ -185,28 +184,5 @@ export const thunks = { break preset_pathStyleAccess; } } - }, - testCredentials: - () => - async (...args) => { - const [dispatch, getState] = args; - - const projectS3Config = - privateSelectors.submittableFormValuesAsProjectS3Config(getState()); - - assert(projectS3Config !== null); - assert(projectS3Config !== undefined); - - await dispatch( - s3CredentialsTest.protectedThunks.testS3Credentials({ - paramsOfCreateS3Client: { - isStsEnabled: false, - url: projectS3Config.url, - pathStyleAccess: projectS3Config.pathStyleAccess, - region: projectS3Config.region, - credentials: projectS3Config.credentials - } - }) - ); } } satisfies Thunks; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts index b9861e635..3539a14a4 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -1,10 +1,8 @@ import * as projectManagement from "core/usecases/projectManagement"; import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; -import { same } from "evt/tools/inDepth/same"; import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; import { assert, type Equals } from "tsafe"; -import type * as s3CredentialsTest from "core/usecases/_s3Next/s3CredentialsTest"; import type { LocalizedString } from "core/ports/OnyxiaApi"; import type { ResolvedTemplateBookmark } from "./resolveTemplatedBookmark"; import type { ResolvedTemplateStsRole } from "./resolveTemplatedStsRole"; @@ -18,11 +16,6 @@ export namespace S3Profile { id: string; isXOnyxiaDefault: boolean; isExplorerConfig: boolean; - credentialsTestStatus: - | { status: "not tested" } - | { status: "test ongoing" } - | { status: "test failed"; errorMessage: string } - | { status: "test succeeded" }; bookmarks: Bookmark[]; }; @@ -61,40 +54,8 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { stsRoles: ResolvedTemplateStsRole[]; }[]; }; - credentialsTestState: s3CredentialsTest.State; }): S3Profile[] { - const { fromVault, fromRegion, credentialsTestState } = params; - - const getCredentialsTestStatus = (params: { - paramsOfCreateS3Client: ParamsOfCreateS3Client; - }): S3Profile["credentialsTestStatus"] => { - const { paramsOfCreateS3Client } = params; - - if ( - credentialsTestState.ongoingTests.find(e => - same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) - ) !== undefined - ) { - return { status: "test ongoing" }; - } - - has_result: { - const { result } = - credentialsTestState.testResults.find(e => - same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) - ) ?? {}; - - if (result === undefined) { - break has_result; - } - - return result.isSuccess - ? { status: "test succeeded" } - : { status: "test failed", errorMessage: result.errorMessage }; - } - - return { status: "not tested" }; - }; + const { fromVault, fromRegion } = params; const s3Profiles: S3Profile[] = [ ...fromVault.projectConfigs_s3.s3Configs @@ -126,10 +87,7 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { s3UriPrefixObj, isReadonly: false }) - ), - credentialsTestStatus: getCredentialsTestStatus({ - paramsOfCreateS3Client - }) + ) }; }) .sort((a, b) => b.creationTime - a.creationTime), @@ -225,9 +183,6 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { })) ], paramsOfCreateS3Client, - credentialsTestStatus: getCredentialsTestStatus({ - paramsOfCreateS3Client - }), isXOnyxiaDefault: false, isExplorerConfig: false }; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts index dd0d90bda..da93c41c7 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts @@ -30,10 +30,6 @@ export function updateDefaultS3ProfilesAfterPotentialDeletion(params: { fromVault: { projectConfigs_s3: fromVault.projectConfigs_s3, userConfigs_s3BookmarksStr: null - }, - credentialsTestState: { - ongoingTests: [], - testResults: [] } }); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts index cc2080306..eef3feb18 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts @@ -1,7 +1,6 @@ import { createSelector } from "clean-architecture"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import * as projectManagement from "core/usecases/projectManagement"; -import * as s3CredentialsTest from "core/usecases/_s3Next/s3CredentialsTest"; import * as userConfigs from "core/usecases/userConfigs"; import { type S3Profile, @@ -36,14 +35,12 @@ const s3Profiles = createSelector( ), resolvedTemplatedBookmarks, resolvedTemplatedStsRoles, - s3CredentialsTest.protectedSelectors.credentialsTestState, userConfigs_s3BookmarksStr, ( projectConfigs_s3, s3Profiles_region, resolvedTemplatedBookmarks, resolvedTemplatedStsRoles, - credentialsTestState, userConfigs_s3BookmarksStr ): S3Profile[] => aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ @@ -55,8 +52,7 @@ const s3Profiles = createSelector( s3Profiles: s3Profiles_region, resolvedTemplatedBookmarks, resolvedTemplatedStsRoles - }, - credentialsTestState + } }) ); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts index 67cc788ef..8d9bdbb82 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts @@ -4,7 +4,6 @@ import * as projectManagement from "core/usecases/projectManagement"; import { assert } from "tsafe/assert"; import type { S3Client } from "core/ports/S3Client"; import { createUsecaseContextApi } from "clean-architecture"; -import * as s3CredentialsTest from "core/usecases/_s3Next/s3CredentialsTest"; import { updateDefaultS3ProfilesAfterPotentialDeletion } from "./decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion"; import structuredClone from "@ungap/structured-clone"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; @@ -23,24 +22,6 @@ import { import * as userConfigs from "core/usecases/userConfigs"; export const thunks = { - testS3ProfileCredentials: - (params: { s3ProfileId: string }) => - async (...args) => { - const { s3ProfileId } = params; - const [dispatch, getState] = args; - - const s3Profiles = selectors.s3Profiles(getState()); - - const s3Profile = s3Profiles.find(s3Profile => s3Profile.id === s3ProfileId); - - assert(s3Profile !== undefined); - - await dispatch( - s3CredentialsTest.protectedThunks.testS3Credentials({ - paramsOfCreateS3Client: s3Profile.paramsOfCreateS3Client - }) - ); - }, deleteS3Config: (params: { s3ProfileCreationTime: number }) => async (...args) => { diff --git a/web/src/core/usecases/index.ts b/web/src/core/usecases/index.ts index 87d4157bc..7c2162826 100644 --- a/web/src/core/usecases/index.ts +++ b/web/src/core/usecases/index.ts @@ -25,7 +25,6 @@ import * as projectManagement from "./projectManagement"; import * as viewQuotas from "./viewQuotas"; import * as dataCollection from "./dataCollection"; -import * as s3CredentialsTest from "./_s3Next/s3CredentialsTest"; import * as s3ProfilesManagement from "./_s3Next/s3ProfilesManagement"; import * as s3ProfilesCreationUiController from "./_s3Next/s3ProfilesCreationUiController"; import * as s3ExplorerRootUiController from "./_s3Next/s3ExplorerRootUiController"; @@ -58,7 +57,6 @@ export const usecases = { viewQuotas, dataCollection, // Next - s3CredentialsTest, s3ProfilesManagement, s3ProfilesCreationUiController, s3ExplorerRootUiController diff --git a/web/src/ui/pages/s3Explorer/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx index ad59f2de8..c4eac135e 100644 --- a/web/src/ui/pages/s3Explorer/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx +++ b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx @@ -16,7 +16,6 @@ import { tss } from "tss"; import { useCoreState, getCoreSync } from "core"; import { declareComponentKeys, useTranslation } from "ui/i18n"; import { Text } from "onyxia-ui/Text"; -import { TestS3ConnectionButton } from "./TestS3ConnectionButton"; import FormHelperText from "@mui/material/FormHelperText"; import Switch from "@mui/material/Switch"; @@ -97,16 +96,10 @@ type ButtonsProps = { const Buttons = memo((props: ButtonsProps) => { const { onCloseCancel, onCloseSubmit } = props; - const { - isReady, - credentialsTestStatus, - isFormSubmittable, - isEditionOfAnExistingConfig - } = useCoreState("s3ProfilesCreationUiController", "main"); - - const { - functions: { s3ProfilesCreationUiController } - } = getCoreSync(); + const { isReady, isFormSubmittable, isEditionOfAnExistingConfig } = useCoreState( + "s3ProfilesCreationUiController", + "main" + ); const { css } = useButtonsStyles(); @@ -118,14 +111,6 @@ const Buttons = memo((props: ButtonsProps) => { return ( <> -
- {(() => { - if (credentialsTestStatus.status === "test ongoing") { - return ; - } - - switch (credentialsTestStatus.status) { - case "not tested": - return null; - case "test succeeded": - return ( - - ); - case "test failed": - return ( - <> - - - - - ); - } - assert>(false); - })()} -
- ); -} - -const useStyles = tss.withName({ TestS3ConnectionButton }).create(({ theme }) => ({ - root: { - display: "flex", - alignItems: "center", - gap: theme.spacing(3) - }, - icon: { - fontSize: "inherit", - ...(() => { - const factor = 1.6; - return { width: `${factor}em`, height: `${factor}em` }; - })() - } -})); - -const { i18n } = declareComponentKeys< - | "test connection" - | { - K: "test connection failed"; - P: { errorMessage: string }; - R: JSX.Element; - } ->()({ TestS3ConnectionButton }); -export type I18n = typeof i18n; From 504466a973ce0a2d894a7b92624b80a7718e5ae2 Mon Sep 17 00:00:00 2001 From: garronej Date: Sat, 20 Dec 2025 10:12:54 +0100 Subject: [PATCH 35/41] Lazily create bucket, not automatically --- web/package.json | 2 +- web/src/core/adapters/s3Client/s3Client.ts | 113 +++++++------ web/src/core/ports/S3Client.ts | 16 +- .../decoupledLogic/s3Profiles.ts | 3 +- web/src/core/usecases/fileExplorer/evt.ts | 38 +++++ web/src/core/usecases/fileExplorer/index.ts | 1 + .../core/usecases/fileExplorer/selectors.ts | 32 +++- web/src/core/usecases/fileExplorer/state.ts | 31 +++- web/src/core/usecases/fileExplorer/thunks.ts | 158 +++++++++++++++--- .../decoupledLogic/getS3Configs.ts | 7 +- web/src/ui/pages/fileExplorer/Page.tsx | 62 +++++-- .../ConfirmBucketCreationAttemptDialog.tsx | 132 +++++++++++++++ web/src/ui/pages/s3Explorer/Explorer.tsx | 132 ++++++++++----- web/yarn.lock | 8 +- 14 files changed, 574 insertions(+), 161 deletions(-) create mode 100644 web/src/core/usecases/fileExplorer/evt.ts create mode 100644 web/src/ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog.tsx diff --git a/web/package.json b/web/package.json index afffa6a5a..182215ee3 100644 --- a/web/package.json +++ b/web/package.json @@ -54,7 +54,7 @@ "async-mutex": "^0.5.0", "axios": "^1.9.0", "bytes": "^3.1.2", - "clean-architecture": "^6.0.3", + "clean-architecture": "^6.1.0", "codemirror": "6.0.1", "codemirror-json-schema": "0.7.9", "compare-versions": "^6.1.1", diff --git a/web/src/core/adapters/s3Client/s3Client.ts b/web/src/core/adapters/s3Client/s3Client.ts index 10b6c8c91..a8e1fbb23 100644 --- a/web/src/core/adapters/s3Client/s3Client.ts +++ b/web/src/core/adapters/s3Client/s3Client.ts @@ -51,8 +51,6 @@ export namespace ParamsOfCreateS3Client { roleSessionName: string; } | undefined; - // TODO: Remove this param - nameOfBucketToCreateIfNotExist: string | undefined; }; } @@ -223,52 +221,6 @@ export function createS3Client( return { getAwsS3Client }; })(); - // TODO: Remove this block - create_bucket: { - if (!params.isStsEnabled) { - break create_bucket; - } - - const { nameOfBucketToCreateIfNotExist } = params; - - if (nameOfBucketToCreateIfNotExist === undefined) { - break create_bucket; - } - - const { awsS3Client } = await getAwsS3Client(); - - const { CreateBucketCommand, BucketAlreadyExists, BucketAlreadyOwnedByYou } = - await import("@aws-sdk/client-s3"); - - try { - await awsS3Client.send( - new CreateBucketCommand({ - Bucket: nameOfBucketToCreateIfNotExist - }) - ); - } catch (error) { - assert(is(error)); - - if ( - !(error instanceof BucketAlreadyExists) && - !(error instanceof BucketAlreadyOwnedByYou) - ) { - console.log( - "An unexpected error occurred while creating the bucket, we ignore it:", - error - ); - break create_bucket; - } - - console.log( - [ - `The above network error is expected we tried creating the `, - `bucket ${nameOfBucketToCreateIfNotExist} in case it didn't exist but it did.` - ].join(" ") - ); - } - } - return { getNewlyRequestedOrCachedToken, clearCachedToken, getAwsS3Client }; })(); @@ -420,11 +372,22 @@ export function createS3Client( try { resp = await awsS3Client.send(listObjectsV2Command); } catch (error) { - if (!String(error).includes("Access Denied")) { - throw error; + const { NoSuchBucket, S3ServiceException } = await import( + "@aws-sdk/client-s3" + ); + + if (error instanceof NoSuchBucket) { + return { isSuccess: false, errorCase: "no such bucket" }; } - return { isAccessDenied: true }; + if ( + error instanceof S3ServiceException && + error.$metadata?.httpStatusCode === 403 + ) { + return { isSuccess: false, errorCase: "access denied" }; + } + + throw error; } Contents.push(...(resp.Contents ?? [])); @@ -466,7 +429,7 @@ export function createS3Client( ); return { - isAccessDenied: false, + isSuccess: true, objects: [...directories, ...files], bucketPolicy, isBucketPolicyAvailable @@ -695,6 +658,52 @@ export function createS3Client( ); return head.ContentType; + }, + createBucket: async ({ bucket }) => { + const { getAwsS3Client } = await prApi; + + const { awsS3Client } = await getAwsS3Client(); + + const { + CreateBucketCommand, + BucketAlreadyExists, + BucketAlreadyOwnedByYou, + S3ServiceException + } = await import("@aws-sdk/client-s3"); + + try { + await awsS3Client.send( + new CreateBucketCommand({ + Bucket: bucket + }) + ); + } catch (error) { + assert(is(error)); + + if ( + error instanceof S3ServiceException && + error.$metadata?.httpStatusCode === 403 + ) { + return { + isSuccess: false, + errorCase: "access denied", + errorMessage: error.message + }; + } + + if ( + !(error instanceof BucketAlreadyExists) && + !(error instanceof BucketAlreadyOwnedByYou) + ) { + return { + isSuccess: false, + errorCase: "already exist", + errorMessage: error.message + }; + } + } + + return { isSuccess: true }; } }; diff --git a/web/src/core/ports/S3Client.ts b/web/src/core/ports/S3Client.ts index 64121b93a..eb1734f30 100644 --- a/web/src/core/ports/S3Client.ts +++ b/web/src/core/ports/S3Client.ts @@ -35,9 +35,12 @@ export type S3Client = { * In charge of creating bucket if doesn't exist. */ listObjects: (params: { path: string }) => Promise< - | { isAccessDenied: true } | { - isAccessDenied: false; + isSuccess: false; + errorCase: "access denied" | "no such bucket"; + } + | { + isSuccess: true; objects: S3Object[]; bucketPolicy: S3BucketPolicy | undefined; isBucketPolicyAvailable: boolean; @@ -75,6 +78,15 @@ export type S3Client = { getFileContentType: (params: { path: string }) => Promise; + createBucket: (params: { bucket: string }) => Promise< + | { isSuccess: true } + | { + isSuccess: false; + errorCase: "already exist" | "access denied" | "unknown"; + errorMessage: string; + } + >; + // getPresignedUploadUrl: (params: { // path: string; // validityDurationSecond: number; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts index 3539a14a4..90417df44 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -116,8 +116,7 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { region: c.region, oidcParams: c.sts.oidcParams, durationSeconds: c.sts.durationSeconds, - role: resolvedTemplatedStsRole, - nameOfBucketToCreateIfNotExist: undefined + role: resolvedTemplatedStsRole }; const id = `region-${fnv1aHashToHex( diff --git a/web/src/core/usecases/fileExplorer/evt.ts b/web/src/core/usecases/fileExplorer/evt.ts new file mode 100644 index 000000000..cbc8c0b24 --- /dev/null +++ b/web/src/core/usecases/fileExplorer/evt.ts @@ -0,0 +1,38 @@ +import type { CreateEvt } from "core/bootstrap"; +import { Evt } from "evt"; +import { name } from "./state"; +import { protectedThunks } from "./thunks"; + +export const createEvt = (({ evtAction, dispatch }) => { + const evtOut = Evt.create<{ + action: "ask confirmation for bucket creation attempt"; + bucket: string; + createBucket: () => Promise<{ isSuccess: boolean }>; + }>(); + + const evtUsecaseAction = evtAction.pipe(action => action.usecaseName === name); + + evtUsecaseAction.$attach( + action => + action.actionName === "navigationCompleted" && + !action.payload.isSuccess && + action.payload.navigationError.errorCase === "no such bucket" && + action.payload.navigationError.shouldAttemptToCreate + ? [action.payload.navigationError] + : null, + ({ bucket, directoryPath }) => + evtOut.post({ + action: "ask confirmation for bucket creation attempt", + bucket, + createBucket: () => + dispatch( + protectedThunks.createBucket({ + bucket, + directoryPath_toNavigateToOnSuccess: directoryPath + }) + ) + }) + ); + + return evtOut; +}) satisfies CreateEvt; diff --git a/web/src/core/usecases/fileExplorer/index.ts b/web/src/core/usecases/fileExplorer/index.ts index 6e655c5cd..dd6008150 100644 --- a/web/src/core/usecases/fileExplorer/index.ts +++ b/web/src/core/usecases/fileExplorer/index.ts @@ -1,3 +1,4 @@ export * from "./state"; export * from "./thunks"; export * from "./selectors"; +export * from "./evt"; diff --git a/web/src/core/usecases/fileExplorer/selectors.ts b/web/src/core/usecases/fileExplorer/selectors.ts index d1783da3a..d55f3c32f 100644 --- a/web/src/core/usecases/fileExplorer/selectors.ts +++ b/web/src/core/usecases/fileExplorer/selectors.ts @@ -10,6 +10,7 @@ import { id } from "tsafe/id"; import type { S3Object } from "core/ports/S3Client"; import { join as pathJoin, relative as pathRelative } from "pathe"; import { getUploadProgress } from "./decoupledLogic/uploadProgress"; +import { parseS3UriPrefix } from "core/tools/S3Uri"; const state = (rootState: RootState): State => rootState[name]; @@ -70,20 +71,20 @@ export namespace CurrentWorkingDirectoryView { const currentWorkingDirectoryView = createSelector( createSelector(state, state => state.directoryPath), - createSelector(state, state => state.accessDenied_directoryPath), + createSelector(state, state => state.navigationError), createSelector(state, state => state.objects), createSelector(state, state => state.ongoingOperations), createSelector(state, state => state.s3FilesBeingUploaded), createSelector(state, state => state.isBucketPolicyAvailable), ( directoryPath, - accessDenied_directoryPath, + navigationError, objects, ongoingOperations, s3FilesBeingUploaded, isBucketPolicyAvailable ): CurrentWorkingDirectoryView | null => { - if (directoryPath === undefined || accessDenied_directoryPath !== undefined) { + if (directoryPath === undefined || navigationError !== undefined) { return null; } const items = [ @@ -305,7 +306,7 @@ const pathMinDepth = createSelector(workingDirectoryPath, workingDirectoryPath = }); const main = createSelector( - createSelector(state, state => state.accessDenied_directoryPath), + createSelector(state, state => state.navigationError), uploadProgress, commandLogsEntries, currentWorkingDirectoryView, @@ -315,7 +316,7 @@ const main = createSelector( shareView, isDownloadPreparing, ( - accessDenied_directoryPath, + navigationError, uploadProgress, commandLogsEntries, currentWorkingDirectoryView, @@ -328,7 +329,26 @@ const main = createSelector( if (currentWorkingDirectoryView === null) { return { isCurrentWorkingDirectoryLoaded: false as const, - accessDenied_directoryPath, + navigationError: (() => { + if (navigationError === undefined) { + return undefined; + } + switch (navigationError.errorCase) { + case "access denied": + return { + errorCase: navigationError.errorCase, + directoryPath: navigationError.directoryPath + } as const; + case "no such bucket": + return { + errorCase: navigationError.errorCase, + bucket: parseS3UriPrefix({ + s3UriPrefix: `s3://${navigationError.directoryPath}`, + strict: false + }).bucket + } as const; + } + })(), isNavigationOngoing, uploadProgress, commandLogsEntries, diff --git a/web/src/core/usecases/fileExplorer/state.ts b/web/src/core/usecases/fileExplorer/state.ts index 3d47e35a9..c6a3cb274 100644 --- a/web/src/core/usecases/fileExplorer/state.ts +++ b/web/src/core/usecases/fileExplorer/state.ts @@ -8,7 +8,12 @@ import type { S3FilesBeingUploaded } from "./decoupledLogic/uploadProgress"; //All explorer paths are expected to be absolute (start with /) export type State = { - accessDenied_directoryPath: string | undefined; + navigationError: + | { + errorCase: "access denied" | "no such bucket"; + directoryPath: string; + } + | undefined; directoryPath: string | undefined; viewMode: "list" | "block"; objects: S3Object[]; @@ -56,7 +61,7 @@ export const { reducer, actions } = createUsecaseActions({ }, isBucketPolicyAvailable: true, share: undefined, - accessDenied_directoryPath: undefined + navigationError: undefined }), reducers: { fileUploadStarted: ( @@ -124,11 +129,21 @@ export const { reducer, actions } = createUsecaseActions({ }: { payload: | { - isAccessDenied: true; - directoryPath: string; + isSuccess: false; + navigationError: + | { + errorCase: "access denied"; + directoryPath: string; + } + | { + errorCase: "no such bucket"; + directoryPath: string; + bucket: string; + shouldAttemptToCreate: boolean; + }; } | { - isAccessDenied: false; + isSuccess: true; directoryPath: string; objects: S3Object[]; bucketPolicy: S3BucketPolicy | undefined; @@ -136,15 +151,15 @@ export const { reducer, actions } = createUsecaseActions({ }; } ) => { - if (payload.isAccessDenied) { - state.accessDenied_directoryPath = payload.directoryPath; + if (!payload.isSuccess) { + state.navigationError = payload.navigationError; return; } const { directoryPath, objects, bucketPolicy, isBucketPolicyAvailable } = payload; - state.accessDenied_directoryPath = undefined; + state.navigationError = undefined; state.directoryPath = directoryPath; state.objects = objects; state.isNavigationOngoing = false; diff --git a/web/src/core/usecases/fileExplorer/thunks.ts b/web/src/core/usecases/fileExplorer/thunks.ts index f24ac7ca3..84d78b8be 100644 --- a/web/src/core/usecases/fileExplorer/thunks.ts +++ b/web/src/core/usecases/fileExplorer/thunks.ts @@ -1,4 +1,4 @@ -import { assert } from "tsafe/assert"; +import { assert, type Equals } from "tsafe/assert"; import { Evt } from "evt"; import { Zip, ZipPassThrough } from "fflate/browser"; import type { Thunks } from "core/bootstrap"; @@ -13,6 +13,7 @@ import { relative as pathRelative } from "pathe"; import { id } from "tsafe/id"; import { isAmong } from "tsafe/isAmong"; import { removeDuplicates } from "evt/tools/reducers/removeDuplicates"; +import { parseS3UriPrefix } from "core/tools/S3Uri"; const privateThunks = { startOperationWhenAllConflictingOperationHaveCompleted: @@ -116,7 +117,7 @@ const privateThunks = { if ( !doListAgainIfSamePath && getState()[name].directoryPath === directoryPath && - getState()[name].accessDenied_directoryPath === undefined + getState()[name].navigationError === undefined ) { return; } @@ -154,11 +155,11 @@ const privateThunks = { }) ); - const s3Client = await dispatch( + const { s3Client, s3Profile } = await dispatch( s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); - return r.s3Client; + return r; }); //const { objects, bucketPolicy, isBucketPolicyAvailable } = @@ -176,31 +177,80 @@ const privateThunks = { dispatch( actions.commandLogResponseReceived({ cmdId, - resp: listObjectResult.isAccessDenied - ? "Access Denied" - : listObjectResult.objects - .map(({ kind, basename }) => - kind === "directory" ? `${basename}/` : basename - ) - .join("\n") + resp: (() => { + if (listObjectResult.isSuccess) { + return listObjectResult.objects + .map(({ kind, basename }) => + kind === "directory" ? `${basename}/` : basename + ) + .join("\n"); + } + + switch (listObjectResult.errorCase) { + case "access denied": + return "Access Denied"; + case "no such bucket": + return "No Such Bucket"; + default: + assert>( + false + ); + } + })() }) ); dispatch( actions.navigationCompleted( - listObjectResult.isAccessDenied - ? { - isAccessDenied: true, - directoryPath - } - : { - isAccessDenied: false, - directoryPath, - objects: listObjectResult.objects, - bucketPolicy: listObjectResult.bucketPolicy, - isBucketPolicyAvailable: - listObjectResult.isBucketPolicyAvailable - } + (() => { + if (!listObjectResult.isSuccess) { + switch (listObjectResult.errorCase) { + case "access denied": + return { + isSuccess: false, + navigationError: { + directoryPath, + errorCase: "access denied" + } + }; + case "no such bucket": { + const { bucket } = parseS3UriPrefix({ + s3UriPrefix: `s3://${directoryPath}`, + strict: false + }); + + const shouldAttemptToCreate = + s3Profile.bookmarks.find( + bookmark => + bookmark.s3UriPrefixObj.bucket === bucket + ) !== undefined; + + return { + isSuccess: false, + navigationError: { + directoryPath, + errorCase: "no such bucket", + bucket, + shouldAttemptToCreate + } + }; + } + default: + assert< + Equals + >(false); + } + } + + return { + isSuccess: true, + directoryPath, + objects: listObjectResult.objects, + bucketPolicy: listObjectResult.bucketPolicy, + isBucketPolicyAvailable: + listObjectResult.isBucketPolicyAvailable + }; + })() ) ); }, @@ -301,7 +351,7 @@ const privateThunks = { path: directoryPath }); - assert(!listObjectResult.isAccessDenied); + assert(listObjectResult.isSuccess); return listObjectResult.objects.reduce<{ fileBasenames: string[]; @@ -481,6 +531,62 @@ const privateThunks = { } } satisfies Thunks; +export const protectedThunks = { + createBucket: + (params: { bucket: string; directoryPath_toNavigateToOnSuccess: string }) => + async (...args): Promise<{ isSuccess: boolean }> => { + const { bucket, directoryPath_toNavigateToOnSuccess } = params; + + const [dispatch] = args; + + const s3Client = await dispatch( + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() + ).then(r => { + assert(r !== undefined); + return r.s3Client; + }); + + const cmdId = Date.now(); + + dispatch( + actions.commandLogIssued({ + cmdId, + cmd: `mc mb ${pathJoin("s3", bucket)}` + }) + ); + + const result = await s3Client.createBucket({ bucket }); + + dispatch( + actions.commandLogResponseReceived({ + cmdId, + resp: result.isSuccess + ? `Bucket \`${pathJoin("s3", bucket)}\` created` + : (() => { + switch (result.errorCase) { + case "already exist": + return `Bucket \`${pathJoin("s3", bucket)}\` already exists`; + case "access denied": + return `Access denied while creating \`${pathJoin("s3", bucket)}\`: ${result.errorMessage}`; + case "unknown": + return `Failed to create \`${pathJoin("s3", bucket)}\`: ${result.errorMessage}`; + } + })() + }) + ); + + if (result.isSuccess) { + await dispatch( + thunks.changeCurrentDirectory({ + directoryPath: directoryPath_toNavigateToOnSuccess + }) + ); + } + + return { isSuccess: result.isSuccess }; + } +} satisfies Thunks; + export const thunks = { initialize: (params: { directoryPath: string; viewMode: "list" | "block" }) => @@ -804,7 +910,7 @@ export const thunks = { path: directoryPath }); - assert(!listObjectsResult.isAccessDenied); + assert(listObjectsResult.isSuccess); const { objects } = listObjectsResult; diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts index ee28c0dac..767b5388e 100644 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts +++ b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts @@ -4,7 +4,6 @@ import { parseS3UriPrefix } from "core/tools/S3Uri"; import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; import { same } from "evt/tools/inDepth/same"; import { getWorkingDirectoryPath } from "./getWorkingDirectoryPath"; -import { getWorkingDirectoryBucketToCreate } from "./getWorkingDirectoryBucket"; import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; import { assert, type Equals } from "tsafe/assert"; import { getProjectS3ConfigId } from "./projectS3ConfigId"; @@ -239,11 +238,7 @@ export function getS3Configs(params: { region, oidcParams: c.sts.oidcParams, durationSeconds: c.sts.durationSeconds, - role: c.sts.role, - nameOfBucketToCreateIfNotExist: getWorkingDirectoryBucketToCreate({ - workingDirectory: c.workingDirectory, - context: workingDirectoryContext - }) + role: c.sts.role }; const adminBookmarks: S3Config.FromDeploymentRegion.Location.AdminBookmark[] = diff --git a/web/src/ui/pages/fileExplorer/Page.tsx b/web/src/ui/pages/fileExplorer/Page.tsx index 17c6edb97..8ffbff3a9 100644 --- a/web/src/ui/pages/fileExplorer/Page.tsx +++ b/web/src/ui/pages/fileExplorer/Page.tsx @@ -20,6 +20,11 @@ import { enforceLogin } from "ui/shared/enforceLogin"; import CircularProgress from "@mui/material/CircularProgress"; import { Text } from "onyxia-ui/Text"; import { Button } from "onyxia-ui/Button"; +import { useEvt } from "evt/hooks"; +import { + ConfirmBucketCreationAttemptDialog, + type ConfirmBucketCreationAttemptDialogProps +} from "ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog"; const Page = withLoader({ loader: enforceLogin, @@ -28,6 +33,37 @@ const Page = withLoader({ export default Page; function FileExplorer() { + const { + evts: { evtFileExplorer } + } = getCoreSync(); + + const evtConfirmBucketCreationAttemptDialogOpen = useConst(() => + Evt.create() + ); + + useEvt(ctx => { + evtFileExplorer.pipe(ctx).attach( + data => data.action === "ask confirmation for bucket creation attempt", + ({ bucket, createBucket }) => { + evtConfirmBucketCreationAttemptDialogOpen.post({ + bucket, + createBucket + }); + } + ); + }, []); + + return ( + <> + + + + ); +} + +function FileExplorer_inner() { const route = useRoute(); assert(routeGroup.has(route)); @@ -35,7 +71,7 @@ function FileExplorer() { const { isCurrentWorkingDirectoryLoaded, - accessDenied_directoryPath, + navigationError, commandLogsEntries, isNavigationOngoing, uploadProgress, @@ -46,16 +82,16 @@ function FileExplorer() { isDownloadPreparing } = useCoreState("fileExplorer", "main"); + const { + functions: { fileExplorer } + } = getCoreSync(); + const evtIsSnackbarOpen = useConst(() => Evt.create(isDownloadPreparing)); useEffect(() => { evtIsSnackbarOpen.state = isDownloadPreparing; }, [isDownloadPreparing]); - const { - functions: { fileExplorer } - } = getCoreSync(); - useEffect(() => { fileExplorer.initialize({ directoryPath: route.params.path, @@ -167,18 +203,18 @@ function FileExplorer() { )} > {(() => { - if (accessDenied_directoryPath !== undefined) { + if (navigationError !== undefined) { return ( - <> - - You do not have read permission on s3:// - {accessDenied_directoryPath} - with this S3 Profile. - +
+ {navigationError.errorCase} - +
); } diff --git a/web/src/ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog.tsx b/web/src/ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog.tsx new file mode 100644 index 000000000..7d8680b4c --- /dev/null +++ b/web/src/ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog.tsx @@ -0,0 +1,132 @@ +import { useState, memo } from "react"; +import { Dialog } from "onyxia-ui/Dialog"; +import { Button } from "onyxia-ui/Button"; +import { symToStr } from "tsafe/symToStr"; +import { assert } from "tsafe/assert"; +import type { NonPostableEvt, UnpackEvt } from "evt"; +import { useEvt } from "evt/hooks"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; + +export type ConfirmBucketCreationAttemptDialogProps = { + evtOpen: NonPostableEvt<{ + bucket: string; + createBucket: () => Promise<{ isSuccess: boolean }>; + }>; +}; + +export const ConfirmBucketCreationAttemptDialog = memo( + (props: ConfirmBucketCreationAttemptDialogProps) => { + const { evtOpen } = props; + + const [state, setState] = useState< + | (UnpackEvt & { + isBucketCreationFailed: boolean; + isCreatingBucket: boolean; + }) + | undefined + >(undefined); + + useEvt( + ctx => { + evtOpen.attach(ctx, eventData => + setState({ + ...eventData, + isBucketCreationFailed: false, + isCreatingBucket: false + }) + ); + }, + [evtOpen] + ); + + return ( + <> + { + if (state === undefined) { + return null; + } + + if (state.isCreatingBucket) { + return ; + } + + return ( + <> + + + + ); + })()} + isOpen={state !== undefined && !state.isBucketCreationFailed} + onClose={() => { + if (state === undefined) { + return; + } + + if (state.isCreatingBucket) { + return; + } + + setState(undefined); + }} + /> + setState(undefined)}> + Ok + + } + isOpen={state !== undefined && state.isBucketCreationFailed} + onClose={() => setState(undefined)} + /> + + ); + } +); + +ConfirmBucketCreationAttemptDialog.displayName = symToStr({ + ConfirmBucketCreationAttemptDialog +}); diff --git a/web/src/ui/pages/s3Explorer/Explorer.tsx b/web/src/ui/pages/s3Explorer/Explorer.tsx index ef271f6c7..416755d14 100644 --- a/web/src/ui/pages/s3Explorer/Explorer.tsx +++ b/web/src/ui/pages/s3Explorer/Explorer.tsx @@ -10,13 +10,18 @@ import { routes } from "ui/routes"; import { Evt } from "evt"; import type { Param0 } from "tsafe"; import { useConst } from "powerhooks/useConst"; -import { assert } from "tsafe/assert"; +import { assert, type Equals } from "tsafe/assert"; import { triggerBrowserDownload } from "ui/tools/triggerBrowserDonwload"; import CircularProgress from "@mui/material/CircularProgress"; import { Text } from "onyxia-ui/Text"; import { Button } from "onyxia-ui/Button"; import { useStyles } from "tss"; import { getIconUrlByName } from "lazy-icons"; +import { + ConfirmBucketCreationAttemptDialog, + type ConfirmBucketCreationAttemptDialogProps +} from "./ConfirmBucketCreationAttemptDialog"; +import { useEvt } from "evt/hooks"; type Props = { className?: string; @@ -34,6 +39,36 @@ type Props = { }; export function Explorer(props: Props) { + const { + evts: { evtFileExplorer } + } = getCoreSync(); + + const evtConfirmBucketCreationAttemptDialogOpen = useConst(() => + Evt.create() + ); + + useEvt(ctx => { + evtFileExplorer.pipe(ctx).attach( + data => data.action === "ask confirmation for bucket creation attempt", + ({ bucket, createBucket }) => + evtConfirmBucketCreationAttemptDialogOpen.post({ + bucket, + createBucket + }) + ); + }, []); + + return ( + <> + + + + ); +} + +function Explorer_inner(props: Props) { const { className, directoryPath, @@ -44,7 +79,7 @@ export function Explorer(props: Props) { const { isCurrentWorkingDirectoryLoaded, - accessDenied_directoryPath, + navigationError, commandLogsEntries, isNavigationOngoing, uploadProgress, @@ -177,7 +212,7 @@ export function Explorer(props: Props) { )} > {(() => { - if (accessDenied_directoryPath !== undefined) { + if (navigationError !== undefined) { return (
- You do not have read permission on s3:// - {accessDenied_directoryPath} - with this S3 Profile. + {(() => { + switch (navigationError.errorCase) { + case "access denied": + return [ + "You do not have read permission on s3://", + navigationError.directoryPath, + "with this S3 Profile" + ].join(" "); + case "no such bucket": + return `The bucket ${navigationError.bucket} does not exist`; + default: + assert< + Equals + >(false); + } + })()} } - isOpen={state !== undefined && state.isBucketCreationFailed} + isOpen={ + state !== undefined && state.isBucketCreationSuccess !== undefined + } onClose={() => setState(undefined)} /> From a099d7166efe3e69ff3fc0d9185d64b96c8f4253 Mon Sep 17 00:00:00 2001 From: garronej Date: Sat, 20 Dec 2025 12:02:12 +0100 Subject: [PATCH 39/41] Fix state management error --- web/src/core/usecases/fileExplorer/state.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/core/usecases/fileExplorer/state.ts b/web/src/core/usecases/fileExplorer/state.ts index d3665a3b4..eec8148b7 100644 --- a/web/src/core/usecases/fileExplorer/state.ts +++ b/web/src/core/usecases/fileExplorer/state.ts @@ -162,6 +162,8 @@ export const { reducer, actions } = createUsecaseActions({ }; } ) => { + state.ongoingNavigation = undefined; + if (!payload.isSuccess) { state.navigationError = payload.navigationError; return; @@ -173,7 +175,6 @@ export const { reducer, actions } = createUsecaseActions({ state.navigationError = undefined; state.directoryPath = directoryPath; state.objects = objects; - state.ongoingNavigation = undefined; if (bucketPolicy) { state.bucketPolicy = bucketPolicy; } From 12999c7c08d6a1e3d02b3ac628d83952a3871d55 Mon Sep 17 00:00:00 2001 From: garronej Date: Sat, 20 Dec 2025 12:15:51 +0100 Subject: [PATCH 40/41] Add internationalization of the new modal --- web/src/ui/i18n/resources/de.tsx | 12 +++++ web/src/ui/i18n/resources/en.tsx | 12 +++++ web/src/ui/i18n/resources/es.tsx | 11 +++++ web/src/ui/i18n/resources/fi.tsx | 11 +++++ web/src/ui/i18n/resources/fr.tsx | 11 +++++ web/src/ui/i18n/resources/it.tsx | 16 +++++-- web/src/ui/i18n/resources/nl.tsx | 11 +++++ web/src/ui/i18n/resources/no.tsx | 11 +++++ web/src/ui/i18n/resources/zh-CN.tsx | 11 +++++ web/src/ui/i18n/types.ts | 1 + .../ConfirmBucketCreationAttemptDialog.tsx | 45 +++++++++++++++---- 11 files changed, 140 insertions(+), 12 deletions(-) diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index 3e03b5441..2771a5ad0 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -349,6 +349,18 @@ export const translations: Translations<"de"> = { cancel: "Abbrechen", "go to settings": "Zu den Einstellungen gehen" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => + `Der Bucket ${bucket} existiert nicht`, + "bucket does not exist body": "Möchten Sie jetzt versuchen, ihn zu erstellen?", + no: "Nein", + yes: "Ja", + "success title": "Erfolg", + "failed title": "Fehlgeschlagen", + "success body": ({ bucket }) => `Bucket ${bucket} wurde erfolgreich erstellt.`, + "failed body": ({ bucket }) => `Bucket ${bucket} konnte nicht erstellt werden.`, + ok: "Ok" + }, ShareDialog: { title: "Ihre Daten teilen", close: "Schließen", diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 34f99327b..dfa107496 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -341,6 +341,18 @@ export const translations: Translations<"en"> = { cancel: "Cancel", "go to settings": "Go to settings" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => + `The ${bucket} bucket does not exist`, + "bucket does not exist body": "Do you want to attempt creating it now?", + no: "No", + yes: "Yes", + "success title": "Success", + "failed title": "Failed", + "success body": ({ bucket }) => `Bucket ${bucket} successfully created.`, + "failed body": ({ bucket }) => `Failed to create ${bucket}.`, + ok: "Ok" + }, ShareDialog: { title: "Share your data", close: "Close", diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index c414b392c..dafb53464 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -352,6 +352,17 @@ export const translations: Translations<"en"> = { cancel: "Cancelar", "go to settings": "Ir a configuración" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => `El bucket ${bucket} no existe`, + "bucket does not exist body": "¿Quieres intentar crearlo ahora?", + no: "No", + yes: "Sí", + "success title": "Éxito", + "failed title": "Error", + "success body": ({ bucket }) => `Bucket ${bucket} creado correctamente.`, + "failed body": ({ bucket }) => `No se pudo crear ${bucket}.`, + ok: "Ok" + }, ShareDialog: { title: "Compartir tus datos", close: "Cerrar", diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index ddc6cf6b1..5fdf757db 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -346,6 +346,17 @@ export const translations: Translations<"fi"> = { cancel: "Peruuta", "go to settings": "Siirry asetuksiin" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => `Bucket ${bucket} ei ole olemassa`, + "bucket does not exist body": "Haluatko yrittää luoda sen nyt?", + no: "Ei", + yes: "Kyllä", + "success title": "Onnistui", + "failed title": "Epäonnistui", + "success body": ({ bucket }) => `Bucket ${bucket} luotiin onnistuneesti.`, + "failed body": ({ bucket }) => `Kohteen ${bucket} luonti epäonnistui.`, + ok: "Ok" + }, ShareDialog: { title: "Jaa tietosi", close: "Sulje", diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 1fecc74c1..af8dcdd41 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -355,6 +355,17 @@ export const translations: Translations<"fr"> = { cancel: "Annuler", "go to settings": "Aller aux paramètres" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => `Le bucket ${bucket} n'existe pas`, + "bucket does not exist body": "Voulez-vous tenter de le créer maintenant ?", + no: "Non", + yes: "Oui", + "success title": "Succès", + "failed title": "Échec", + "success body": ({ bucket }) => `Bucket ${bucket} créé avec succès.`, + "failed body": ({ bucket }) => `Échec de la création de ${bucket}.`, + ok: "Ok" + }, ShareDialog: { title: "Partager vos données", close: "Fermer", diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index 61d74300e..f969426d5 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -349,6 +349,17 @@ export const translations: Translations<"it"> = { cancel: "Annulla", "go to settings": "Vai alle impostazioni" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => `Il bucket ${bucket} non esiste`, + "bucket does not exist body": "Vuoi provare a crearlo ora?", + no: "No", + yes: "Sì", + "success title": "Successo", + "failed title": "Fallito", + "success body": ({ bucket }) => `Bucket ${bucket} creato con successo.`, + "failed body": ({ bucket }) => `Creazione di ${bucket} non riuscita.`, + ok: "Ok" + }, ShareDialog: { title: "Condividi i tuoi dati", close: "Chiudi", @@ -384,9 +395,8 @@ export const translations: Translations<"it"> = { la nostra documentazione .   - - Configurare il tuo Vault CLI locale - . + Configurare il tuo Vault CLI locale + . ) }, diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index ae8f28ff7..a94850188 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -349,6 +349,17 @@ export const translations: Translations<"nl"> = { cancel: "Annuleren", "go to settings": "Ga naar instellingen" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => `De bucket ${bucket} bestaat niet`, + "bucket does not exist body": "Wil je proberen hem nu aan te maken?", + no: "Nee", + yes: "Ja", + "success title": "Gelukt", + "failed title": "Mislukt", + "success body": ({ bucket }) => `Bucket ${bucket} is succesvol aangemaakt.`, + "failed body": ({ bucket }) => `Aanmaken van ${bucket} is mislukt.`, + ok: "Ok" + }, ShareDialog: { title: "Deel je gegevens", close: "Sluiten", diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 5dc6a75fc..48a84cbb3 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -345,6 +345,17 @@ export const translations: Translations<"no"> = { cancel: "Avbryt", "go to settings": "Gå til innstillinger" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => `Bucket ${bucket} finnes ikke`, + "bucket does not exist body": "Vil du prøve å opprette den nå?", + no: "Nei", + yes: "Ja", + "success title": "Vellykket", + "failed title": "Feilet", + "success body": ({ bucket }) => `Bucket ${bucket} ble opprettet.`, + "failed body": ({ bucket }) => `Kunne ikke opprette ${bucket}.`, + ok: "Ok" + }, ShareDialog: { title: "Del dataene dine", close: "Lukk", diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index f13366950..e570ac182 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -312,6 +312,17 @@ export const translations: Translations<"zh-CN"> = { cancel: "取消", "go to settings": "前往设置" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => `存储桶 ${bucket} 不存在`, + "bucket does not exist body": "要立即尝试创建吗?", + no: "否", + yes: "是", + "success title": "成功", + "failed title": "失败", + "success body": ({ bucket }) => `存储桶 ${bucket} 创建成功。`, + "failed body": ({ bucket }) => `创建 ${bucket} 失败。`, + ok: "确定" + }, ShareDialog: { title: "分享您的数据", close: "关闭", diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index e8f8b5498..59b82d93f 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -19,6 +19,7 @@ export type ComponentKey = | import("ui/pages/fileExplorerEntry/Page").I18n | import("ui/pages/fileExplorerEntry/S3Entries/S3EntryCard").I18n | import("ui/pages/fileExplorerEntry/FileExplorerDisabledDialog").I18n + | import("ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog").I18n | import("ui/pages/fileExplorer/Explorer/Explorer").I18n | import("ui/pages/fileExplorer/Explorer/ExplorerButtonBar").I18n | import("ui/pages/fileExplorer/Explorer/ExplorerItems").I18n diff --git a/web/src/ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog.tsx b/web/src/ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog.tsx index 5e77bcc1e..7bfe04473 100644 --- a/web/src/ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog.tsx +++ b/web/src/ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog.tsx @@ -6,6 +6,7 @@ import { assert } from "tsafe/assert"; import type { NonPostableEvt, UnpackEvt } from "evt"; import { useEvt } from "evt/hooks"; import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { declareComponentKeys, useTranslation } from "ui/i18n"; export type ConfirmBucketCreationAttemptDialogProps = { evtOpen: NonPostableEvt<{ @@ -18,6 +19,8 @@ export const ConfirmBucketCreationAttemptDialog = memo( (props: ConfirmBucketCreationAttemptDialogProps) => { const { evtOpen } = props; + const { t } = useTranslation({ ConfirmBucketCreationAttemptDialog }); + const [state, setState] = useState< | (UnpackEvt & { isBucketCreationSuccess: boolean | undefined; @@ -42,8 +45,12 @@ export const ConfirmBucketCreationAttemptDialog = memo( return ( <> { if (state === undefined) { return null; @@ -60,7 +67,7 @@ export const ConfirmBucketCreationAttemptDialog = memo( autoFocus variant="secondary" > - No + {t("no")} ); @@ -110,15 +117,21 @@ export const ConfirmBucketCreationAttemptDialog = memo( }} /> setState(undefined)}> - Ok + {t("ok")} } isOpen={ @@ -134,3 +147,17 @@ export const ConfirmBucketCreationAttemptDialog = memo( ConfirmBucketCreationAttemptDialog.displayName = symToStr({ ConfirmBucketCreationAttemptDialog }); + +const { i18n } = declareComponentKeys< + | { K: "bucket does not exist title"; P: { bucket: string } } + | "bucket does not exist body" + | "no" + | "yes" + | "success title" + | "failed title" + | { K: "success body"; P: { bucket: string } } + | { K: "failed body"; P: { bucket: string } } + | "ok" +>()({ ConfirmBucketCreationAttemptDialog }); + +export type I18n = typeof i18n; From eead460b2929e845bf7e5acb6df8dda81d299d7a Mon Sep 17 00:00:00 2001 From: garronej Date: Sat, 20 Dec 2025 12:18:44 +0100 Subject: [PATCH 41/41] Fix rebase --- web/src/core/bootstrap.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index d170d9b5f..8ad19f5a8 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -174,7 +174,7 @@ export async function bootstrapCore( s3_url_style: s3Profile.paramsOfCreateS3Client.pathStyleAccess ? "path" : "vhost", - s3_region: s3Config.region + s3_region: s3Profile.paramsOfCreateS3Client.region }; } })