From 5454f5333ba48d681164211ce71f990b6a2c371a Mon Sep 17 00:00:00 2001 From: garronej Date: Tue, 21 Oct 2025 21:01:34 +0200 Subject: [PATCH 01/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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 }; } }) From a988bb5e29df7538a3ca876fa2e390f0ed1baeb5 Mon Sep 17 00:00:00 2001 From: garronej Date: Fri, 9 Jan 2026 13:39:16 +0100 Subject: [PATCH 42/59] Fix config creation in new S3 page --- .../AddCustomS3ConfigDialog.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/web/src/ui/pages/s3Explorer/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx index c4eac135e..3e61211fd 100644 --- a/web/src/ui/pages/s3Explorer/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx +++ b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx @@ -133,7 +133,7 @@ const Body = memo(() => { ); const { - functions: { s3ConfigCreation } + functions: { s3ProfilesCreationUiController } } = getCoreSync(); const { classes, css, theme } = useBodyStyles(); @@ -161,7 +161,7 @@ const Body = memo(() => { defaultValue={formValues.friendlyName} doOnlyShowErrorAfterFirstFocusLost onValueBeingTypedChange={({ value }) => - s3ConfigCreation.changeValue({ + s3ProfilesCreationUiController.changeValue({ key: "friendlyName", value }) @@ -178,7 +178,7 @@ const Body = memo(() => { defaultValue={formValues.url} doOnlyShowErrorAfterFirstFocusLost onValueBeingTypedChange={({ value }) => - s3ConfigCreation.changeValue({ + s3ProfilesCreationUiController.changeValue({ key: "url", value }) @@ -195,7 +195,7 @@ const Body = memo(() => { defaultValue={formValues.region} doOnlyShowErrorAfterFirstFocusLost onValueBeingTypedChange={({ value }) => - s3ConfigCreation.changeValue({ + s3ProfilesCreationUiController.changeValue({ key: "region", value }) @@ -223,7 +223,7 @@ const Body = memo(() => { aria-labelledby="path-style" value={formValues.pathStyleAccess ? "path" : "virtual-hosted"} onChange={(_, value) => - s3ConfigCreation.changeValue({ + s3ProfilesCreationUiController.changeValue({ key: "pathStyleAccess", value: value === "path" }) @@ -267,7 +267,7 @@ const Body = memo(() => { - s3ConfigCreation.changeValue({ + s3ProfilesCreationUiController.changeValue({ key: "isAnonymous", value: isChecked }) @@ -295,7 +295,7 @@ const Body = memo(() => { defaultValue={formValues.accessKeyId ?? ""} doOnlyShowErrorAfterFirstFocusLost onValueBeingTypedChange={({ value }) => - s3ConfigCreation.changeValue({ + s3ProfilesCreationUiController.changeValue({ key: "accessKeyId", value: value || undefined }) @@ -316,7 +316,7 @@ const Body = memo(() => { defaultValue={formValues.secretAccessKey ?? ""} doOnlyShowErrorAfterFirstFocusLost onValueBeingTypedChange={({ value }) => - s3ConfigCreation.changeValue({ + s3ProfilesCreationUiController.changeValue({ key: "secretAccessKey", value: value || undefined }) @@ -338,7 +338,7 @@ const Body = memo(() => { defaultValue={formValues.sessionToken ?? ""} doOnlyShowErrorAfterFirstFocusLost onValueBeingTypedChange={({ value }) => - s3ConfigCreation.changeValue({ + s3ProfilesCreationUiController.changeValue({ key: "sessionToken", value: value || undefined }) From aea37aac2808298603ade0a78e39a79b0df1ce40 Mon Sep 17 00:00:00 2001 From: garronej Date: Fri, 9 Jan 2026 17:47:17 +0100 Subject: [PATCH 43/59] Fix: Update globally selected profile for explorer upon navigation to an s3 url --- web/src/core/adapters/onyxiaApi/onyxiaApi.ts | 49 ++----------------- .../s3ExplorerRootUiController/selectors.ts | 7 +-- .../s3ExplorerRootUiController/thunks.ts | 38 ++++++++++---- .../decoupledLogic/s3Profiles.ts | 30 +++++++++--- ...DefaultS3ProfilesAfterPotentialDeletion.ts | 4 +- .../core/usecases/projectManagement/thunks.ts | 20 +++++--- web/src/ui/pages/s3Explorer/Page.tsx | 7 +-- 7 files changed, 75 insertions(+), 80 deletions(-) diff --git a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts index 585d60c44..aa4f3a08c 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -22,7 +22,6 @@ import type { ApiTypes } from "./ApiTypes"; import { Evt } from "evt"; import { id } from "tsafe/id"; import { parseS3UriPrefix } from "core/tools/S3Uri"; -import type { LocalizedString } from "core/ports/OnyxiaApi"; export function createOnyxiaApi(params: { url: string; @@ -189,45 +188,6 @@ export function createOnyxiaApi(params: { })() }); - const bookmarkedDirectories_test = await (async () => { - if (!window.location.href.includes("localhost")) { - return []; - } - - return id< - ({ - fullPath: string; - title: LocalizedString; - description?: LocalizedString; - tags?: LocalizedString[]; - forStsRoleSessionName?: string | string[]; - } & ( - | { claimName?: undefined } - | { - claimName: string; - includedClaimPattern?: string; - excludedClaimPattern?: string; - } - ))[] - >([ - { - fullPath: "$1/", - title: "Personal", - description: "Personal Bucket", - claimName: "preferred_username", - forStsRoleSessionName: undefined - }, - { - fullPath: "projet-$1/", - title: "Group $1", - description: "Shared bucket among members of project $1", - claimName: "groups", - excludedClaimPattern: "^USER_ONYXIA$", - forStsRoleSessionName: undefined - } - ]); - })(); - const regions = data.regions.map( (apiRegion): DeploymentRegion => id({ @@ -476,11 +436,10 @@ export function createOnyxiaApi(params: { ) } as any; })(), - bookmarks: [ - ...bookmarkedDirectories_test, - ...(s3Config_api.bookmarkedDirectories ?? - []) - ].map( + bookmarks: ( + s3Config_api.bookmarkedDirectories ?? + [] + ).map( ( bookmarkedDirectory_api ): DeploymentRegion.S3Next.S3Profile.Bookmark => { diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts index 1d84aa910..eef07a424 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts @@ -65,11 +65,8 @@ const view = createSelector( s3Profile => s3Profile.id === selectedS3ProfileId ); - // TODO: Handle this case gratefully - assert( - s3Profile !== undefined, - "The profile in the root url does not exist in configuration" - ); + // NOTE: We enforce this invariant while loading the route + assert(s3Profile !== undefined); const s3UriPrefixObj = routeParams.path === "" diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts index 14467b706..fb1927022 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts @@ -10,12 +10,34 @@ import { assert } from "tsafe/assert"; export const thunks = { load: (params: { routeParams: RouteParams }) => - (...args): { routeParams_toSet: RouteParams | undefined } => { + async (...args): Promise<{ routeParams_toSet: RouteParams | undefined }> => { const [dispatch, getState] = args; const { routeParams } = params; if (routeParams.profile !== undefined) { + const s3ProfileId = routeParams.profile; + + { + const s3Profiles = + s3ProfilesManagement.selectors.s3Profiles(getState()); + + if ( + s3Profiles.find(s3Profile => s3Profile.id === s3ProfileId) === + undefined + ) { + return dispatch(thunks.load({ routeParams: { path: "" } })); + } + } + + await dispatch( + s3ProfilesManagement.protectedThunks.changeIsDefault({ + s3ProfileId, + usecase: "explorer", + value: true + }) + ); + dispatch(actions.routeParamsSet({ routeParams })); return { routeParams_toSet: undefined }; } @@ -27,14 +49,12 @@ export const thunks = { return { routeParams_toSet: routeParams }; } - const s3Profiles = s3ProfilesManagement.selectors.s3Profiles(getState()); - - const s3Profile = - s3Profiles.find(s3Profile => s3Profile.origin === "defined in region") ?? - s3Profiles[0]; + const wrap = await dispatch( + s3ProfilesManagement.protectedThunks.getS3ConfigAndClientForExplorer() + ); const routeParams_toSet: RouteParams = { - profile: s3Profile === undefined ? undefined : s3Profile.id, + profile: wrap === undefined ? undefined : wrap.s3Profile.id, path: "" }; @@ -44,10 +64,10 @@ export const thunks = { }, notifyRouteParamsExternallyUpdated: (params: { routeParams: RouteParams }) => - (...args) => { + async (...args) => { const { routeParams } = params; const [dispatch] = args; - const { routeParams_toSet } = dispatch(thunks.load({ routeParams })); + const { routeParams_toSet } = await dispatch(thunks.load({ routeParams })); if (routeParams_toSet !== undefined) { evt.post({ diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts index 90417df44..3a6a8a714 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -45,14 +45,20 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { }; fromRegion: { s3Profiles: DeploymentRegion.S3Next.S3Profile[]; - resolvedTemplatedBookmarks: { - correspondingS3ConfigIndexInRegion: number; - bookmarks: ResolvedTemplateBookmark[]; - }[]; - resolvedTemplatedStsRoles: { - correspondingS3ConfigIndexInRegion: number; - stsRoles: ResolvedTemplateStsRole[]; - }[]; + // NOTE: The resolvedXXX can be undefined only when the function is used to + // the stablish the default profiles (for explorer and services) + resolvedTemplatedBookmarks: + | { + correspondingS3ConfigIndexInRegion: number; + bookmarks: ResolvedTemplateBookmark[]; + }[] + | undefined; + resolvedTemplatedStsRoles: + | { + correspondingS3ConfigIndexInRegion: number; + stsRoles: ResolvedTemplateStsRole[]; + }[] + | undefined; }; }): S3Profile[] { const { fromVault, fromRegion } = params; @@ -94,6 +100,10 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { ...fromRegion.s3Profiles .map((c, index): S3Profile.DefinedInRegion[] => { const resolvedTemplatedBookmarks_forThisProfile = (() => { + if (fromRegion.resolvedTemplatedBookmarks === undefined) { + return []; + } + const entry = fromRegion.resolvedTemplatedBookmarks.find( e => e.correspondingS3ConfigIndexInRegion === index ); @@ -188,6 +198,10 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { }; const resolvedTemplatedStsRoles_forThisProfile = (() => { + if (fromRegion.resolvedTemplatedStsRoles === undefined) { + return []; + } + const entry = fromRegion.resolvedTemplatedStsRoles.find( e => e.correspondingS3ConfigIndexInRegion === index ); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts index da93c41c7..a40203cfa 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts @@ -24,8 +24,8 @@ export function updateDefaultS3ProfilesAfterPotentialDeletion(params: { const s3Profiles = aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ fromRegion: { s3Profiles: fromRegion.s3Profiles, - resolvedTemplatedBookmarks: [], - resolvedTemplatedStsRoles: [] + resolvedTemplatedBookmarks: undefined, + resolvedTemplatedStsRoles: undefined }, fromVault: { projectConfigs_s3: fromVault.projectConfigs_s3, diff --git a/web/src/core/usecases/projectManagement/thunks.ts b/web/src/core/usecases/projectManagement/thunks.ts index 92c317979..e61b18d42 100644 --- a/web/src/core/usecases/projectManagement/thunks.ts +++ b/web/src/core/usecases/projectManagement/thunks.ts @@ -7,7 +7,7 @@ import { protectedSelectors } from "./selectors"; import * as userConfigs from "core/usecases/userConfigs"; import { same } from "evt/tools/inDepth"; import { id } from "tsafe/id"; -import { updateDefaultS3ConfigsAfterPotentialDeletion } from "core/usecases/s3ConfigManagement/decoupledLogic/updateDefaultS3ConfigsAfterPotentialDeletion"; +import { updateDefaultS3ProfilesAfterPotentialDeletion } from "core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import { getProjectVaultTopDirPath_reserved } from "./decoupledLogic/projectVaultTopDirPath_reserved"; import { secretToValue, valueToSecret } from "./decoupledLogic/secretParsing"; @@ -132,12 +132,16 @@ export const thunks = { await prOnboarding; maybe_update_pinned_default_s3_configs: { - const actions = updateDefaultS3ConfigsAfterPotentialDeletion({ - projectConfigsS3: projectConfigs.s3, - s3RegionConfigs: - deploymentRegionManagement.selectors.currentDeploymentRegion( - getState() - ).s3Configs + const actions = updateDefaultS3ProfilesAfterPotentialDeletion({ + fromRegion: { + s3Profiles: + deploymentRegionManagement.selectors.currentDeploymentRegion( + getState() + )._s3Next.s3Profiles + }, + fromVault: { + projectConfigs_s3: projectConfigs.s3 + } }); let needUpdate = false; @@ -154,7 +158,7 @@ export const thunks = { needUpdate = true; - projectConfigs.s3[propertyName] = action.s3ConfigId; + projectConfigs.s3[propertyName] = action.s3ProfileId; } if (!needUpdate) { diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index f2546508b..fbbbbccd9 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -39,9 +39,10 @@ const Page = withLoader({ const route = getRoute(); assert(routeGroup.has(route)); - const { routeParams_toSet } = core.functions.s3ExplorerRootUiController.load({ - routeParams: route.params - }); + const { routeParams_toSet } = + await core.functions.s3ExplorerRootUiController.load({ + routeParams: route.params + }); if (routeParams_toSet !== undefined) { routes.s3Explorer(routeParams_toSet).replace(); From ae8e3702d7386850b6ec9fa271f8a1ef4fc289a5 Mon Sep 17 00:00:00 2001 From: garronej Date: Fri, 9 Jan 2026 18:13:35 +0100 Subject: [PATCH 44/59] Fix deletion of directory --- web/src/core/adapters/s3Client/s3Client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/core/adapters/s3Client/s3Client.ts b/web/src/core/adapters/s3Client/s3Client.ts index e5e574df6..7c6bc40fc 100644 --- a/web/src/core/adapters/s3Client/s3Client.ts +++ b/web/src/core/adapters/s3Client/s3Client.ts @@ -237,7 +237,7 @@ export function createS3Client( listObjects: async ({ path }) => { const { bucket: bucketName, keyPrefix: prefix } = parseS3UriPrefix({ s3UriPrefix: `s3://${path}`, - strict: true + strict: false }); const { getAwsS3Client } = await prApi; From 9ca1608f0812af58e55e9f64acd8e68b6722b300 Mon Sep 17 00:00:00 2001 From: garronej Date: Fri, 9 Jan 2026 18:36:34 +0100 Subject: [PATCH 45/59] Add button for deleting bookmark on navigation error --- web/src/ui/pages/s3Explorer/Explorer.tsx | 38 +++++++++++++++++++----- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/web/src/ui/pages/s3Explorer/Explorer.tsx b/web/src/ui/pages/s3Explorer/Explorer.tsx index 416755d14..84bb2b2b0 100644 --- a/web/src/ui/pages/s3Explorer/Explorer.tsx +++ b/web/src/ui/pages/s3Explorer/Explorer.tsx @@ -237,18 +237,40 @@ function Explorer_inner(props: Props) { } })()} - + + {bookmarkStatus.isBookmarked && + !bookmarkStatus.isReadonly && ( + + )} +
); } From 7bb841a4ecb6edc018f84a27ffd69c4060f5e7f8 Mon Sep 17 00:00:00 2001 From: garronej Date: Sun, 11 Jan 2026 19:51:36 +0100 Subject: [PATCH 46/59] Complete internationalization of new s3 Explorer --- web/src/ui/i18n/resources/de.tsx | 7 ++++++ web/src/ui/i18n/resources/en.tsx | 7 ++++++ web/src/ui/i18n/resources/es.tsx | 7 ++++++ web/src/ui/i18n/resources/fi.tsx | 7 ++++++ web/src/ui/i18n/resources/fr.tsx | 7 ++++++ web/src/ui/i18n/resources/it.tsx | 12 ++++++++-- web/src/ui/i18n/resources/nl.tsx | 7 ++++++ web/src/ui/i18n/resources/no.tsx | 7 ++++++ web/src/ui/i18n/resources/zh-CN.tsx | 7 ++++++ web/src/ui/i18n/types.ts | 1 + web/src/ui/pages/s3Explorer/Explorer.tsx | 29 +++++++++++++++++------- 11 files changed, 88 insertions(+), 10 deletions(-) diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index 2771a5ad0..a12b37c7e 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -361,6 +361,13 @@ export const translations: Translations<"de"> = { "failed body": ({ bucket }) => `Bucket ${bucket} konnte nicht erstellt werden.`, ok: "Ok" }, + S3ExplorerExplorer: { + "access denied": ({ directoryPath }) => + `Sie haben mit diesem S3-Profil keine Leseberechtigung für s3://${directoryPath}`, + "bucket does not exist": ({ bucket }) => `Der Bucket ${bucket} existiert nicht`, + "go back": "Zurück", + "delete bookmark": "Lesezeichen entfernen" + }, 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 dfa107496..35d951105 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -353,6 +353,13 @@ export const translations: Translations<"en"> = { "failed body": ({ bucket }) => `Failed to create ${bucket}.`, ok: "Ok" }, + S3ExplorerExplorer: { + "access denied": ({ directoryPath }) => + `You do not have read permission on s3://${directoryPath} with this S3 Profile`, + "bucket does not exist": ({ bucket }) => `The bucket ${bucket} does not exist`, + "go back": "Go Back", + "delete bookmark": "Delete bookmark" + }, 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 dafb53464..c6320ad27 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -363,6 +363,13 @@ export const translations: Translations<"en"> = { "failed body": ({ bucket }) => `No se pudo crear ${bucket}.`, ok: "Ok" }, + S3ExplorerExplorer: { + "access denied": ({ directoryPath }) => + `No tienes permiso de lectura en s3://${directoryPath} con este perfil S3`, + "bucket does not exist": ({ bucket }) => `El bucket ${bucket} no existe`, + "go back": "Volver", + "delete bookmark": "Eliminar marcador" + }, 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 5fdf757db..21dd86e50 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -357,6 +357,13 @@ export const translations: Translations<"fi"> = { "failed body": ({ bucket }) => `Kohteen ${bucket} luonti epäonnistui.`, ok: "Ok" }, + S3ExplorerExplorer: { + "access denied": ({ directoryPath }) => + `Sinulla ei ole lukuoikeutta kohteeseen s3://${directoryPath} tällä S3-profiililla`, + "bucket does not exist": ({ bucket }) => `Bucket ${bucket} ei ole olemassa`, + "go back": "Takaisin", + "delete bookmark": "Poista kirjanmerkki" + }, 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 af8dcdd41..9c702654b 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -366,6 +366,13 @@ export const translations: Translations<"fr"> = { "failed body": ({ bucket }) => `Échec de la création de ${bucket}.`, ok: "Ok" }, + S3ExplorerExplorer: { + "access denied": ({ directoryPath }) => + `Vous n'avez pas l'autorisation de lecture sur s3://${directoryPath} avec ce profil S3`, + "bucket does not exist": ({ bucket }) => `Le bucket ${bucket} n'existe pas`, + "go back": "Retour", + "delete bookmark": "Supprimer le favori" + }, 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 f969426d5..239268146 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -360,6 +360,13 @@ export const translations: Translations<"it"> = { "failed body": ({ bucket }) => `Creazione di ${bucket} non riuscita.`, ok: "Ok" }, + S3ExplorerExplorer: { + "access denied": ({ directoryPath }) => + `Non hai il permesso di lettura su s3://${directoryPath} con questo profilo S3`, + "bucket does not exist": ({ bucket }) => `Il bucket ${bucket} non esiste`, + "go back": "Indietro", + "delete bookmark": "Elimina segnalibro" + }, ShareDialog: { title: "Condividi i tuoi dati", close: "Chiudi", @@ -395,8 +402,9 @@ 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 a94850188..e5bc0f0e2 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -360,6 +360,13 @@ export const translations: Translations<"nl"> = { "failed body": ({ bucket }) => `Aanmaken van ${bucket} is mislukt.`, ok: "Ok" }, + S3ExplorerExplorer: { + "access denied": ({ directoryPath }) => + `Je hebt geen leesrechten op s3://${directoryPath} met dit S3-profiel`, + "bucket does not exist": ({ bucket }) => `De bucket ${bucket} bestaat niet`, + "go back": "Terug", + "delete bookmark": "Bladwijzer verwijderen" + }, 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 48a84cbb3..8d6ff4a22 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -356,6 +356,13 @@ export const translations: Translations<"no"> = { "failed body": ({ bucket }) => `Kunne ikke opprette ${bucket}.`, ok: "Ok" }, + S3ExplorerExplorer: { + "access denied": ({ directoryPath }) => + `Du har ikke lesetilgang til s3://${directoryPath} med denne S3-profilen`, + "bucket does not exist": ({ bucket }) => `Bucket ${bucket} finnes ikke`, + "go back": "Tilbake", + "delete bookmark": "Slett bokmerke" + }, 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 e570ac182..35b02e127 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -323,6 +323,13 @@ export const translations: Translations<"zh-CN"> = { "failed body": ({ bucket }) => `创建 ${bucket} 失败。`, ok: "确定" }, + S3ExplorerExplorer: { + "access denied": ({ directoryPath }) => + `您没有使用此 S3 配置文件读取 s3://${directoryPath} 的权限`, + "bucket does not exist": ({ bucket }) => `存储桶 ${bucket} 不存在`, + "go back": "返回", + "delete bookmark": "删除书签" + }, ShareDialog: { title: "分享您的数据", close: "关闭", diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index 59b82d93f..cd38cdb01 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -20,6 +20,7 @@ export type ComponentKey = | import("ui/pages/fileExplorerEntry/S3Entries/S3EntryCard").I18n | import("ui/pages/fileExplorerEntry/FileExplorerDisabledDialog").I18n | import("ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog").I18n + | import("ui/pages/s3Explorer/Explorer").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/Explorer.tsx b/web/src/ui/pages/s3Explorer/Explorer.tsx index 84bb2b2b0..55f61eb2f 100644 --- a/web/src/ui/pages/s3Explorer/Explorer.tsx +++ b/web/src/ui/pages/s3Explorer/Explorer.tsx @@ -17,6 +17,7 @@ import { Text } from "onyxia-ui/Text"; import { Button } from "onyxia-ui/Button"; import { useStyles } from "tss"; import { getIconUrlByName } from "lazy-icons"; +import { declareComponentKeys, useTranslation } from "ui/i18n"; import { ConfirmBucketCreationAttemptDialog, type ConfirmBucketCreationAttemptDialogProps @@ -90,6 +91,8 @@ function Explorer_inner(props: Props) { isDownloadPreparing } = useCoreState("fileExplorer", "main"); + const { t } = useTranslation("S3ExplorerExplorer"); + const evtIsSnackbarOpen = useConst(() => Evt.create(isDownloadPreparing)); useEffect(() => { @@ -223,13 +226,14 @@ function Explorer_inner(props: Props) { {(() => { switch (navigationError.errorCase) { case "access denied": - return [ - "You do not have read permission on s3://", - navigationError.directoryPath, - "with this S3 Profile" - ].join(" "); + return t("access denied", { + directoryPath: + navigationError.directoryPath + }); case "no such bucket": - return `The bucket ${navigationError.bucket} does not exist`; + return t("bucket does not exist", { + bucket: navigationError.bucket + }); default: assert< Equals @@ -254,7 +258,7 @@ function Explorer_inner(props: Props) { }) } > - Go Back + {t("go back")} {bookmarkStatus.isBookmarked && !bookmarkStatus.isReadonly && ( @@ -267,7 +271,7 @@ function Explorer_inner(props: Props) { }); }} > - Delete bookmark + {t("delete bookmark")} )}
@@ -321,3 +325,12 @@ function Explorer_inner(props: Props) { ); } + +const { i18n } = declareComponentKeys< + | { K: "access denied"; P: { directoryPath: string } } + | { K: "bucket does not exist"; P: { bucket: string } } + | "go back" + | "delete bookmark" +>()("S3ExplorerExplorer"); + +export type I18n = typeof i18n; From 0fb75131b62d967e67b42e6df68ad6e3a8e5d4c0 Mon Sep 17 00:00:00 2001 From: garronej Date: Mon, 12 Jan 2026 13:59:12 +0100 Subject: [PATCH 47/59] Do not use offset for the first page for performance reasons --- web/src/core/adapters/sqlOlap/sqlOlap.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web/src/core/adapters/sqlOlap/sqlOlap.ts b/web/src/core/adapters/sqlOlap/sqlOlap.ts index 1ecbd6018..e9ac98883 100644 --- a/web/src/core/adapters/sqlOlap/sqlOlap.ts +++ b/web/src/core/adapters/sqlOlap/sqlOlap.ts @@ -278,7 +278,7 @@ export const createDuckDbSqlOlap = (params: { sourceUrl = sourceUrl_noRedirect; } - const sqlQuery = `SELECT * FROM ${(() => { + let sqlQuery = `SELECT * FROM ${(() => { switch (fileType) { case "csv": return `read_csv('${sourceUrl}')`; @@ -287,7 +287,11 @@ export const createDuckDbSqlOlap = (params: { case "json": return `read_json('${sourceUrl}')`; } - })()} LIMIT ${rowsPerPage} OFFSET ${rowsPerPage * (page - 1)}`; + })()} LIMIT ${rowsPerPage}`; + + if (page !== 1) { + sqlQuery += ` OFFSET ${rowsPerPage * (page - 1)}`; + } const conn = await db.connect(); const stmt = await conn.prepare(sqlQuery); From e1e055fe00dfe19b6ec1981af80aeca338203829 Mon Sep 17 00:00:00 2001 From: garronej Date: Mon, 12 Jan 2026 15:49:29 +0100 Subject: [PATCH 48/59] Stop re-mounting the explorer component on every navigation --- web/src/ui/pages/s3Explorer/Explorer.tsx | 25 +++--------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/web/src/ui/pages/s3Explorer/Explorer.tsx b/web/src/ui/pages/s3Explorer/Explorer.tsx index 55f61eb2f..9e2a43803 100644 --- a/web/src/ui/pages/s3Explorer/Explorer.tsx +++ b/web/src/ui/pages/s3Explorer/Explorer.tsx @@ -152,6 +152,8 @@ function Explorer_inner(props: Props) { const onOpenFile = useConstCallback( ({ basename }) => { + assert(isCurrentWorkingDirectoryLoaded); + //TODO use dataExplorer thunk if ( basename.endsWith(".parquet") || @@ -160,7 +162,7 @@ function Explorer_inner(props: Props) { ) { routes .dataExplorer({ - source: `s3://${directoryPath.replace(/\/$/g, "")}/${basename}` + source: `s3://${currentWorkingDirectoryView.directoryPath.replace(/\/$/g, "")}/${basename}` }) .push(); return; @@ -180,27 +182,6 @@ function Explorer_inner(props: Props) { const { cx, css, theme } = useStyles(); - if ( - isCurrentWorkingDirectoryLoaded && - currentWorkingDirectoryView.directoryPath !== directoryPath - ) { - return ( -
- -
- ); - } - if (!isCurrentWorkingDirectoryLoaded) { return (
Date: Mon, 12 Jan 2026 15:51:41 +0100 Subject: [PATCH 49/59] Better positioning of the explorer --- web/src/ui/pages/s3Explorer/Page.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index fbbbbccd9..9586932dd 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -90,8 +90,7 @@ function S3Explorer() {
@@ -433,9 +432,13 @@ function S3ProfileSelect() { const useStyles = tss.withName({ S3Explorer }).create(({ theme }) => ({ root: { - height: "100%" + height: "100%", + display: "flex", + flexDirection: "column", + overflow: "auto" }, explorer: { - marginTop: theme.spacing(4) + marginTop: theme.spacing(4), + flex: 1 } })); From 1b2c53b3b3105eda004ad8b42ddf318f54ed48e0 Mon Sep 17 00:00:00 2001 From: garronej Date: Mon, 12 Jan 2026 16:03:47 +0100 Subject: [PATCH 50/59] Fix navigation layout shift --- web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx b/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx index 5ac4cdf01..e90b92cd4 100644 --- a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx +++ b/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx @@ -442,7 +442,14 @@ export const Explorer = memo((props: ExplorerProps) => { ); if (bookmarkStatus.isBookmarked && bookmarkStatus.isReadonly) { - return ; + return ( + + ); } return ( From 9fe5cc270d82f9343b2190d408d77a9f5e36c566 Mon Sep 17 00:00:00 2001 From: garronej Date: Mon, 12 Jan 2026 16:13:12 +0100 Subject: [PATCH 51/59] Only offer 25 and 50 row in explorer --- web/src/ui/pages/dataExplorer/DataGrid.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/ui/pages/dataExplorer/DataGrid.tsx b/web/src/ui/pages/dataExplorer/DataGrid.tsx index 9c89481ee..e7e2a380e 100644 --- a/web/src/ui/pages/dataExplorer/DataGrid.tsx +++ b/web/src/ui/pages/dataExplorer/DataGrid.tsx @@ -140,7 +140,7 @@ export function DataGrid(params: { className?: string }) { paginationMode="server" rowCount={rowCount ?? 999999999} pageSizeOptions={(() => { - const pageSizeOptions = [25, 50, 100]; + const pageSizeOptions = [25, 50]; assert(pageSizeOptions.includes(rowsPerPage)); return pageSizeOptions; })()} From 7c24211b976b54f456ee3eb3b4c939a40a58c26f Mon Sep 17 00:00:00 2001 From: garronej Date: Mon, 12 Jan 2026 16:26:15 +0100 Subject: [PATCH 52/59] Improve breadcrumb navigation --- .../pages/fileExplorer/Explorer/Explorer.tsx | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx b/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx index e90b92cd4..1009bdf92 100644 --- a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx +++ b/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx @@ -47,6 +47,7 @@ import { ExplorerDownloadSnackbar } from "./ExplorerDownloadSnackbar"; import { IconButton } from "onyxia-ui/IconButton"; import { Icon } from "onyxia-ui/Icon"; import { getIconUrlByName } from "lazy-icons"; +import { keyframes } from "tss-react"; export type ExplorerProps = { /** @@ -424,13 +425,6 @@ export const Explorer = memo((props: ExplorerProps) => { onNavigate={onBreadcrumbNavigate} evtAction={evtBreadcrumbAction} /> - {isNavigating && ( - - )} {(() => { if (bookmarkStatus === undefined) { return null; @@ -459,6 +453,13 @@ export const Explorer = memo((props: ExplorerProps) => { /> ); })()} + {isNavigating && ( + + )}
Date: Thu, 15 Jan 2026 15:18:11 +0100 Subject: [PATCH 53/59] Tigny refactor --- .../_s3Next/s3ExplorerRootUiController/thunks.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts index fb1927022..dd932e321 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts @@ -49,12 +49,15 @@ export const thunks = { return { routeParams_toSet: routeParams }; } - const wrap = await dispatch( - s3ProfilesManagement.protectedThunks.getS3ConfigAndClientForExplorer() - ); + const { s3Profile } = + (await dispatch( + s3ProfilesManagement.protectedThunks.getS3ConfigAndClientForExplorer() + )) ?? {}; + + console.log(s3Profile); const routeParams_toSet: RouteParams = { - profile: wrap === undefined ? undefined : wrap.s3Profile.id, + profile: s3Profile === undefined ? undefined : s3Profile.id, path: "" }; From 26d5afe75a75dae60869f8769550557ab9dd9ca9 Mon Sep 17 00:00:00 2001 From: garronej Date: Mon, 19 Jan 2026 21:31:12 +0100 Subject: [PATCH 54/59] Move from profileId to profileName --- web/src/core/adapters/onyxiaApi/ApiTypes.ts | 4 +- web/src/core/adapters/onyxiaApi/onyxiaApi.ts | 7 +- web/src/core/bootstrap.ts | 2 +- .../core/ports/OnyxiaApi/DeploymentRegion.ts | 4 +- .../s3ExplorerRootUiController/selectors.ts | 20 +-- .../s3ExplorerRootUiController/state.ts | 6 +- .../s3ExplorerRootUiController/thunks.ts | 27 ++-- .../selectors.ts | 133 +++++++++++++----- .../s3ProfilesCreationUiController/state.ts | 15 +- .../s3ProfilesCreationUiController/thunks.ts | 35 +++-- .../resolveTemplatedBookmark.ts | 8 +- .../decoupledLogic/resolveTemplatedStsRole.ts | 7 +- .../decoupledLogic/s3Profiles.ts | 73 +++++++--- ...DefaultS3ProfilesAfterPotentialDeletion.ts | 38 +++-- .../decoupledLogic/userConfigsS3Bookmarks.ts | 49 ++++++- .../_s3Next/s3ProfilesManagement/thunks.ts | 117 +++++++++------ .../decoupledLogic/ProjectConfigs.ts | 3 + .../core/usecases/projectManagement/thunks.ts | 15 +- 18 files changed, 370 insertions(+), 193 deletions(-) diff --git a/web/src/core/adapters/onyxiaApi/ApiTypes.ts b/web/src/core/adapters/onyxiaApi/ApiTypes.ts index 6375eb3c5..b6b982ab0 100644 --- a/web/src/core/adapters/onyxiaApi/ApiTypes.ts +++ b/web/src/core/adapters/onyxiaApi/ApiTypes.ts @@ -83,6 +83,7 @@ export type ApiTypes = { }; data?: { S3?: ArrayOrNot<{ + profileName?: string; URL: string; pathStyleAccess?: true; @@ -94,6 +95,7 @@ export type ApiTypes = { { roleARN: string; roleSessionName: string; + profileName: string; } & ( | { claimName?: undefined } | { @@ -125,7 +127,7 @@ export type ApiTypes = { title: LocalizedString; description?: LocalizedString; tags?: LocalizedString[]; - forStsRoleSessionName?: string | string[]; + forProfileName?: string | string[]; } & ( | { claimName?: undefined } | { diff --git a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts index aa4f3a08c..4cfdaf770 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -377,6 +377,7 @@ export function createOnyxiaApi(params: { s3Config_api ): DeploymentRegion.S3Next.S3Profile => { return { + profileName: s3Config_api.profileName, url: s3Config_api.URL, pathStyleAccess: s3Config_api.pathStyleAccess ?? @@ -413,6 +414,8 @@ export function createOnyxiaApi(params: { role_api.roleARN, roleSessionName: role_api.roleSessionName, + profileName: + role_api.profileName, ...(role_api.claimName === undefined ? { @@ -462,10 +465,10 @@ export function createOnyxiaApi(params: { tags: bookmarkedDirectory_api.tags ?? [], - forStsRoleSessionNames: + forProfileNames: (() => { const v = - bookmarkedDirectory_api.forStsRoleSessionName; + bookmarkedDirectory_api.forProfileName; if ( v === diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index 8ad19f5a8..bfb666fcb 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.s3ProfilesManagement.protectedThunks.getS3ConfigAndClientForExplorer() + usecases.s3ProfilesManagement.protectedThunks.getS3ProfileAndClientForExplorer() ); if (result === undefined) { diff --git a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts index 5463f65c4..b3d4e6d27 100644 --- a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts +++ b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts @@ -174,6 +174,7 @@ export namespace DeploymentRegion { export namespace S3Next { /** https://github.com/InseeFrLab/onyxia-api/blob/main/docs/region-configuration.md#s3 */ export type S3Profile = { + profileName: string | undefined; url: string; pathStyleAccess: boolean; region: string | undefined; @@ -190,6 +191,7 @@ export namespace DeploymentRegion { export type StsRole = { roleARN: string; roleSessionName: string; + profileName: string; } & ( | { claimName: undefined; @@ -208,7 +210,7 @@ export namespace DeploymentRegion { title: LocalizedString; description: LocalizedString | undefined; tags: LocalizedString[]; - forStsRoleSessionNames: string[]; + forProfileNames: string[]; } & ( | { claimName: undefined; diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts index eef07a424..b78c0ac44 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts @@ -18,10 +18,9 @@ export const protectedSelectors = { }; export type View = { - selectedS3ProfileId: string | undefined; - selectedS3Profile_creationTime: number | undefined; + selectedProfileName: string | undefined; availableS3Profiles: { - id: string; + profileName: string; displayName: string; }[]; bookmarks: { @@ -48,8 +47,7 @@ const view = createSelector( if (routeParams.profile === undefined) { return { - selectedS3ProfileId: undefined, - selectedS3Profile_creationTime: undefined, + selectedProfileName: undefined, availableS3Profiles: [], bookmarks: [], s3UriPrefixObj: undefined, @@ -59,10 +57,10 @@ const view = createSelector( }; } - const selectedS3ProfileId = routeParams.profile; + const profileName = routeParams.profile; const s3Profile = s3Profiles.find( - s3Profile => s3Profile.id === selectedS3ProfileId + s3Profile => s3Profile.profileName === profileName ); // NOTE: We enforce this invariant while loading the route @@ -77,13 +75,9 @@ const view = createSelector( }); return { - selectedS3ProfileId, - selectedS3Profile_creationTime: - s3Profile.origin !== "created by user (or group project member)" - ? undefined - : s3Profile.creationTime, + selectedProfileName: profileName, availableS3Profiles: s3Profiles.map(s3Profile => ({ - id: s3Profile.id, + profileName: s3Profile.profileName, displayName: s3Profile.paramsOfCreateS3Client.url })), bookmarks: s3Profile.bookmarks, diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts index 4fa0b41a6..a2e2e8de1 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts @@ -44,11 +44,11 @@ export const { actions, reducer } = createUsecaseActions({ }, selectedS3ProfileUpdated: ( state, - { payload }: { payload: { s3ProfileId: string } } + { payload }: { payload: { profileName: string } } ) => { - const { s3ProfileId } = payload; + const { profileName } = payload; - state.routeParams.profile = s3ProfileId; + state.routeParams.profile = profileName; state.routeParams.path = ""; } } diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts index dd932e321..625a10097 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts @@ -16,15 +16,16 @@ export const thunks = { const { routeParams } = params; if (routeParams.profile !== undefined) { - const s3ProfileId = routeParams.profile; + const profileName = routeParams.profile; { const s3Profiles = s3ProfilesManagement.selectors.s3Profiles(getState()); if ( - s3Profiles.find(s3Profile => s3Profile.id === s3ProfileId) === - undefined + s3Profiles.find( + s3Profile => s3Profile.profileName === profileName + ) === undefined ) { return dispatch(thunks.load({ routeParams: { path: "" } })); } @@ -32,7 +33,7 @@ export const thunks = { await dispatch( s3ProfilesManagement.protectedThunks.changeIsDefault({ - s3ProfileId, + profileName, usecase: "explorer", value: true }) @@ -51,13 +52,13 @@ export const thunks = { const { s3Profile } = (await dispatch( - s3ProfilesManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfilesManagement.protectedThunks.getS3ProfileAndClientForExplorer() )) ?? {}; console.log(s3Profile); const routeParams_toSet: RouteParams = { - profile: s3Profile === undefined ? undefined : s3Profile.id, + profile: s3Profile === undefined ? undefined : s3Profile.profileName, path: "" }; @@ -90,15 +91,15 @@ export const thunks = { dispatch(actions.s3UrlUpdated({ s3UriPrefixObj })); }, updateSelectedS3Profile: - (params: { s3ProfileId: string }) => + (params: { profileName: string }) => async (...args) => { const [dispatch] = args; - const { s3ProfileId } = params; + const { profileName } = params; await dispatch( s3ProfilesManagement.protectedThunks.changeIsDefault({ - s3ProfileId, + profileName, usecase: "explorer", value: true }) @@ -106,7 +107,7 @@ export const thunks = { dispatch( actions.selectedS3ProfileUpdated({ - s3ProfileId + profileName }) ); }, @@ -123,15 +124,15 @@ export const thunks = { const [dispatch, getState] = args; - const { selectedS3ProfileId, s3UriPrefixObj, bookmarkStatus } = + const { selectedProfileName, s3UriPrefixObj, bookmarkStatus } = selectors.view(getState()); - assert(selectedS3ProfileId !== undefined); + assert(selectedProfileName !== undefined); assert(s3UriPrefixObj !== undefined); await dispatch( s3ProfilesManagement.protectedThunks.createDeleteOrUpdateBookmark({ - s3ProfileId: selectedS3ProfileId, + profileName: selectedProfileName, s3UriPrefixObj, action: bookmarkStatus.isBookmarked ? { diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts index 39a22c86a..4d0628ef1 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts @@ -6,6 +6,7 @@ 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 s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; const readyState = (rootState: RootState) => { const state = rootState[name]; @@ -27,55 +28,111 @@ const formValues = createSelector(readyState, state => { return state.formValues; }); -const formValuesErrors = createSelector(formValues, formValues => { - if (formValues === null) { - return null; - } +const existingProfileNames = createSelector( + isReady, + createSelector(readyState, state => { + if (state === null) { + return null; + } + return state.creationTimeOfProfileToEdit; + }), + s3ProfilesManagement.selectors.s3Profiles, + (isReady, creationTimeOfProfileToEdit, s3Profiles) => { + if (!isReady) { + 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; + assert(creationTimeOfProfileToEdit !== null); + + return s3Profiles + .filter(s3Profile => { + if (creationTimeOfProfileToEdit === undefined) { + return true; } - const value = formValues[key]; + if (s3Profile.origin !== "created by user (or group project member)") { + return true; + } - if ((value ?? "").trim() !== "") { - break required_fields; + if (s3Profile.creationTime === creationTimeOfProfileToEdit) { + return false; } - return "is required"; - } + return true; + }) + .map(s3Profile => s3Profile.profileName); + } +); - if (key === "url") { - const value = formValues[key]; +const formValuesErrors = createSelector( + isReady, + formValues, + existingProfileNames, + (isReady, formValues, existingProfileNames) => { + if (!isReady) { + return null; + } - try { - new URL(value.startsWith("http") ? value : `https://${value}`); - } catch { - return "must be an url"; + assert(formValues !== null); + assert(existingProfileNames !== null); + + const out: Record< + keyof typeof formValues, + | "must be an url" + | "is required" + | "not a valid access key id" + | "profile name already used" + | undefined + > = {} as any; + + for (const key of objectKeys(formValues)) { + out[key] = (() => { + required_fields: { + if ( + !( + key === "url" || + key === "profileName" || + (!formValues.isAnonymous && + (key === "accessKeyId" || key === "secretAccessKey")) + ) + ) { + break required_fields; + } + + const value = formValues[key]; + + if ((value ?? "").trim() !== "") { + break required_fields; + } + + return "is required"; } - } - return undefined; - })(); - } + if (key === "url") { + const value = formValues[key]; - return out; -}); + try { + new URL(value.startsWith("http") ? value : `https://${value}`); + } catch { + return "must be an url"; + } + } + + if (key === "profileName") { + const value = formValues[key]; + + if (existingProfileNames.includes(value)) { + return "profile name already used"; + } + } + + return undefined; + })(); + } + + return out; + } +); const isFormSubmittable = createSelector( isReady, diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/state.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/state.ts index 969ea6d16..98aaed094 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/state.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/state.ts @@ -12,13 +12,12 @@ export namespace State { export type Ready = { stateDescription: "ready"; formValues: Ready.FormValues; - s3ProfileCreationTime: number; - action: "Update existing S3 profile" | "Create new S3 profile"; + creationTimeOfProfileToEdit: number | undefined; }; export namespace Ready { export type FormValues = { - friendlyName: string; + profileName: string; url: string; region: string | undefined; pathStyleAccess: boolean; @@ -53,21 +52,17 @@ export const { reducer, actions } = createUsecaseActions({ payload }: { payload: { - creationTimeOfS3ProfileToEdit: number | undefined; + creationTimeOfProfileToEdit: number | undefined; initialFormValues: State.Ready["formValues"]; }; } ) => { - const { creationTimeOfS3ProfileToEdit, initialFormValues } = payload; + const { creationTimeOfProfileToEdit, initialFormValues } = payload; return id({ stateDescription: "ready", formValues: initialFormValues, - s3ProfileCreationTime: creationTimeOfS3ProfileToEdit ?? Date.now(), - action: - creationTimeOfS3ProfileToEdit === undefined - ? "Create new S3 profile" - : "Update existing S3 profile" + creationTimeOfProfileToEdit }); }, formValueChanged: ( diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts index 214f838c8..c3914c349 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts @@ -7,37 +7,34 @@ import * as deploymentRegionManagement from "core/usecases/deploymentRegionManag export const thunks = { initialize: - (params: { creationTimeOfS3ProfileToEdit: number | undefined }) => + (params: { + // NOTE: Undefined for creation + profileName_toUpdate: string | undefined; + }) => async (...args) => { - const { creationTimeOfS3ProfileToEdit } = params; + const { profileName_toUpdate } = params; const [dispatch, getState] = args; const s3Profiles = s3ProfilesManagement.selectors.s3Profiles(getState()); update_existing_config: { - if (creationTimeOfS3ProfileToEdit === undefined) { + if (profileName_toUpdate === undefined) { break update_existing_config; } - const s3Profile = s3Profiles - .filter( - s3Profile => - s3Profile.origin === - "created by user (or group project member)" - ) - .find( - s3Profile => - s3Profile.creationTime === creationTimeOfS3ProfileToEdit - ); + const s3Profile = s3Profiles.find( + s3Profile => s3Profile.profileName === profileName_toUpdate + ); assert(s3Profile !== undefined); + assert(s3Profile.origin === "created by user (or group project member)"); dispatch( actions.initialized({ - creationTimeOfS3ProfileToEdit, + creationTimeOfProfileToEdit: s3Profile.creationTime, initialFormValues: { - friendlyName: s3Profile.friendlyName, + profileName: s3Profile.profileName, url: s3Profile.paramsOfCreateS3Client.url, region: s3Profile.paramsOfCreateS3Client.region, pathStyleAccess: @@ -83,9 +80,9 @@ export const thunks = { if (s3Profiles_defaultValuesOfCreationForm === undefined) { dispatch( actions.initialized({ - creationTimeOfS3ProfileToEdit: undefined, + creationTimeOfProfileToEdit: undefined, initialFormValues: { - friendlyName: "", + profileName: "", url: "", region: undefined, pathStyleAccess: false, @@ -101,9 +98,9 @@ export const thunks = { dispatch( actions.initialized({ - creationTimeOfS3ProfileToEdit: undefined, + creationTimeOfProfileToEdit: undefined, initialFormValues: { - friendlyName: "", + profileName: "", url: s3Profiles_defaultValuesOfCreationForm.url, region: s3Profiles_defaultValuesOfCreationForm.region, pathStyleAccess: diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts index 85fa029dc..8b1f7f22e 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts @@ -10,7 +10,7 @@ export type ResolvedTemplateBookmark = { description: LocalizedString | undefined; tags: LocalizedString[]; s3UriPrefixObj: S3UriPrefixObj; - forStsRoleSessionNames: string[]; + forProfileNames: string[]; }; export async function resolveTemplatedBookmark(params: { @@ -29,7 +29,7 @@ export async function resolveTemplatedBookmark(params: { title: bookmark_region.title, description: bookmark_region.description, tags: bookmark_region.tags, - forStsRoleSessionNames: bookmark_region.forStsRoleSessionNames + forProfileNames: bookmark_region.forProfileNames }) ]; } @@ -130,8 +130,8 @@ export async function resolveTemplatedBookmark(params: { ? undefined : substituteLocalizedString(bookmark_region.description), tags: bookmark_region.tags.map(tag => substituteLocalizedString(tag)), - forStsRoleSessionNames: bookmark_region.forStsRoleSessionNames.map( - stsRoleSessionName => substituteTemplateString(stsRoleSessionName) + forProfileNames: bookmark_region.forProfileNames.map(profileName => + substituteTemplateString(profileName) ) }); }) diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedStsRole.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedStsRole.ts index 0e8186a07..384e0fe48 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedStsRole.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedStsRole.ts @@ -6,6 +6,7 @@ import { getValueAtPath } from "core/tools/Stringifyable"; export type ResolvedTemplateStsRole = { roleARN: string; roleSessionName: string; + profileName: string; }; export async function resolveTemplatedStsRole(params: { @@ -18,7 +19,8 @@ export async function resolveTemplatedStsRole(params: { return [ id({ roleARN: stsRole_region.roleARN, - roleSessionName: stsRole_region.roleSessionName + roleSessionName: stsRole_region.roleSessionName, + profileName: stsRole_region.profileName }) ]; } @@ -97,7 +99,8 @@ export async function resolveTemplatedStsRole(params: { return id({ roleARN: substituteTemplateString(stsRole_region.roleARN), - roleSessionName: substituteTemplateString(stsRole_region.roleSessionName) + roleSessionName: substituteTemplateString(stsRole_region.roleSessionName), + profileName: substituteTemplateString(stsRole_region.profileName) }); }) .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 3a6a8a714..3e884c7ee 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -1,7 +1,6 @@ import * as projectManagement from "core/usecases/projectManagement"; import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; -import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; import { assert, type Equals } from "tsafe"; import type { LocalizedString } from "core/ports/OnyxiaApi"; import type { ResolvedTemplateBookmark } from "./resolveTemplatedBookmark"; @@ -13,7 +12,7 @@ export type S3Profile = S3Profile.DefinedInRegion | S3Profile.CreatedByUser; export namespace S3Profile { type Common = { - id: string; + profileName: string; isXOnyxiaDefault: boolean; isExplorerConfig: boolean; bookmarks: Bookmark[]; @@ -28,7 +27,6 @@ export namespace S3Profile { origin: "created by user (or group project member)"; creationTime: number; paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts; - friendlyName: string; }; export type Bookmark = { @@ -80,9 +78,8 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { return { origin: "created by user (or group project member)", + profileName: c.friendlyName, creationTime: c.creationTime, - friendlyName: c.friendlyName, - id: `${c.creationTime}`, paramsOfCreateS3Client, isXOnyxiaDefault: false, isExplorerConfig: false, @@ -129,17 +126,22 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { role: resolvedTemplatedStsRole }; - const id = `region-${fnv1aHashToHex( - JSON.stringify([c.url, c.sts.oidcParams.clientId ?? ""]) - )}`; + const profileName = (() => { + if (resolvedTemplatedStsRole === undefined) { + assert(c.profileName !== undefined); + return c.profileName; + } + + return resolvedTemplatedStsRole.profileName; + })(); return { origin: "defined in region", - id, + profileName, bookmarks: [ ...resolvedTemplatedBookmarks_forThisProfile - .filter(({ forStsRoleSessionNames }) => { - if (forStsRoleSessionNames.length === 0) { + .filter(({ forProfileNames }) => { + if (forProfileNames.length === 0) { return true; } @@ -166,13 +168,12 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { ); }; - return forStsRoleSessionNames.some( - stsRoleSessionName => - getDoMatch({ - stringWithWildcards: stsRoleSessionName, - candidate: - resolvedTemplatedStsRole.roleSessionName - }) + return forProfileNames.some(profileName => + getDoMatch({ + stringWithWildcards: profileName, + candidate: + resolvedTemplatedStsRole.profileName + }) ); }) .map(({ title, s3UriPrefixObj }) => ({ @@ -184,10 +185,10 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { userConfigs_s3BookmarksStr: fromVault.userConfigs_s3BookmarksStr }) - .filter(entry => entry.s3ProfileId === id) + .filter(entry => entry.profileName === profileName) .map(entry => ({ isReadonly: false, - displayName: entry.displayName, + displayName: entry.displayName ?? undefined, s3UriPrefixObj: entry.s3UriPrefixObj })) ], @@ -223,18 +224,44 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { .flat() ]; + for (const s3Profile of [...s3Profiles].sort((a, b) => { + if (a.origin === b.origin) { + return 0; + } + + return a.origin === "defined in region" ? -1 : 1; + })) { + const s3Profiles_conflicting = s3Profiles.filter( + s3Profile_i => + s3Profile_i !== s3Profile && + s3Profile_i.profileName === s3Profile.profileName + ); + + if (s3Profiles_conflicting.length === 0) { + continue; + } + + console.warn(`The is more than one s3Profile named: ${s3Profile.profileName}`); + + for (const s3Profile_conflicting of s3Profiles_conflicting) { + const i = s3Profiles.indexOf(s3Profile_conflicting); + + s3Profiles.splice(i, 1); + } + } + ( [ ["defaultXOnyxia", fromVault.projectConfigs_s3.s3ConfigId_defaultXOnyxia], ["explorer", fromVault.projectConfigs_s3.s3ConfigId_explorer] ] as const - ).forEach(([prop, s3ProfileId]) => { - if (s3ProfileId === undefined) { + ).forEach(([prop, profileName]) => { + if (profileName === undefined) { return; } const s3Profile = - s3Profiles.find(({ id }) => id === s3ProfileId) ?? + s3Profiles.find(s3Profile => s3Profile.profileName === profileName) ?? s3Profiles.find(s3Config => s3Config.origin === "defined in region"); if (s3Profile === undefined) { diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts index a40203cfa..49480fb09 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts @@ -3,13 +3,13 @@ import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; import { aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet } from "./s3Profiles"; type R = Record< - "s3ConfigId_defaultXOnyxia" | "s3ConfigId_explorer", + "profileName_defaultXOnyxia" | "profileName_explorer", | { isUpdateNeeded: false; } | { isUpdateNeeded: true; - s3ProfileId: string | undefined; + profileName: string | undefined; } >; @@ -34,35 +34,47 @@ export function updateDefaultS3ProfilesAfterPotentialDeletion(params: { }); const actions: R = { - s3ConfigId_defaultXOnyxia: { + profileName_defaultXOnyxia: { isUpdateNeeded: false }, - s3ConfigId_explorer: { + profileName_explorer: { isUpdateNeeded: false } }; for (const propertyName of [ - "s3ConfigId_defaultXOnyxia", - "s3ConfigId_explorer" + "s3ConfigId_defaultXOnyxia", // TODO: Rename + "s3ConfigId_explorer" // TODO: Rename ] as const) { - const s3ConfigId_default = fromVault.projectConfigs_s3[propertyName]; + const profileName_default = fromVault.projectConfigs_s3[propertyName]; - if (s3ConfigId_default === undefined) { + if (profileName_default === undefined) { continue; } - if (s3Profiles.find(({ id }) => id === s3ConfigId_default) !== undefined) { + if ( + s3Profiles.find(({ profileName }) => profileName === profileName_default) !== + undefined + ) { continue; } - const s3ConfigId_toUseAsDefault = s3Profiles.find( + const profileName_newDefault = s3Profiles.find( ({ origin }) => origin === "defined in region" - )?.id; + )?.profileName; - actions[propertyName] = { + actions[ + (() => { + switch (propertyName) { + case "s3ConfigId_defaultXOnyxia": + return "profileName_defaultXOnyxia"; + case "s3ConfigId_explorer": + return "profileName_explorer"; + } + })() + ] = { isUpdateNeeded: true, - s3ProfileId: s3ConfigId_toUseAsDefault + profileName: profileName_newDefault }; } diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts index 392b66b34..7a5c9495d 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts @@ -1,11 +1,44 @@ import type { S3UriPrefixObj } from "core/tools/S3Uri"; +import { z } from "zod"; +import { assert, type Equals, id, is } from "tsafe"; export type UserProfileS3Bookmark = { - s3ProfileId: string; - displayName: string | undefined; + profileName: string; + displayName: string | null; s3UriPrefixObj: S3UriPrefixObj; }; +const zS3UriPrefixObj = (() => { + type TargetType = S3UriPrefixObj; + + const zTargetType = z.object({ + bucket: z.string(), + keyPrefix: z.string() + }); + + type InferredType = z.infer; + + assert>; + + return id>(zTargetType); +})(); + +const zUserProfileS3Bookmark = (() => { + type TargetType = UserProfileS3Bookmark; + + const zTargetType = z.object({ + profileName: z.string(), + displayName: z.union([z.string(), z.null()]), + s3UriPrefixObj: zS3UriPrefixObj + }); + + type InferredType = z.infer; + + assert>; + + return id>(zTargetType); +})(); + export function parseUserConfigsS3BookmarksStr(params: { userConfigs_s3BookmarksStr: string | null; }): UserProfileS3Bookmark[] { @@ -15,7 +48,17 @@ export function parseUserConfigsS3BookmarksStr(params: { return []; } - return JSON.parse(userConfigs_s3BookmarksStr); + const userProfileS3Bookmarks: unknown = JSON.parse(userConfigs_s3BookmarksStr); + + try { + z.array(zUserProfileS3Bookmark).parse(userProfileS3Bookmarks); + } catch { + return []; + } + + assert(is(userProfileS3Bookmarks)); + + return userProfileS3Bookmarks; } export function serializeUserConfigsS3Bookmarks(params: { diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts index 8d9bdbb82..a3b48f5c3 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts @@ -3,7 +3,6 @@ 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 { updateDefaultS3ProfilesAfterPotentialDeletion } from "./decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion"; import structuredClone from "@ungap/structured-clone"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; @@ -22,10 +21,10 @@ import { import * as userConfigs from "core/usecases/userConfigs"; export const thunks = { - deleteS3Config: - (params: { s3ProfileCreationTime: number }) => + deleteS3Profile: + (params: { profileName: string }) => async (...args) => { - const { s3ProfileCreationTime } = params; + const { profileName } = params; const [dispatch, getState] = args; @@ -34,7 +33,7 @@ export const thunks = { ); const i = projectConfigs_s3.s3Configs.findIndex( - ({ creationTime }) => creationTime === s3ProfileCreationTime + s3Profile => s3Profile.friendlyName === profileName ); assert(i !== -1); @@ -54,19 +53,27 @@ export const thunks = { } }); - await Promise.all( - (["s3ConfigId_defaultXOnyxia", "s3ConfigId_explorer"] as const).map( - async propertyName => { - const action = actions[propertyName]; + for (const propertyName of [ + "profileName_defaultXOnyxia", + "profileName_explorer" + ] as const) { + const action = actions[propertyName]; - if (!action.isUpdateNeeded) { - return; - } + if (!action.isUpdateNeeded) { + continue; + } - projectConfigs_s3[propertyName] = action.s3ProfileId; - } - ) - ); + projectConfigs_s3[ + (() => { + switch (propertyName) { + case "profileName_defaultXOnyxia": + return "s3ConfigId_defaultXOnyxia"; + case "profileName_explorer": + return "s3ConfigId_explorer"; + } + })() + ] = action.profileName; + } } await dispatch( @@ -78,21 +85,22 @@ export const thunks = { } } satisfies Thunks; +const globalContext = { + prS3ClientByProfileName: new Map>() +}; + export const protectedThunks = { - getS3ClientForSpecificConfig: - (params: { s3ProfileId: string | undefined }) => + getS3Client: + (params: { profileName: string }) => async (...args): Promise => { - const { s3ProfileId } = params; + const { profileName } = 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 + s3Profile => s3Profile.profileName === profileName ); assert(s3Config !== undefined); @@ -100,7 +108,9 @@ export const protectedThunks = { })(); use_cached_s3Client: { - const prS3Client = prS3ClientByProfileId.get(s3Profile.id); + const prS3Client = globalContext.prS3ClientByProfileName.get( + s3Profile.profileName + ); if (prS3Client === undefined) { break use_cached_s3Client; @@ -189,11 +199,11 @@ export const protectedThunks = { ); })(); - prS3ClientByProfileId.set(s3Profile.id, prS3Client); + globalContext.prS3ClientByProfileName.set(s3Profile.profileName, prS3Client); return prS3Client; }, - getS3ConfigAndClientForExplorer: + getS3ProfileAndClientForExplorer: () => async ( ...args @@ -209,8 +219,8 @@ export const protectedThunks = { } const s3Client = await dispatch( - protectedThunks.getS3ClientForSpecificConfig({ - s3ProfileId: s3Profile.id + protectedThunks.getS3Client({ + profileName: s3Profile.profileName }) ); @@ -219,7 +229,7 @@ export const protectedThunks = { createOrUpdateS3Profile: (params: { s3Config_vault: projectManagement.ProjectConfigs.S3Config }) => async (...args) => { - const { s3Config_vault: s3Config_vault } = params; + const { s3Config_vault } = params; const [dispatch, getState] = args; @@ -232,6 +242,27 @@ export const protectedThunks = { projectS3Config_i.creationTime === s3Config_vault.creationTime ); + update_default_selected: { + if (i === -1) { + break update_default_selected; + } + + const s3Config_vault_current = fromVault.s3Configs[i]; + + if (s3Config_vault.friendlyName === s3Config_vault.friendlyName) { + break update_default_selected; + } + + for (const propertyName of [ + "s3ConfigId_defaultXOnyxia", + "s3ConfigId_explorer" + ] as const) { + if (fromVault[propertyName] === s3Config_vault_current.friendlyName) { + fromVault[propertyName] = s3Config_vault.friendlyName; + } + } + } + if (i < 0) { fromVault.s3Configs.push(s3Config_vault); } else { @@ -247,7 +278,7 @@ export const protectedThunks = { }, createDeleteOrUpdateBookmark: (params: { - s3ProfileId: string; + profileName: string; s3UriPrefixObj: S3UriPrefixObj; action: | { @@ -259,13 +290,15 @@ export const protectedThunks = { }; }) => async (...args) => { - const { s3ProfileId, s3UriPrefixObj, action } = params; + const { profileName, s3UriPrefixObj, action } = params; const [dispatch, getState] = args; const s3Profiles = selectors.s3Profiles(getState()); - const s3Profile = s3Profiles.find(s3Profile => s3Profile.id === s3ProfileId); + const s3Profile = s3Profiles.find( + s3Profile => s3Profile.profileName === profileName + ); assert(s3Profile !== undefined); @@ -332,7 +365,7 @@ export const protectedThunks = { const index = userConfigs_s3Bookmarks.findIndex( entry => - entry.s3ProfileId === s3Profile.id && + entry.profileName === s3Profile.profileName && same(entry.s3UriPrefixObj, s3UriPrefixObj) ); @@ -340,8 +373,8 @@ export const protectedThunks = { case "create or update": { const bookmark_new = { - s3ProfileId: s3Profile.id, - displayName: action.displayName, + profileName: s3Profile.profileName, + displayName: action.displayName ?? null, s3UriPrefixObj }; @@ -375,12 +408,12 @@ export const protectedThunks = { }, changeIsDefault: (params: { - s3ProfileId: string; + profileName: string; usecase: "defaultXOnyxia" | "explorer"; value: boolean; }) => async (...args) => { - const { s3ProfileId, usecase, value } = params; + const { profileName, usecase, value } = params; const [dispatch, getState] = args; @@ -401,17 +434,17 @@ export const protectedThunks = { const s3ProfileId_currentDefault = fromVault[propertyName]; if (value) { - if (s3ProfileId_currentDefault === s3ProfileId) { + if (s3ProfileId_currentDefault === profileName) { return; } } else { - if (s3ProfileId_currentDefault !== s3ProfileId) { + if (s3ProfileId_currentDefault !== profileName) { return; } } } - fromVault[propertyName] = value ? s3ProfileId : undefined; + fromVault[propertyName] = value ? profileName : undefined; await dispatch( projectManagement.protectedThunks.updateConfigValue({ @@ -516,7 +549,3 @@ export const protectedThunks = { ); } } satisfies Thunks; - -const { getContext } = createUsecaseContextApi(() => ({ - prS3ClientByConfigId: new Map>() -})); diff --git a/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts b/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts index 7b0c151b9..9d19877b6 100644 --- a/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts +++ b/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts @@ -13,7 +13,9 @@ export type ProjectConfigs = { restorableConfigs: ProjectConfigs.RestorableServiceConfig[]; s3: { s3Configs: ProjectConfigs.S3Config[]; + // TODO: Rename to profileName_defaultXOnyxia s3ConfigId_defaultXOnyxia: string | undefined; + // TODO: Rename to profileName_explorer s3ConfigId_explorer: string | undefined; }; clusterNotificationCheckoutTime: number; @@ -22,6 +24,7 @@ export type ProjectConfigs = { export namespace ProjectConfigs { export type S3Config = { creationTime: number; + // TODO: Rename this to profileName friendlyName: string; url: string; region: string | undefined; diff --git a/web/src/core/usecases/projectManagement/thunks.ts b/web/src/core/usecases/projectManagement/thunks.ts index e61b18d42..11bedfedd 100644 --- a/web/src/core/usecases/projectManagement/thunks.ts +++ b/web/src/core/usecases/projectManagement/thunks.ts @@ -147,8 +147,8 @@ export const thunks = { let needUpdate = false; for (const propertyName of [ - "s3ConfigId_defaultXOnyxia", - "s3ConfigId_explorer" + "profileName_defaultXOnyxia", + "profileName_explorer" ] as const) { const action = actions[propertyName]; @@ -158,7 +158,16 @@ export const thunks = { needUpdate = true; - projectConfigs.s3[propertyName] = action.s3ProfileId; + projectConfigs.s3[ + (() => { + switch (propertyName) { + case "profileName_defaultXOnyxia": + return "s3ConfigId_defaultXOnyxia"; + case "profileName_explorer": + return "s3ConfigId_explorer"; + } + })() + ] = action.profileName; } if (!needUpdate) { From 8bd608ca97e5263b20fcc3190b09a63371454ab5 Mon Sep 17 00:00:00 2001 From: garronej Date: Tue, 20 Jan 2026 14:20:33 +0100 Subject: [PATCH 55/59] Correctly restore bookmark on edit --- .../selectors.ts | 60 ++++++++++++++----- .../s3ProfilesCreationUiController/thunks.ts | 5 +- .../decoupledLogic/s3Profiles.ts | 1 - 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts index 4d0628ef1..56e6aa880 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts @@ -7,6 +7,7 @@ import { id } from "tsafe/id"; import type { ProjectConfigs } from "core/usecases/projectManagement"; import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; +import * as projectManagement from "core/usecases/projectManagement"; const readyState = (rootState: RootState) => { const state = rootState[name]; @@ -180,14 +181,16 @@ const submittableFormValuesAsProjectS3Config = createSelector( if (state === null) { return null; } - return state.s3ProfileCreationTime; + return state.creationTimeOfProfileToEdit; }), + projectManagement.protectedSelectors.projectConfig, ( isReady, formValues, formattedFormValuesUrl, isFormSubmittable, - s3ProfileCreationTime + creationTimeOfProfileToEdit, + projectConfig ) => { if (!isReady) { return null; @@ -195,7 +198,7 @@ const submittableFormValuesAsProjectS3Config = createSelector( assert(formValues !== null); assert(formattedFormValuesUrl !== null); assert(isFormSubmittable !== null); - assert(s3ProfileCreationTime !== null); + assert(creationTimeOfProfileToEdit !== null); if (!isFormSubmittable) { return undefined; @@ -203,9 +206,30 @@ const submittableFormValuesAsProjectS3Config = createSelector( assert(formattedFormValuesUrl !== undefined); - return id({ - creationTime: s3ProfileCreationTime, - friendlyName: formValues.friendlyName.trim(), + const projectS3Config_current = (() => { + if (creationTimeOfProfileToEdit === undefined) { + return undefined; + } + + const projectS3Config_current = projectConfig.s3.s3Configs.find( + s3Config => s3Config.creationTime === creationTimeOfProfileToEdit + ); + + assert(projectS3Config_current !== undefined); + + return projectS3Config_current; + })(); + + return id< + Omit & { + creationTime: number | undefined; + } + >({ + creationTime: + projectS3Config_current === undefined + ? undefined + : projectS3Config_current.creationTime, + friendlyName: formValues.profileName.trim(), url: formattedFormValuesUrl, region: formValues.region?.trim(), pathStyleAccess: formValues.pathStyleAccess, @@ -224,8 +248,14 @@ const submittableFormValuesAsProjectS3Config = createSelector( }; })(), // TODO: Delete once we move on - workingDirectoryPath: "mybucket/my/prefix/", - bookmarks: [] + workingDirectoryPath: + projectS3Config_current === undefined + ? "mybucket/my/prefix/" + : projectS3Config_current.workingDirectoryPath, + bookmarks: + projectS3Config_current === undefined + ? [] + : projectS3Config_current.bookmarks }); } ); @@ -285,20 +315,18 @@ const urlStylesExamples = createSelector( } ); -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, + createSelector(readyState, state => { + if (state === null) { + return null; + } + return state.creationTimeOfProfileToEdit !== undefined; + }), ( isReady, formValues, diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts index c3914c349..796842440 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts @@ -134,7 +134,10 @@ export const thunks = { await dispatch( s3ProfilesManagement.protectedThunks.createOrUpdateS3Profile({ - s3Config_vault + s3Config_vault: { + ...s3Config_vault, + creationTime: s3Config_vault.creationTime ?? Date.now() + } }) ); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts index 3e884c7ee..fd773d289 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -83,7 +83,6 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { paramsOfCreateS3Client, isXOnyxiaDefault: false, isExplorerConfig: false, - // TODO: Actually store custom bookmarks bookmarks: (c.bookmarks ?? []).map( ({ displayName, s3UriPrefixObj }) => ({ displayName, From e7368838b414e287fc552946484ae1af241e2b54 Mon Sep 17 00:00:00 2001 From: garronej Date: Thu, 22 Jan 2026 15:40:48 +0100 Subject: [PATCH 56/59] Move from profileId to profileName (follow up) --- .../s3ExplorerRootUiController/selectors.ts | 22 ++- .../s3ExplorerRootUiController/thunks.ts | 8 +- web/src/core/usecases/fileExplorer/thunks.ts | 18 +-- web/src/core/usecases/launcher/selectors.ts | 30 ++-- web/src/core/usecases/launcher/state.ts | 4 +- web/src/core/usecases/launcher/thunks.ts | 85 ++++++----- .../core/usecases/userProfileForm/thunks.ts | 2 +- web/src/ui/i18n/resources/de.tsx | 55 +++++++ web/src/ui/i18n/resources/en.tsx | 50 +++++++ web/src/ui/i18n/resources/es.tsx | 53 +++++++ web/src/ui/i18n/resources/fi.tsx | 54 +++++++ web/src/ui/i18n/resources/fr.tsx | 55 +++++++ web/src/ui/i18n/resources/it.tsx | 59 +++++++- web/src/ui/i18n/resources/nl.tsx | 52 +++++++ web/src/ui/i18n/resources/no.tsx | 53 +++++++ web/src/ui/i18n/resources/zh-CN.tsx | 49 +++++++ web/src/ui/i18n/types.ts | 1 + .../ui/pages/launcher/LauncherMainCard.tsx | 17 +-- web/src/ui/pages/s3Explorer/Page.tsx | 65 +++++---- ...og.tsx => CreateOrUpdateProfileDialog.tsx} | 134 +++++++++--------- .../S3ConfigDialogs/S3ConfigDialogs.tsx | 16 ++- 21 files changed, 672 insertions(+), 210 deletions(-) rename web/src/ui/pages/s3Explorer/S3ConfigDialogs/{AddCustomS3ConfigDialog.tsx => CreateOrUpdateProfileDialog.tsx} (82%) diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts index b78c0ac44..39fc1593c 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts @@ -18,11 +18,9 @@ export const protectedSelectors = { }; export type View = { - selectedProfileName: string | undefined; - availableS3Profiles: { - profileName: string; - displayName: string; - }[]; + selectedS3ProfileName: string | undefined; + isSelectedS3ProfileEditable: boolean; + availableS3ProfileNames: string[]; bookmarks: { displayName: LocalizedString | undefined; s3UriPrefixObj: S3UriPrefixObj; @@ -47,8 +45,9 @@ const view = createSelector( if (routeParams.profile === undefined) { return { - selectedProfileName: undefined, - availableS3Profiles: [], + selectedS3ProfileName: undefined, + isSelectedS3ProfileEditable: false, + availableS3ProfileNames: [], bookmarks: [], s3UriPrefixObj: undefined, bookmarkStatus: { @@ -75,11 +74,10 @@ const view = createSelector( }); return { - selectedProfileName: profileName, - availableS3Profiles: s3Profiles.map(s3Profile => ({ - profileName: s3Profile.profileName, - displayName: s3Profile.paramsOfCreateS3Client.url - })), + selectedS3ProfileName: profileName, + isSelectedS3ProfileEditable: + s3Profile.origin === "created by user (or group project member)", + availableS3ProfileNames: s3Profiles.map(s3Profile => s3Profile.profileName), bookmarks: s3Profile.bookmarks, s3UriPrefixObj, bookmarkStatus: (() => { diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts index 625a10097..887d2c2eb 100644 --- a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts @@ -55,8 +55,6 @@ export const thunks = { s3ProfilesManagement.protectedThunks.getS3ProfileAndClientForExplorer() )) ?? {}; - console.log(s3Profile); - const routeParams_toSet: RouteParams = { profile: s3Profile === undefined ? undefined : s3Profile.profileName, path: "" @@ -124,15 +122,15 @@ export const thunks = { const [dispatch, getState] = args; - const { selectedProfileName, s3UriPrefixObj, bookmarkStatus } = + const { selectedS3ProfileName, s3UriPrefixObj, bookmarkStatus } = selectors.view(getState()); - assert(selectedProfileName !== undefined); + assert(selectedS3ProfileName !== undefined); assert(s3UriPrefixObj !== undefined); await dispatch( s3ProfilesManagement.protectedThunks.createDeleteOrUpdateBookmark({ - profileName: selectedProfileName, + profileName: selectedS3ProfileName, s3UriPrefixObj, action: bookmarkStatus.isBookmarked ? { diff --git a/web/src/core/usecases/fileExplorer/thunks.ts b/web/src/core/usecases/fileExplorer/thunks.ts index 528441827..9faa51706 100644 --- a/web/src/core/usecases/fileExplorer/thunks.ts +++ b/web/src/core/usecases/fileExplorer/thunks.ts @@ -167,7 +167,7 @@ const privateThunks = { ); const { s3Client, s3Profile } = await dispatch( - s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ProfileAndClientForExplorer() ).then(r => { assert(r !== undefined); return r; @@ -275,7 +275,7 @@ const privateThunks = { const { s3Object } = params; const s3Client = await dispatch( - s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ProfileAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -317,7 +317,7 @@ const privateThunks = { const { s3Objects } = params; const s3Client = await dispatch( - s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ProfileAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -518,7 +518,7 @@ const privateThunks = { ); const s3Client = await dispatch( - s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ProfileAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -550,7 +550,7 @@ export const protectedThunks = { const [dispatch] = args; const s3Client = await dispatch( - s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ProfileAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -670,7 +670,7 @@ export const thunks = { }) ); const s3Client = await dispatch( - s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ProfileAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -903,7 +903,7 @@ export const thunks = { ); const s3Client = await dispatch( - s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ProfileAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -1004,7 +1004,7 @@ export const thunks = { ); const s3Client = await dispatch( - s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ProfileAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -1040,7 +1040,7 @@ export const thunks = { assert(directoryPath !== undefined); const { s3Client, s3Profile } = await dispatch( - s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ProfileAndClientForExplorer() ).then(r => { assert(r !== undefined); return r; diff --git a/web/src/core/usecases/launcher/selectors.ts b/web/src/core/usecases/launcher/selectors.ts index 2f9400a50..67d724155 100644 --- a/web/src/core/usecases/launcher/selectors.ts +++ b/web/src/core/usecases/launcher/selectors.ts @@ -186,17 +186,8 @@ const s3ConfigSelect = createSelector( } return { - options: availableConfigs.map(s3Config => ({ - optionValue: s3Config.id, - label: { - dataSource: new URL(s3Config.paramsOfCreateS3Client.url).hostname, - friendlyName: - s3Config.origin === "created by user (or group project member)" - ? s3Config.friendlyName - : undefined - } - })), - selectedOptionValue: s3Config.s3ConfigId + options: availableConfigs.map(s3Config => s3Config.profileName), + selectedOptionValue: s3Config.s3ProfileName }; } ); @@ -220,7 +211,7 @@ const restorableConfig = createSelector( if (state === null) { return null; } - return state.s3Config.isChartUsingS3 ? state.s3Config.s3ConfigId : undefined; + return state.s3Config.isChartUsingS3 ? state.s3Config.s3ProfileName : undefined; }), helmValues, createSelector(readyState, state => { @@ -237,7 +228,7 @@ const restorableConfig = createSelector( catalogId, chartName, chartVersion, - s3ConfigId, + s3ProfileName, helmValues, helmValues_default ): projectManagement.ProjectConfigs.RestorableServiceConfig | null => { @@ -250,7 +241,7 @@ const restorableConfig = createSelector( assert(isShared !== null); assert(chartName !== null); assert(chartVersion !== null); - assert(s3ConfigId !== null); + assert(s3ProfileName !== null); assert(helmValues !== null); assert(helmValues_default !== null); @@ -265,7 +256,7 @@ const restorableConfig = createSelector( friendlyName, isShared, chartVersion, - s3ConfigId, + s3ConfigId: s3ProfileName, helmValuesPatch: diffPatch }; } @@ -318,7 +309,7 @@ const isDefaultConfiguration = createSelector( return null; } const { s3Config } = state; - return s3Config.isChartUsingS3 ? s3Config.s3ConfigId_default : undefined; + return s3Config.isChartUsingS3 ? s3Config.s3ProfileName_default : undefined; }), restorableConfig, ( @@ -326,7 +317,7 @@ const isDefaultConfiguration = createSelector( friendlyName_default, chartVersion_default, isShared_default, - s3ConfigId_default, + s3ProfileName_default, restorableConfig ) => { if (!isReady) { @@ -335,14 +326,15 @@ const isDefaultConfiguration = createSelector( assert(friendlyName_default !== null); assert(chartVersion_default !== null); assert(isShared_default !== null); - assert(s3ConfigId_default !== null); + assert(s3ProfileName_default !== null); assert(restorableConfig !== null); return ( restorableConfig.chartVersion === chartVersion_default && restorableConfig.isShared === isShared_default && restorableConfig.friendlyName === friendlyName_default && - restorableConfig.helmValuesPatch.length === 0 + restorableConfig.helmValuesPatch.length === 0 && + restorableConfig.s3ConfigId === s3ProfileName_default ); } ); diff --git a/web/src/core/usecases/launcher/state.ts b/web/src/core/usecases/launcher/state.ts index e9f3a5e4c..955150d5c 100644 --- a/web/src/core/usecases/launcher/state.ts +++ b/web/src/core/usecases/launcher/state.ts @@ -47,8 +47,8 @@ export declare namespace State { | { isChartUsingS3: false } | { isChartUsingS3: true; - s3ConfigId: string | undefined; - s3ConfigId_default: string | undefined; + s3ProfileName: string | undefined; + s3ProfileName_default: string | undefined; }; helmDependencies: { diff --git a/web/src/core/usecases/launcher/thunks.ts b/web/src/core/usecases/launcher/thunks.ts index c8ebf7961..9e2c650a8 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/_s3Next/s3ProfilesManagement"; +import * as s3ProfilesManagement 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"; @@ -121,7 +121,7 @@ export const thunks = { chartVersion: chartVersion_pinned, friendlyName, isShared, - s3ConfigId: s3ConfigId_pinned, + s3ConfigId: s3ProfileName_pinned, helmValuesPatch }, autoLaunch @@ -171,8 +171,8 @@ export const thunks = { const doInjectPersonalInfos = projectManagement.selectors.canInjectPersonalInfos(getState()); - const { s3ConfigId, s3ConfigId_default } = (() => { - const s3Configs = s3ConfigManagement.selectors + const { s3ProfileName, s3ProfileName_default } = (() => { + const s3Profiles = s3ProfilesManagement.selectors .s3Profiles(getState()) .filter(s3Config => doInjectPersonalInfos @@ -181,40 +181,41 @@ export const thunks = { "created by user (or group project member)" ); - const s3ConfigId_default = (() => { - const s3Config = s3Configs.find( + const s3ProfileName_default = (() => { + const s3Config = s3Profiles.find( s3Config => s3Config.isXOnyxiaDefault ); if (s3Config === undefined) { return undefined; } - return s3Config.id; + return s3Config.profileName; })(); - const s3ConfigId = (() => { - use_pinned_s3_config: { - if (s3ConfigId_pinned === undefined) { - break use_pinned_s3_config; + const s3ProfileName = (() => { + use_pinned_s3_profile: { + if (s3ProfileName_pinned === undefined) { + break use_pinned_s3_profile; } - const s3Config = s3Configs.find( - s3Config => s3Config.id === s3ConfigId_pinned + const s3Config = s3Profiles.find( + s3Profile => + s3Profile.profileName === s3ProfileName_pinned ); if (s3Config === undefined) { - break use_pinned_s3_config; + break use_pinned_s3_profile; } - return s3Config.id; + return s3Config.profileName; } - return s3ConfigId_default; + return s3ProfileName_default; })(); - return { s3ConfigId, s3ConfigId_default }; + return { s3ProfileName, s3ProfileName_default }; })(); const xOnyxiaContext = await dispatch( protectedThunks.getXOnyxiaContext({ - s3ConfigId, + s3ProfileName, doInjectPersonalInfos }) ); @@ -287,8 +288,8 @@ export const thunks = { ? { isChartUsingS3: false } : { isChartUsingS3: true, - s3ConfigId, - s3ConfigId_default + s3ProfileName, + s3ProfileName_default }, helmDependencies, @@ -576,9 +577,9 @@ const { getContext, setContext, getIsContextSet } = createUsecaseContextApi<{ export const protectedThunks = { getXOnyxiaContext: - (params: { s3ConfigId: string | undefined; doInjectPersonalInfos: boolean }) => + (params: { s3ProfileName: string | undefined; doInjectPersonalInfos: boolean }) => async (...args): Promise => { - const { s3ConfigId, doInjectPersonalInfos } = params; + const { s3ProfileName, doInjectPersonalInfos } = params; const [ dispatch, @@ -677,16 +678,16 @@ export const protectedThunks = { }; })(), s3: await (async () => { - const s3Config = (() => { - if (s3ConfigId === undefined) { + const s3Profile = (() => { + if (s3ProfileName === undefined) { return undefined; } - const s3Configs = - s3ConfigManagement.selectors.s3Profiles(getState()); + const s3Profiles = + s3ProfilesManagement.selectors.s3Profiles(getState()); - const s3Config = s3Configs.find( - s3Config => s3Config.id === s3ConfigId + const s3Config = s3Profiles.find( + s3Profile => s3Profile.profileName === s3ProfileName ); assert(s3Config !== undefined); @@ -694,13 +695,13 @@ export const protectedThunks = { return s3Config; })(); - if (s3Config === undefined) { + if (s3Profile === undefined) { return undefined; } const { host = "", port = 443 } = - s3Config.paramsOfCreateS3Client.url !== "" - ? parseUrl(s3Config.paramsOfCreateS3Client.url) + s3Profile.paramsOfCreateS3Client.url !== "" + ? parseUrl(s3Profile.paramsOfCreateS3Client.url) : {}; const s3: XOnyxiaContext["s3"] = { @@ -709,20 +710,18 @@ export const protectedThunks = { AWS_SECRET_ACCESS_KEY: undefined, AWS_SESSION_TOKEN: undefined, AWS_DEFAULT_REGION: - s3Config.paramsOfCreateS3Client.region ?? "us-east-1", + s3Profile.paramsOfCreateS3Client.region ?? "us-east-1", AWS_S3_ENDPOINT: host, port, - pathStyleAccess: s3Config.paramsOfCreateS3Client.pathStyleAccess, + pathStyleAccess: s3Profile.paramsOfCreateS3Client.pathStyleAccess, isAnonymous: false }; - if (s3Config.paramsOfCreateS3Client.isStsEnabled) { + if (s3Profile.paramsOfCreateS3Client.isStsEnabled) { const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ClientForSpecificConfig( - { - s3ProfileId: s3Config.id - } - ) + s3ProfilesManagement.protectedThunks.getS3Client({ + profileName: s3Profile.profileName + }) ); const tokens = await s3Client.getToken({ doForceRenew: false }); @@ -733,14 +732,14 @@ export const protectedThunks = { s3.AWS_SECRET_ACCESS_KEY = tokens.secretAccessKey; s3.AWS_SESSION_TOKEN = tokens.sessionToken; } else if ( - s3Config.paramsOfCreateS3Client.credentials !== undefined + s3Profile.paramsOfCreateS3Client.credentials !== undefined ) { s3.AWS_ACCESS_KEY_ID = - s3Config.paramsOfCreateS3Client.credentials.accessKeyId; + s3Profile.paramsOfCreateS3Client.credentials.accessKeyId; s3.AWS_SECRET_ACCESS_KEY = - s3Config.paramsOfCreateS3Client.credentials.secretAccessKey; + s3Profile.paramsOfCreateS3Client.credentials.secretAccessKey; s3.AWS_SESSION_TOKEN = - s3Config.paramsOfCreateS3Client.credentials.sessionToken; + s3Profile.paramsOfCreateS3Client.credentials.sessionToken; } return s3; diff --git a/web/src/core/usecases/userProfileForm/thunks.ts b/web/src/core/usecases/userProfileForm/thunks.ts index 357f79790..f9ead1aa4 100644 --- a/web/src/core/usecases/userProfileForm/thunks.ts +++ b/web/src/core/usecases/userProfileForm/thunks.ts @@ -123,7 +123,7 @@ export const protectedThunks = { xOnyxiaContext: await dispatch( launcher.protectedThunks.getXOnyxiaContext({ doInjectPersonalInfos: true, - s3ConfigId: undefined + s3ProfileName: undefined }) ), helmValuesYaml: "{}", diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index a12b37c7e..e88c12a21 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -495,6 +495,61 @@ export const translations: Translations<"de"> = { SecretsExplorerItems: { "empty directory": "Dieses Verzeichnis ist leer" }, + CreateOrUpdateProfileDialog: { + "dialog title": "Neue benutzerdefinierte S3-Konfiguration", + "dialog subtitle": + "Geben Sie ein benutzerdefiniertes Servicekonto an oder verbinden Sie einen anderen S3-kompatiblen Dienst", + cancel: "Abbrechen", + "save config": "Konfiguration speichern", + "update config": "Konfiguration aktualisieren", + "is required": "Dieses Feld ist erforderlich", + "must be an url": "Keine gültige URL", + "profile name already used": + "Ein anderes Profil mit demselben Namen existiert bereits", + "not a valid access key id": + "Dies sieht nicht nach einer gültigen Access-Key-ID aus", + "url textField label": "URL", + "url textField helper text": "URL des S3-Dienstes", + "region textField label": "AWS S3-Region", + "region textField helper text": + "Beispiel: eu-west-1, bei Unsicherheit leer lassen", + "account credentials": "Kontodaten", + "profileName textField label": "Profilname", + "profileName textField helper text": "Eindeutige Kennung dieses S3-Profils", + "isAnonymous switch label": "Anonymer Zugriff", + "isAnonymous switch helper text": + "Auf EIN stellen, wenn kein geheimer Zugriffsschlüssel erforderlich ist", + "accessKeyId textField label": "Access-Key-ID", + "accessKeyId textField helper text": "Beispiel: 1A2B3C4D5E6F7G8H9I0J", + "secretAccessKey textField label": "Geheimer Zugriffsschlüssel", + "sessionToken textField label": "Sitzungstoken", + "sessionToken textField helper text": "Optional, bei Unsicherheit leer lassen", + "url style": "URL-Stil", + "url style helper text": + "Geben Sie an, wie Ihr S3-Server die URL zum Herunterladen von Dateien formatiert.", + "path style label": ({ example }) => ( + <> + Pfad-Stil + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ), + "virtual-hosted style label": ({ example }) => ( + <> + Virtual-Hosted-Stil + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ) + }, MySecretsEditor: { "do not display again": "Nicht mehr anzeigen", "add an entry": "Einen Variable hinzufügen", diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 35d951105..48ec41772 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -482,6 +482,56 @@ export const translations: Translations<"en"> = { "can't be empty": "Can't be empty", "new directory": "New directory" }, + CreateOrUpdateProfileDialog: { + "dialog title": "New custom S3 configuration", + "dialog subtitle": + "Specify a custom service account or connect to another S3 compatible service", + cancel: "Cancel", + "save config": "Save configuration", + "update config": "Update configuration", + "is required": "This field is required", + "must be an url": "Not a valid URL", + "profile name already used": "Another profile with same name already exists", + "not a valid access key id": "This doesn't look like a valid access key id", + "url textField label": "URL", + "url textField helper text": "URL of the S3 service", + "region textField label": "AWS S3 Region", + "region textField helper text": "Example: eu-west-1, if not sure, leave empty", + "account credentials": "Account credentials", + "profileName textField label": "Profile Name", + "profileName textField helper text": "Unique identifier of this s3 profile", + "isAnonymous switch label": "Anonymous access", + "isAnonymous switch helper text": "Set to ON if no secret access key is required", + "accessKeyId textField label": "Access key ID", + "accessKeyId textField helper text": "Example: 1A2B3C4D5E6F7G8H9I0J", + "secretAccessKey textField label": "Secret access key", + "sessionToken textField label": "Session token", + "sessionToken textField helper text": "Optional, leave empty if not sure", + "url style": "URL style", + "url style helper text": `Specify how your S3 server formats the URL for downloading files.`, + "path style label": ({ example }) => ( + <> + Path style + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ), + "virtual-hosted style label": ({ example }) => ( + <> + Virtual-hosted style + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ) + }, MySecretsEditor: { "do not display again": "Don't display again", "add an entry": "Add a new variable", diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index c6320ad27..f97d75b86 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -496,6 +496,59 @@ export const translations: Translations<"en"> = { "header size": "Tamaño", "header policy": "Política" }, + CreateOrUpdateProfileDialog: { + "dialog title": "Nueva configuración S3 personalizada", + "dialog subtitle": + "Especifique una cuenta de servicio personalizada o conéctese a otro servicio compatible con S3", + cancel: "Cancelar", + "save config": "Guardar configuración", + "update config": "Actualizar configuración", + "is required": "Este campo es obligatorio", + "must be an url": "No es una URL válida", + "profile name already used": "Ya existe otro perfil con el mismo nombre", + "not a valid access key id": "No parece un ID de clave de acceso válido", + "url textField label": "URL", + "url textField helper text": "URL del servicio S3", + "region textField label": "Región de AWS S3", + "region textField helper text": + "Ejemplo: eu-west-1, si no está seguro, déjelo vacío", + "account credentials": "Credenciales de la cuenta", + "profileName textField label": "Nombre del perfil", + "profileName textField helper text": "Identificador único de este perfil S3", + "isAnonymous switch label": "Acceso anónimo", + "isAnonymous switch helper text": + "Poner en ON si no se requiere una clave de acceso secreta", + "accessKeyId textField label": "ID de clave de acceso", + "accessKeyId textField helper text": "Ejemplo: 1A2B3C4D5E6F7G8H9I0J", + "secretAccessKey textField label": "Clave de acceso secreta", + "sessionToken textField label": "Token de sesión", + "sessionToken textField helper text": "Opcional, deje vacío si no está seguro", + "url style": "Estilo de URL", + "url style helper text": + "Indique cómo su servidor S3 formatea la URL para descargar archivos.", + "path style label": ({ example }) => ( + <> + Estilo de ruta + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ), + "virtual-hosted style label": ({ example }) => ( + <> + Estilo de host virtual + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ) + }, MySecretsEditor: { "do not display again": "No mostrar de nuevo", "add an entry": "Agregar una nueva variable", diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 21dd86e50..55a477f95 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -482,6 +482,60 @@ export const translations: Translations<"fi"> = { "can't be empty": "Ei voi olla tyhjä", "new directory": "Uusi hakemisto" }, + CreateOrUpdateProfileDialog: { + "dialog title": "Uusi mukautettu S3-määritys", + "dialog subtitle": + "Määritä mukautettu palvelutili tai yhdistä toiseen S3-yhteensopivaan palveluun", + cancel: "Peruuta", + "save config": "Tallenna määritys", + "update config": "Päivitä määritys", + "is required": "Tämä kenttä on pakollinen", + "must be an url": "Ei kelvollinen URL-osoite", + "profile name already used": "Samanniminen profiili on jo olemassa", + "not a valid access key id": + "Tämä ei näytä kelvolliselta access key -tunnukselta", + "url textField label": "URL", + "url textField helper text": "S3-palvelun URL-osoite", + "region textField label": "AWS S3 -alue", + "region textField helper text": + "Esimerkki: eu-west-1, jos et ole varma, jätä tyhjäksi", + "account credentials": "Tilin tunnistetiedot", + "profileName textField label": "Profiilin nimi", + "profileName textField helper text": "Tämän S3-profiilin yksilöllinen tunniste", + "isAnonymous switch label": "Anonyymi pääsy", + "isAnonymous switch helper text": + "Aseta ON, jos salainen access key ei ole tarpeen", + "accessKeyId textField label": "Access key -tunnus", + "accessKeyId textField helper text": "Esimerkki: 1A2B3C4D5E6F7G8H9I0J", + "secretAccessKey textField label": "Salainen access key", + "sessionToken textField label": "Istuntotunnus", + "sessionToken textField helper text": + "Valinnainen, jätä tyhjäksi jos et ole varma", + "url style": "URL-tyyli", + "url style helper text": "Määritä, miten S3-palvelimesi muotoilee lataus-URL:n.", + "path style label": ({ example }) => ( + <> + Polkutyylinen + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ), + "virtual-hosted style label": ({ example }) => ( + <> + Virtual-hosted-tyyli + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ) + }, MySecretsEditor: { "do not display again": "Älä näytä uudelleen", "add an entry": "Lisää uusi muuttuja", diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 9c702654b..e6cf381e4 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -498,6 +498,61 @@ export const translations: Translations<"fr"> = { SecretsExplorerItems: { "empty directory": "Ce répertoire est vide" }, + CreateOrUpdateProfileDialog: { + "dialog title": "Nouvelle configuration S3 personnalisée", + "dialog subtitle": + "Spécifiez un compte de service personnalisé ou connectez-vous à un autre service compatible S3", + cancel: "Annuler", + "save config": "Enregistrer la configuration", + "update config": "Mettre à jour la configuration", + "is required": "Ce champ est requis", + "must be an url": "URL non valide", + "profile name already used": "Un autre profil portant le même nom existe déjà", + "not a valid access key id": + "Cela ne ressemble pas à un ID de clé d'accès valide", + "url textField label": "URL", + "url textField helper text": "URL du service S3", + "region textField label": "Région AWS S3", + "region textField helper text": + "Exemple : eu-west-1, si vous n'êtes pas sûr, laissez vide", + "account credentials": "Identifiants du compte", + "profileName textField label": "Nom du profil", + "profileName textField helper text": "Identifiant unique de ce profil S3", + "isAnonymous switch label": "Accès anonyme", + "isAnonymous switch helper text": + "Mettre sur ON si aucune clé d'accès secrète n'est requise", + "accessKeyId textField label": "ID de clé d'accès", + "accessKeyId textField helper text": "Exemple : 1A2B3C4D5E6F7G8H9I0J", + "secretAccessKey textField label": "Clé d'accès secrète", + "sessionToken textField label": "Jeton de session", + "sessionToken textField helper text": + "Optionnel, laissez vide si vous n'êtes pas sûr", + "url style": "Style d'URL", + "url style helper text": + "Indiquez comment votre serveur S3 formate l'URL pour télécharger des fichiers.", + "path style label": ({ example }) => ( + <> + Style chemin + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ), + "virtual-hosted style label": ({ example }) => ( + <> + Style virtual-hosted + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ) + }, MySecretsEditor: { "do not display again": "Ne plus afficher", "add an entry": "Ajouter une variable", diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index 239268146..2b48524a5 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -402,9 +402,8 @@ export const translations: Translations<"it"> = { la nostra documentazione .   - - Configurare il tuo Vault CLI locale - . + Configurare il tuo Vault CLI locale + . ) }, @@ -493,6 +492,60 @@ export const translations: Translations<"it"> = { SecretsExplorerItems: { "empty directory": "Questa cartella è vuota" }, + CreateOrUpdateProfileDialog: { + "dialog title": "Nuova configurazione S3 personalizzata", + "dialog subtitle": + "Specifica un account di servizio personalizzato o connettiti a un altro servizio compatibile con S3", + cancel: "Annulla", + "save config": "Salva configurazione", + "update config": "Aggiorna configurazione", + "is required": "Questo campo è obbligatorio", + "must be an url": "URL non valido", + "profile name already used": "Esiste già un altro profilo con lo stesso nome", + "not a valid access key id": "Non sembra un ID di access key valido", + "url textField label": "URL", + "url textField helper text": "URL del servizio S3", + "region textField label": "Regione AWS S3", + "region textField helper text": + "Esempio: eu-west-1, se non sei sicuro lascia vuoto", + "account credentials": "Credenziali dell'account", + "profileName textField label": "Nome profilo", + "profileName textField helper text": + "Identificatore univoco di questo profilo S3", + "isAnonymous switch label": "Accesso anonimo", + "isAnonymous switch helper text": + "Imposta su ON se non è necessaria una secret access key", + "accessKeyId textField label": "ID access key", + "accessKeyId textField helper text": "Esempio: 1A2B3C4D5E6F7G8H9I0J", + "secretAccessKey textField label": "Secret access key", + "sessionToken textField label": "Token di sessione", + "sessionToken textField helper text": "Opzionale, lascia vuoto se non sei sicuro", + "url style": "Stile URL", + "url style helper text": + "Specifica come il server S3 formatta l'URL per scaricare i file.", + "path style label": ({ example }) => ( + <> + Stile path + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ), + "virtual-hosted style label": ({ example }) => ( + <> + Stile virtual-hosted + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ) + }, MySecretsEditor: { "do not display again": "Non mostrare più", "add an entry": "Aggiungiere una variabile", diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index e5bc0f0e2..663cd2e35 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -493,6 +493,58 @@ export const translations: Translations<"nl"> = { SecretsExplorerItems: { "empty directory": "Deze bestandenlijst is leeg" }, + CreateOrUpdateProfileDialog: { + "dialog title": "Nieuwe aangepaste S3-configuratie", + "dialog subtitle": + "Specificeer een aangepast serviceaccount of verbind met een andere S3-compatibele dienst", + cancel: "Annuleren", + "save config": "Configuratie opslaan", + "update config": "Configuratie bijwerken", + "is required": "Dit veld is verplicht", + "must be an url": "Geen geldige URL", + "profile name already used": "Er bestaat al een ander profiel met dezelfde naam", + "not a valid access key id": "Dit lijkt geen geldige access key-id", + "url textField label": "URL", + "url textField helper text": "URL van de S3-dienst", + "region textField label": "AWS S3-regio", + "region textField helper text": "Voorbeeld: eu-west-1, bij twijfel leeg laten", + "account credentials": "Accountgegevens", + "profileName textField label": "Profielnaam", + "profileName textField helper text": "Unieke identificatie van dit S3-profiel", + "isAnonymous switch label": "Anonieme toegang", + "isAnonymous switch helper text": + "Zet op ON als geen geheime access key nodig is", + "accessKeyId textField label": "Access key-id", + "accessKeyId textField helper text": "Voorbeeld: 1A2B3C4D5E6F7G8H9I0J", + "secretAccessKey textField label": "Geheime access key", + "sessionToken textField label": "Sessietoken", + "sessionToken textField helper text": "Optioneel, leeg laten bij twijfel", + "url style": "URL-stijl", + "url style helper text": + "Geef aan hoe je S3-server de URL voor het downloaden van bestanden opmaakt.", + "path style label": ({ example }) => ( + <> + Padstijl + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ), + "virtual-hosted style label": ({ example }) => ( + <> + Virtual-hosted-stijl + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ) + }, MySecretsEditor: { "do not display again": "Niet meer weergeven", "add an entry": "Een variabele toevoegen", diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 8d6ff4a22..437babdc8 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -491,6 +491,59 @@ export const translations: Translations<"no"> = { "header size": "Størrelse", "header policy": "Retningslinje" }, + CreateOrUpdateProfileDialog: { + "dialog title": "Ny tilpasset S3-konfigurasjon", + "dialog subtitle": + "Angi en tilpasset tjenestekonto eller koble til en annen S3-kompatibel tjeneste", + cancel: "Avbryt", + "save config": "Lagre konfigurasjon", + "update config": "Oppdater konfigurasjon", + "is required": "Dette feltet er påkrevd", + "must be an url": "Ugyldig URL", + "profile name already used": "En annen profil med samme navn finnes allerede", + "not a valid access key id": "Dette ser ikke ut som en gyldig access key-ID", + "url textField label": "URL", + "url textField helper text": "URL til S3-tjenesten", + "region textField label": "AWS S3-region", + "region textField helper text": + "Eksempel: eu-west-1, hvis du er usikker, la stå tomt", + "account credentials": "Kontoopplysninger", + "profileName textField label": "Profilnavn", + "profileName textField helper text": "Unik identifikator for denne S3-profilen", + "isAnonymous switch label": "Anonym tilgang", + "isAnonymous switch helper text": + "Sett til PÅ hvis ingen hemmelig access key er nødvendig", + "accessKeyId textField label": "Access key-ID", + "accessKeyId textField helper text": "Eksempel: 1A2B3C4D5E6F7G8H9I0J", + "secretAccessKey textField label": "Hemmelig access key", + "sessionToken textField label": "Økttoken", + "sessionToken textField helper text": "Valgfritt, la stå tomt hvis du er usikker", + "url style": "URL-stil", + "url style helper text": + "Angi hvordan S3-serveren din formaterer URL-en for nedlasting av filer.", + "path style label": ({ example }) => ( + <> + Sti-stil + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ), + "virtual-hosted style label": ({ example }) => ( + <> + Virtual-hosted-stil + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ) + }, MySecretsEditor: { "do not display again": "Ikke vis igjen", "add an entry": "Legg til en ny variabel", diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index 35b02e127..cb8690d1b 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -453,6 +453,55 @@ export const translations: Translations<"zh-CN"> = { SecretsExplorerItems: { "empty directory": "此目录为空" }, + CreateOrUpdateProfileDialog: { + "dialog title": "新的自定义 S3 配置", + "dialog subtitle": "指定自定义服务账号或连接到其他兼容 S3 的服务", + cancel: "取消", + "save config": "保存配置", + "update config": "更新配置", + "is required": "此字段为必填项", + "must be an url": "不是有效的 URL", + "profile name already used": "已存在同名的配置文件", + "not a valid access key id": "看起来不是有效的 Access Key ID", + "url textField label": "URL", + "url textField helper text": "S3 服务的 URL", + "region textField label": "AWS S3 区域", + "region textField helper text": "示例:eu-west-1,不确定可留空", + "account credentials": "账号凭证", + "profileName textField label": "配置文件名称", + "profileName textField helper text": "此 S3 配置文件的唯一标识", + "isAnonymous switch label": "匿名访问", + "isAnonymous switch helper text": "若不需要 Secret Access Key,请设为开", + "accessKeyId textField label": "Access Key ID", + "accessKeyId textField helper text": "示例:1A2B3C4D5E6F7G8H9I0J", + "secretAccessKey textField label": "Secret Access Key", + "sessionToken textField label": "会话令牌", + "sessionToken textField helper text": "可选,不确定可留空", + "url style": "URL 样式", + "url style helper text": "指定你的 S3 服务器用于下载文件的 URL 格式。", + "path style label": ({ example }) => ( + <> + 路径样式 + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ), + "virtual-hosted style label": ({ example }) => ( + <> + 虚拟主机样式 + {example !== undefined && ( + <> + :  + {example}my-dataset.parquet + + )} + + ) + }, MySecretsEditor: { "do not display again": "不要再显示", "add an entry": "添加变量", diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index cd38cdb01..df0d6c1bc 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -21,6 +21,7 @@ export type ComponentKey = | import("ui/pages/fileExplorerEntry/FileExplorerDisabledDialog").I18n | import("ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog").I18n | import("ui/pages/s3Explorer/Explorer").I18n + | import("ui/pages/s3Explorer/S3ConfigDialogs/CreateOrUpdateProfileDialog").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/launcher/LauncherMainCard.tsx b/web/src/ui/pages/launcher/LauncherMainCard.tsx index a3ce7e8ac..253280280 100644 --- a/web/src/ui/pages/launcher/LauncherMainCard.tsx +++ b/web/src/ui/pages/launcher/LauncherMainCard.tsx @@ -77,13 +77,7 @@ export type Props = { | { projectS3ConfigLink: Link; selectedOption: string | undefined; - options: { - optionValue: string; - label: { - dataSource: string; - friendlyName: string | undefined; - }; - }[]; + options: string[]; onSelectedS3ConfigChange: (params: { s3ConfigId: string }) => void; } | undefined; @@ -317,12 +311,9 @@ export const LauncherMainCard = memo((props: Props) => { {" "} - {s3ConfigsSelect.options.map(({ label, optionValue }) => ( - - {label.friendlyName !== undefined - ? `${label.friendlyName} - ` - : ""} - {label.dataSource} + {s3ConfigsSelect.options.map(profileName => ( + + {profileName} ))} diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 9586932dd..5373bbac2 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -58,7 +58,7 @@ function S3Explorer() { evts: { evtS3ExplorerRootUiController } } = getCoreSync(); - const { selectedS3ProfileId, s3UriPrefixObj, bookmarkStatus } = useCoreState( + const { selectedS3ProfileName, s3UriPrefixObj, bookmarkStatus } = useCoreState( "s3ExplorerRootUiController", "view" ); @@ -105,14 +105,15 @@ function S3Explorer() { className={css({ marginTop: theme.spacing(5), display: - selectedS3ProfileId === undefined || s3UriPrefixObj !== undefined + selectedS3ProfileName === undefined || + s3UriPrefixObj !== undefined ? "none" : undefined })} /> {(() => { - if (selectedS3ProfileId === undefined) { + if (selectedS3ProfileName === undefined) { return

Create a profile

; } @@ -335,14 +336,17 @@ function S3ProfileSelect() { functions: { s3ExplorerRootUiController } } = getCoreSync(); - const { selectedS3ProfileId, selectedS3Profile_creationTime, availableS3Profiles } = - useCoreState("s3ExplorerRootUiController", "view"); + const { + selectedS3ProfileName, + availableS3ProfileNames, + isSelectedS3ProfileEditable + } = useCoreState("s3ExplorerRootUiController", "view"); const { css } = useStyles(); const { evtConfirmCustomS3ConfigDeletionDialogOpen, - evtAddCustomS3ConfigDialogOpen, + evtCreateOrUpdateProfileDialogOpen, evtMaybeAcknowledgeConfigVolatilityDialogOpen } = useConst(() => ({ evtConfirmCustomS3ConfigDeletionDialogOpen: @@ -351,9 +355,9 @@ function S3ProfileSelect() { S3ConfigDialogsProps["evtConfirmCustomS3ConfigDeletionDialogOpen"] > >(), - evtAddCustomS3ConfigDialogOpen: + evtCreateOrUpdateProfileDialogOpen: Evt.create< - UnpackEvt + UnpackEvt >(), evtMaybeAcknowledgeConfigVolatilityDialogOpen: Evt.create() @@ -365,7 +369,7 @@ function S3ProfileSelect() { S3 Profile - {selectedS3Profile_creationTime !== undefined && ( - - )} + {isSelectedS3ProfileEditable && + (() => { + assert(selectedS3ProfileName !== undefined); + + return ( + + ); + })()} ; }; -export const AddCustomS3ConfigDialog = memo((props: AddCustomS3ConfigDialogProps) => { - const { evtOpen } = props; +export const CreateOrUpdateProfileDialog = memo( + (props: CreateOrUpdateProfileDialogProps) => { + const { evtOpen } = props; - const { t } = useTranslation({ AddCustomS3ConfigDialog }); + const { t } = useTranslation({ CreateOrUpdateProfileDialog }); - const { - functions: { s3ProfilesCreationUiController } - } = getCoreSync(); + const { + functions: { s3ProfilesCreationUiController } + } = getCoreSync(); - const { isReady } = useCoreState("s3ProfilesCreationUiController", "main"); + const { isReady } = useCoreState("s3ProfilesCreationUiController", "main"); - useEvt( - ctx => - evtOpen.attach(ctx, ({ creationTimeOfS3ProfileToEdit }) => - s3ProfilesCreationUiController.initialize({ - creationTimeOfS3ProfileToEdit - }) - ), - [evtOpen] - ); + useEvt( + ctx => + evtOpen.attach(ctx, ({ profileName_toUpdate }) => + s3ProfilesCreationUiController.initialize({ + profileName_toUpdate + }) + ), + [evtOpen] + ); - const onCloseFactory = useCallbackFactory(([isSubmit]: [boolean]) => { - if (isSubmit) { - s3ProfilesCreationUiController.submit(); - } else { - s3ProfilesCreationUiController.reset(); - } - }); + const onCloseFactory = useCallbackFactory(([isSubmit]: [boolean]) => { + if (isSubmit) { + s3ProfilesCreationUiController.submit(); + } else { + s3ProfilesCreationUiController.reset(); + } + }); - const { classes } = useStyles(); + const { classes } = useStyles(); - return ( - } - buttons={ - - } - onClose={onCloseFactory(false)} - /> - ); -}); + return ( + } + buttons={ + + } + onClose={onCloseFactory(false)} + /> + ); + } +); -AddCustomS3ConfigDialog.displayName = symToStr({ - AddCustomS3ConfigDialog +CreateOrUpdateProfileDialog.displayName = symToStr({ + CreateOrUpdateProfileDialog }); -const useStyles = tss.withName({ AddCustomS3ConfigDialog }).create({ +const useStyles = tss.withName({ CreateOrUpdateProfileDialog }).create({ buttons: { display: "flex" } @@ -103,7 +105,7 @@ const Buttons = memo((props: ButtonsProps) => { const { css } = useButtonsStyles(); - const { t } = useTranslation({ AddCustomS3ConfigDialog }); + const { t } = useTranslation({ CreateOrUpdateProfileDialog }); if (!isReady) { return null; @@ -123,7 +125,7 @@ const Buttons = memo((props: ButtonsProps) => { }); const useButtonsStyles = tss - .withName(`${symToStr({ AddCustomS3ConfigDialog })}${symToStr({ Buttons })}`) + .withName(`${symToStr({ CreateOrUpdateProfileDialog })}${symToStr({ Buttons })}`) .create({}); const Body = memo(() => { @@ -138,7 +140,7 @@ const Body = memo(() => { const { classes, css, theme } = useBodyStyles(); - const { t } = useTranslation({ AddCustomS3ConfigDialog }); + const { t } = useTranslation({ CreateOrUpdateProfileDialog }); if (!isReady) { return null; @@ -151,18 +153,18 @@ const Body = memo(() => { className={css({ marginBottom: theme.spacing(6) })} - label={t("friendlyName textField label")} - helperText={t("friendlyName textField helper text")} + label={t("profileName textField label")} + helperText={t("profileName textField helper text")} helperTextError={ - formValuesErrors.friendlyName === undefined + formValuesErrors.profileName === undefined ? undefined - : t(formValuesErrors.friendlyName) + : t(formValuesErrors.profileName) } - defaultValue={formValues.friendlyName} + defaultValue={formValues.profileName} doOnlyShowErrorAfterFirstFocusLost onValueBeingTypedChange={({ value }) => s3ProfilesCreationUiController.changeValue({ - key: "friendlyName", + key: "profileName", value }) } @@ -352,7 +354,7 @@ const Body = memo(() => { }); const useBodyStyles = tss - .withName(`${symToStr({ AddCustomS3ConfigDialog })}${symToStr({ Body })}`) + .withName(`${symToStr({ CreateOrUpdateProfileDialog })}${symToStr({ Body })}`) .create(({ theme }) => ({ serverConfigFormGroup: { display: "flex", @@ -381,19 +383,15 @@ const { i18n } = declareComponentKeys< | "update config" | "is required" | "must be an url" + | "profile name already used" | "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" + | "profileName textField label" + | "profileName textField helper text" | "isAnonymous switch label" | "isAnonymous switch helper text" | "accessKeyId textField label" @@ -413,5 +411,5 @@ const { i18n } = declareComponentKeys< P: { example: string | undefined }; R: JSX.Element; } ->()({ AddCustomS3ConfigDialog }); +>()({ CreateOrUpdateProfileDialog }); export type I18n = typeof i18n; diff --git a/web/src/ui/pages/s3Explorer/S3ConfigDialogs/S3ConfigDialogs.tsx b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/S3ConfigDialogs.tsx index 6580ca930..78086075c 100644 --- a/web/src/ui/pages/s3Explorer/S3ConfigDialogs/S3ConfigDialogs.tsx +++ b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/S3ConfigDialogs.tsx @@ -3,25 +3,27 @@ import { type Props as ConfirmCustomS3ConfigDeletionDialogProps } from "./ConfirmCustomS3ConfigDeletionDialog"; import { - AddCustomS3ConfigDialog, - type AddCustomS3ConfigDialogProps -} from "./AddCustomS3ConfigDialog"; + CreateOrUpdateProfileDialog, + type CreateOrUpdateProfileDialogProps +} from "./CreateOrUpdateProfileDialog"; export type S3ConfigDialogsProps = { evtConfirmCustomS3ConfigDeletionDialogOpen: ConfirmCustomS3ConfigDeletionDialogProps["evtOpen"]; - evtAddCustomS3ConfigDialogOpen: AddCustomS3ConfigDialogProps["evtOpen"]; + evtCreateOrUpdateProfileDialogOpen: CreateOrUpdateProfileDialogProps["evtOpen"]; }; export function S3ConfigDialogs(props: S3ConfigDialogsProps) { - const { evtConfirmCustomS3ConfigDeletionDialogOpen, evtAddCustomS3ConfigDialogOpen } = - props; + const { + evtConfirmCustomS3ConfigDeletionDialogOpen, + evtCreateOrUpdateProfileDialogOpen + } = props; return ( <> - + ); } From 733f12932bb591af4d377ebeec0c5c8bfb753ba5 Mon Sep 17 00:00:00 2001 From: garronej Date: Mon, 26 Jan 2026 20:23:29 +0100 Subject: [PATCH 57/59] Temporary patch before deleting persistance of s3 profile defaults --- .../updateDefaultS3ProfilesAfterPotentialDeletion.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts index 49480fb09..5e4d04325 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts @@ -23,7 +23,12 @@ export function updateDefaultS3ProfilesAfterPotentialDeletion(params: { const s3Profiles = aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ fromRegion: { - s3Profiles: fromRegion.s3Profiles, + s3Profiles: fromRegion.s3Profiles.map(s3Profile => { + return { + ...s3Profile, + profileName: s3Profile.profileName ?? "" + }; + }), resolvedTemplatedBookmarks: undefined, resolvedTemplatedStsRoles: undefined }, From ab6a707c334ff40c1d87dd683da071de762cfcc5 Mon Sep 17 00:00:00 2001 From: garronej Date: Tue, 27 Jan 2026 02:53:52 +0100 Subject: [PATCH 58/59] Delete all legacy code --- web/src/core/bootstrap.ts | 1 - .../_s3Next/s3ProfilesManagement/selectors.ts | 20 +- .../core/usecases/fileExplorer/selectors.ts | 44 +- web/src/core/usecases/fileExplorer/thunks.ts | 17 - web/src/core/usecases/index.ts | 6 - .../projectConfigsMigration/index.ts | 1 - .../projectConfigsMigration.ts | 50 -- .../projectConfigsMigration/v0ToV1.ts | 323 ------------- .../core/usecases/projectManagement/thunks.ts | 6 - .../core/usecases/s3CodeSnippets/selectors.ts | 15 +- .../core/usecases/s3CodeSnippets/thunks.ts | 16 +- .../usecases/s3ConfigConnectionTest/index.ts | 3 - .../s3ConfigConnectionTest/selectors.ts | 13 - .../usecases/s3ConfigConnectionTest/state.ts | 107 ----- .../usecases/s3ConfigConnectionTest/thunks.ts | 55 --- .../core/usecases/s3ConfigCreation/index.ts | 3 - .../usecases/s3ConfigCreation/selectors.ts | 412 ---------------- .../core/usecases/s3ConfigCreation/state.ts | 101 ---- .../core/usecases/s3ConfigCreation/thunks.ts | 237 --------- .../decoupledLogic/getS3Configs.ts | 331 ------------- .../getWorkingDirectoryBucket.ts | 33 -- .../decoupledLogic/getWorkingDirectoryPath.ts | 51 -- .../decoupledLogic/projectS3ConfigId.ts | 23 - .../decoupledLogic/resolveS3AdminBookmarks.ts | 190 -------- ...eDefaultS3ConfigsAfterPotentialDeletion.ts | 71 --- .../core/usecases/s3ConfigManagement/index.ts | 4 - .../usecases/s3ConfigManagement/selectors.ts | 127 ----- .../core/usecases/s3ConfigManagement/state.ts | 36 -- .../usecases/s3ConfigManagement/thunks.ts | 356 -------------- web/src/ui/App/LeftBar.tsx | 37 +- web/src/ui/i18n/resources/de.tsx | 166 ------- web/src/ui/i18n/resources/en.tsx | 166 ------- web/src/ui/i18n/resources/es.tsx | 171 ------- web/src/ui/i18n/resources/fi.tsx | 169 ------- web/src/ui/i18n/resources/fr.tsx | 171 ------- web/src/ui/i18n/resources/it.tsx | 175 +------ web/src/ui/i18n/resources/nl.tsx | 169 ------- web/src/ui/i18n/resources/no.tsx | 168 ------- web/src/ui/i18n/resources/zh-CN.tsx | 155 ------ web/src/ui/i18n/types.ts | 30 +- web/src/ui/pages/fileExplorer/Page.tsx | 290 ------------ web/src/ui/pages/fileExplorer/index.ts | 3 - web/src/ui/pages/fileExplorer/route.ts | 34 -- .../FileExplorerDisabledDialog.stories.tsx | 15 - .../FileExplorerDisabledDialog.tsx | 50 -- web/src/ui/pages/fileExplorerEntry/Page.tsx | 135 ------ .../fileExplorerEntry/S3Entries/S3Entries.tsx | 39 -- .../S3Entries/S3EntryCard.tsx | 92 ---- web/src/ui/pages/fileExplorerEntry/index.ts | 4 - web/src/ui/pages/fileExplorerEntry/route.ts | 7 - web/src/ui/pages/home/Page.tsx | 11 +- web/src/ui/pages/index.ts | 6 - web/src/ui/pages/launcher/Page.tsx | 5 +- web/src/ui/pages/projectSettings/Page.tsx | 108 ----- .../ProjectSettingsS3ConfigTab.tsx | 168 ------- .../S3ConfigCard.tsx | 194 -------- .../AddCustomS3ConfigDialog.tsx | 448 ------------------ ...rmCustomS3ConfigDeletionDialog.stories.tsx | 37 -- .../ConfirmCustomS3ConfigDeletionDialog.tsx | 59 --- .../S3ConfigDialogs/S3ConfigDialogs.tsx | 27 -- .../S3ConfigDialogs/index.ts | 1 - .../TestS3ConnectionButton.stories.tsx | 49 -- .../TestS3ConnectionButton.tsx | 109 ----- .../ProjectSettingsS3ConfigTab/index.ts | 1 - .../ProjectSettingsSecurityInfosTab.tsx | 33 -- web/src/ui/pages/projectSettings/index.ts | 3 - web/src/ui/pages/projectSettings/route.ts | 25 - web/src/ui/pages/projectSettings/tabIds.ts | 3 - web/src/ui/pages/s3Explorer/Explorer.tsx | 4 +- .../headless}/Explorer/Explorer.tsx | 3 - .../Explorer/ExplorerButtonBar.stories.tsx | 0 .../headless}/Explorer/ExplorerButtonBar.tsx | 0 .../Explorer/ExplorerDownloadSnackbar.tsx | 0 .../ExplorerIcon/ExplorerIcon.stories.tsx | 0 .../Explorer/ExplorerIcon/ExplorerIcon.tsx | 0 .../headless}/Explorer/ExplorerIcon/icons.ts | 0 .../headless}/Explorer/ExplorerIcon/index.ts | 0 .../ExplorerItems/ExplorerItem.stories.tsx | 0 .../Explorer/ExplorerItems/ExplorerItem.tsx | 0 .../ExplorerItems/ExplorerItems.stories.tsx | 0 .../Explorer/ExplorerItems/ExplorerItems.tsx | 0 .../headless}/Explorer/ExplorerItems/index.ts | 0 .../ExplorerUploadModal.stories.tsx | 0 .../ExplorerUploadModal.tsx | 0 .../ExplorerUploadModalDropArea.stories.tsx | 0 .../ExplorerUploadModalDropArea.tsx | 0 .../ExplorerUploadProgress.stories.tsx | 0 .../ExplorerUploadProgress.tsx | 0 .../Explorer/ExplorerUploadModal/index.ts | 0 .../ListExplorerItems.stories.tsx | 0 .../ListExplorer/ListExplorerItems.tsx | 0 .../Explorer/PolicySwitch.stories.tsx | 0 .../headless}/Explorer/PolicySwitch.tsx | 0 .../headless}/Explorer/index.ts | 0 .../ShareFile/SelectTime.stories.tsx | 0 .../headless}/ShareFile/SelectTime.tsx | 0 .../ShareFile/ShareDialog.stories.tsx | 0 .../headless}/ShareFile/ShareDialog.tsx | 0 .../DirectoryOrFileDetailed.stories.tsx | 0 .../shared/DirectoryOrFileDetailed.tsx | 0 .../headless}/shared/tools.ts | 0 .../headless}/shared/types.ts | 0 102 files changed, 67 insertions(+), 6125 deletions(-) delete mode 100644 web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/index.ts delete mode 100644 web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/projectConfigsMigration.ts delete mode 100644 web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/v0ToV1.ts delete mode 100644 web/src/core/usecases/s3ConfigConnectionTest/index.ts delete mode 100644 web/src/core/usecases/s3ConfigConnectionTest/selectors.ts delete mode 100644 web/src/core/usecases/s3ConfigConnectionTest/state.ts delete mode 100644 web/src/core/usecases/s3ConfigConnectionTest/thunks.ts delete mode 100644 web/src/core/usecases/s3ConfigCreation/index.ts delete mode 100644 web/src/core/usecases/s3ConfigCreation/selectors.ts delete mode 100644 web/src/core/usecases/s3ConfigCreation/state.ts delete mode 100644 web/src/core/usecases/s3ConfigCreation/thunks.ts delete mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts delete mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryBucket.ts delete mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryPath.ts delete mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/projectS3ConfigId.ts delete mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/resolveS3AdminBookmarks.ts delete mode 100644 web/src/core/usecases/s3ConfigManagement/decoupledLogic/updateDefaultS3ConfigsAfterPotentialDeletion.ts delete mode 100644 web/src/core/usecases/s3ConfigManagement/index.ts delete mode 100644 web/src/core/usecases/s3ConfigManagement/selectors.ts delete mode 100644 web/src/core/usecases/s3ConfigManagement/state.ts delete mode 100644 web/src/core/usecases/s3ConfigManagement/thunks.ts delete mode 100644 web/src/ui/pages/fileExplorer/Page.tsx delete mode 100644 web/src/ui/pages/fileExplorer/index.ts delete mode 100644 web/src/ui/pages/fileExplorer/route.ts delete mode 100644 web/src/ui/pages/fileExplorerEntry/FileExplorerDisabledDialog.stories.tsx delete mode 100644 web/src/ui/pages/fileExplorerEntry/FileExplorerDisabledDialog.tsx delete mode 100644 web/src/ui/pages/fileExplorerEntry/Page.tsx delete mode 100644 web/src/ui/pages/fileExplorerEntry/S3Entries/S3Entries.tsx delete mode 100644 web/src/ui/pages/fileExplorerEntry/S3Entries/S3EntryCard.tsx delete mode 100644 web/src/ui/pages/fileExplorerEntry/index.ts delete mode 100644 web/src/ui/pages/fileExplorerEntry/route.ts delete mode 100644 web/src/ui/pages/projectSettings/Page.tsx delete mode 100644 web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/ProjectSettingsS3ConfigTab.tsx delete mode 100644 web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigCard.tsx delete mode 100644 web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx delete mode 100644 web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog.stories.tsx delete mode 100644 web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog.tsx delete mode 100644 web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/S3ConfigDialogs.tsx delete mode 100644 web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/index.ts delete mode 100644 web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/TestS3ConnectionButton.stories.tsx delete mode 100644 web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/TestS3ConnectionButton.tsx delete mode 100644 web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/index.ts delete mode 100644 web/src/ui/pages/projectSettings/ProjectSettingsSecurityInfosTab.tsx delete mode 100644 web/src/ui/pages/projectSettings/index.ts delete mode 100644 web/src/ui/pages/projectSettings/route.ts delete mode 100644 web/src/ui/pages/projectSettings/tabIds.ts rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/Explorer.tsx (99%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerButtonBar.stories.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerButtonBar.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerDownloadSnackbar.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerIcon/ExplorerIcon.stories.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerIcon/ExplorerIcon.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerIcon/icons.ts (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerIcon/index.ts (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerItems/ExplorerItem.stories.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerItems/ExplorerItem.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerItems/ExplorerItems.stories.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerItems/ExplorerItems.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerItems/index.ts (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerUploadModal/ExplorerUploadModal.stories.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerUploadModal/ExplorerUploadModal.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerUploadModal/ExplorerUploadModalDropArea.stories.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerUploadModal/ExplorerUploadModalDropArea.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerUploadModal/ExplorerUploadProgress.stories.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerUploadModal/ExplorerUploadProgress.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ExplorerUploadModal/index.ts (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ListExplorer/ListExplorerItems.stories.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/ListExplorer/ListExplorerItems.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/PolicySwitch.stories.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/PolicySwitch.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/Explorer/index.ts (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/ShareFile/SelectTime.stories.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/ShareFile/SelectTime.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/ShareFile/ShareDialog.stories.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/ShareFile/ShareDialog.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/shared/DirectoryOrFileDetailed.stories.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/shared/DirectoryOrFileDetailed.tsx (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/shared/tools.ts (100%) rename web/src/ui/pages/{fileExplorer => s3Explorer/headless}/shared/types.ts (100%) diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index bfb666fcb..fb85ccdc1 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -272,7 +272,6 @@ export async function bootstrapCore( } if (oidc.isUserLoggedIn) { - await dispatch(usecases.s3ConfigManagement.protectedThunks.initialize()); await dispatch(usecases.s3ProfilesManagement.protectedThunks.initialize()); } diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts index eef3feb18..591e5c434 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts @@ -8,6 +8,7 @@ import { } from "./decoupledLogic/s3Profiles"; import { name } from "./state"; import type { State as RootState } from "core/bootstrap"; +import * as userAuthentication from "core/usecases/userAuthentication"; const resolvedTemplatedBookmarks = createSelector( (state: RootState) => state[name], @@ -56,7 +57,24 @@ const s3Profiles = createSelector( }) ); -export const selectors = { s3Profiles }; +/** Can be used even when not authenticated */ +const isS3ExplorerEnabled = (rootState: RootState) => { + const { isUserLoggedIn } = userAuthentication.selectors.main(rootState); + + if (!isUserLoggedIn) { + const { s3Configs } = + deploymentRegionManagement.selectors.currentDeploymentRegion(rootState); + + return s3Configs.length !== 0; + } else { + return ( + s3Profiles(rootState).find(s3Profile => s3Profile.isExplorerConfig) !== + undefined + ); + } +}; + +export const selectors = { s3Profiles, isS3ExplorerEnabled }; export const protectedSelectors = { resolvedTemplatedBookmarks diff --git a/web/src/core/usecases/fileExplorer/selectors.ts b/web/src/core/usecases/fileExplorer/selectors.ts index 704f99a92..c0113c337 100644 --- a/web/src/core/usecases/fileExplorer/selectors.ts +++ b/web/src/core/usecases/fileExplorer/selectors.ts @@ -2,10 +2,7 @@ import type { State as RootState } from "core/bootstrap"; import { type State, name } from "./state"; import { createSelector } from "clean-architecture"; import * as userConfigs from "core/usecases/userConfigs"; -import * as s3ConfigManagement from "core/usecases/s3ConfigManagement"; -import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import { assert } from "tsafe/assert"; -import * as userAuthentication from "core/usecases/userAuthentication"; import { id } from "tsafe/id"; import type { S3Object } from "core/ports/S3Client"; import { join as pathJoin, relative as pathRelative } from "pathe"; @@ -292,29 +289,12 @@ const isNavigationOngoing = createSelector( state => state.ongoingNavigation !== undefined ); -const workingDirectoryPath = createSelector( - s3ConfigManagement.selectors.s3Configs, - s3Configs => { - const s3Config = s3Configs.find(s3Config => s3Config.isExplorerConfig); - assert(s3Config !== undefined); - return s3Config.workingDirectoryPath; - } -); - -const pathMinDepth = createSelector(workingDirectoryPath, workingDirectoryPath => { - // "jgarrone/" -> 0 - // "jgarrone/foo/" -> 1 - // "jgarrone/foo/bar/" -> 2 - return workingDirectoryPath.split("/").length - 2; -}); - const main = createSelector( createSelector(state, state => state.navigationError), uploadProgress, commandLogsEntries, currentWorkingDirectoryView, isNavigationOngoing, - pathMinDepth, createSelector(state, state => state.viewMode), shareView, isDownloadPreparing, @@ -324,7 +304,6 @@ const main = createSelector( commandLogsEntries, currentWorkingDirectoryView, isNavigationOngoing, - pathMinDepth, viewMode, shareView, isDownloadPreparing @@ -355,7 +334,6 @@ const main = createSelector( isNavigationOngoing, uploadProgress, commandLogsEntries, - pathMinDepth, viewMode, isDownloadPreparing }; @@ -368,7 +346,6 @@ const main = createSelector( isNavigationOngoing, uploadProgress, commandLogsEntries, - pathMinDepth, currentWorkingDirectoryView, viewMode, shareView, @@ -377,25 +354,8 @@ const main = createSelector( } ); -const isFileExplorerEnabled = (rootState: RootState) => { - const { isUserLoggedIn } = userAuthentication.selectors.main(rootState); - - if (!isUserLoggedIn) { - const { s3Configs } = - deploymentRegionManagement.selectors.currentDeploymentRegion(rootState); - - return s3Configs.length !== 0; - } else { - return ( - s3ConfigManagement.selectors - .s3Configs(rootState) - .find(s3Config => s3Config.isExplorerConfig) !== undefined - ); - } -}; - const directoryPath = createSelector(state, state => state.directoryPath); -export const protectedSelectors = { workingDirectoryPath, directoryPath, shareView }; +export const protectedSelectors = { directoryPath, shareView }; -export const selectors = { main, isFileExplorerEnabled }; +export const selectors = { main }; diff --git a/web/src/core/usecases/fileExplorer/thunks.ts b/web/src/core/usecases/fileExplorer/thunks.ts index 9faa51706..6c9983989 100644 --- a/web/src/core/usecases/fileExplorer/thunks.ts +++ b/web/src/core/usecases/fileExplorer/thunks.ts @@ -598,23 +598,6 @@ export const protectedThunks = { } satisfies Thunks; export const thunks = { - initialize: - (params: { directoryPath: string; viewMode: "list" | "block" }) => - async (...args) => { - const { directoryPath, viewMode } = params; - - const [dispatch] = args; - - dispatch(actions.viewModeChanged({ viewMode })); - - await dispatch( - privateThunks.navigate({ - directoryPath: directoryPath, - doListAgainIfSamePath: false - }) - ); - }, - changeCurrentDirectory: (params: { directoryPath: string }) => async (...args) => { diff --git a/web/src/core/usecases/index.ts b/web/src/core/usecases/index.ts index 7c2162826..eb73743b1 100644 --- a/web/src/core/usecases/index.ts +++ b/web/src/core/usecases/index.ts @@ -7,9 +7,6 @@ import * as secretExplorer from "./secretExplorer"; import * as launcher from "./launcher"; import * as podLogs from "./podLogs"; import * as restorableConfigManagement from "./restorableConfigManagement"; -import * as s3ConfigConnectionTest from "./s3ConfigConnectionTest"; -import * as s3ConfigCreation from "./s3ConfigCreation"; -import * as s3ConfigManagement from "./s3ConfigManagement"; import * as serviceDetails from "./serviceDetails"; import * as serviceManagement from "./serviceManagement"; import * as userAuthentication from "./userAuthentication"; @@ -39,9 +36,6 @@ export const usecases = { launcher, podLogs, restorableConfigManagement, - s3ConfigConnectionTest, - s3ConfigCreation, - s3ConfigManagement, serviceDetails, serviceManagement, userAuthentication, diff --git a/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/index.ts b/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/index.ts deleted file mode 100644 index 8ab4e9ee4..000000000 --- a/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./projectConfigsMigration"; diff --git a/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/projectConfigsMigration.ts b/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/projectConfigsMigration.ts deleted file mode 100644 index 81f0f398b..000000000 --- a/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/projectConfigsMigration.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { v0ToV1 } from "./v0ToV1"; -import type { SecretsManager } from "core/ports/SecretsManager"; -import type { ProjectConfigs } from "../ProjectConfigs"; -import { assert, type Equals } from "tsafe/assert"; -import { clearProjectConfigs } from "../clearProjectConfigs"; -import { join as pathJoin } from "pathe"; -import { secretToValue } from "../secretParsing"; - -export async function projectConfigsMigration(params: { - projectVaultTopDirPath_reserved: string; - secretsManager: SecretsManager; -}) { - const { projectVaultTopDirPath_reserved, secretsManager } = params; - - const modelVersion = await (async () => { - const key = "__modelVersion"; - - assert>>(); - - const modelVersion = await secretsManager - .get({ - path: pathJoin(projectVaultTopDirPath_reserved, key) - }) - .then( - ({ secret }) => secretToValue(secret) as number, - () => { - console.log("The above error is ok"); - return undefined; - } - ); - - return modelVersion ?? 0; - })(); - - try { - if (modelVersion < 1) { - await v0ToV1({ - projectVaultTopDirPath_reserved, - secretsManager - }); - } - } catch { - console.warn("Migration of the ProjectConfigs failed, clearing everything"); - - await clearProjectConfigs({ - projectVaultTopDirPath_reserved, - secretsManager - }); - } -} diff --git a/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/v0ToV1.ts b/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/v0ToV1.ts deleted file mode 100644 index 1c62cddd5..000000000 --- a/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/v0ToV1.ts +++ /dev/null @@ -1,323 +0,0 @@ -import { assert, type Equals } from "tsafe/assert"; -import type { StringifyableAtomic } from "core/tools/Stringifyable"; -import type { SecretsManager } from "core/ports/SecretsManager"; -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 = { - servicePassword: string; - restorableConfigs: ProjectConfigs.RestorableServiceConfig[]; - s3: { - customConfigs: ProjectConfigs.CustomS3Config[]; - indexForXOnyxia: number | undefined; - indexForExplorer: number | undefined; - }; - clusterNotificationCheckoutTime: number; - }; - - namespace ProjectConfigs { - export type CustomS3Config = { - url: string; - region: string; - workingDirectoryPath: string; - pathStyleAccess: boolean; - accountFriendlyName: string; - credentials: - | { - accessKeyId: string; - secretAccessKey: string; - sessionToken: string | undefined; - } - | undefined; - }; - - export type RestorableServiceConfig = { - friendlyName: string; - isShared: boolean | undefined; - catalogId: string; - chartName: string; - chartVersion: string; - formFieldsValueDifferentFromDefault: FormFieldValue[]; - }; - } - - type FormFieldValue = { - path: string[]; - value: FormFieldValue.Value; - }; - - namespace FormFieldValue { - export type Value = string | boolean | number | Value.Yaml; - - export namespace Value { - export type Yaml = { - type: "yaml"; - yamlStr: string; - }; - } - } -} - -export namespace v1 { - export type ProjectConfigs = { - __modelVersion: 1; - servicePassword: string; - restorableConfigs: ProjectConfigs.RestorableServiceConfig[]; - s3: { - s3Configs: ProjectConfigs.S3Config[]; - s3ConfigId_defaultXOnyxia: string | undefined; - s3ConfigId_explorer: string | undefined; - }; - clusterNotificationCheckoutTime: number; - }; - - export namespace ProjectConfigs { - export type S3Config = { - creationTime: number; - friendlyName: string; - url: string; - region: string | undefined; - workingDirectoryPath: string; - pathStyleAccess: boolean; - credentials: - | { - accessKeyId: string; - secretAccessKey: string; - 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; - catalogId: string; - chartName: string; - chartVersion: string; - s3ConfigId: string | undefined; - helmValuesPatch: { - path: (string | number)[]; - value: StringifyableAtomic | undefined; - }[]; - }; - } -} - -export async function v0ToV1(params: { - projectVaultTopDirPath_reserved: string; - secretsManager: SecretsManager; -}) { - const { projectVaultTopDirPath_reserved, secretsManager } = params; - - console.log("Performing v0 to v1 migration"); - - for (const key of [ - "servicePassword", - "s3", - "restorableConfigs", - "clusterNotificationCheckoutTime" - ] as const) { - assert>(); - - switch (key) { - case "servicePassword": - assert< - Equals - >(); - break; - case "restorableConfigs": - { - const path = pathJoin(projectVaultTopDirPath_reserved, key); - - assert(); - - const legacyValue = await secretsManager - .get({ path }) - .then( - ({ secret }) => - secretToValue(secret) as v0.ProjectConfigs[typeof key] - ); - - const newValue: v1.ProjectConfigs[typeof key] = []; - - legacyValue.forEach(restorableServiceConfig_legacy => { - newValue.push({ - friendlyName: restorableServiceConfig_legacy.friendlyName, - isShared: restorableServiceConfig_legacy.isShared, - catalogId: restorableServiceConfig_legacy.catalogId, - chartName: restorableServiceConfig_legacy.chartName, - chartVersion: restorableServiceConfig_legacy.chartVersion, - s3ConfigId: undefined, - helmValuesPatch: (() => { - const helmValuesPatch: { - path: (string | number)[]; - value: StringifyableAtomic | undefined; - }[] = []; - - restorableServiceConfig_legacy.formFieldsValueDifferentFromDefault.forEach( - formFieldValue => { - if (typeof formFieldValue.value === "object") { - assert(formFieldValue.value.type === "yaml"); - - let parsed: unknown; - - try { - parsed = YAML.parse( - formFieldValue.value.yamlStr - ); - } catch { - return undefined; - } - - if ( - typeof parsed !== "object" || - parsed === null - ) { - return; - } - - (function callee( - path: (string | number)[], - o: object - ) { - Object.entries(o).forEach( - ([segment, value]) => { - const newPath = [ - ...path, - segment - ]; - - if ( - typeof value === "object" && - value !== null - ) { - callee(newPath, value); - return; - } - - helmValuesPatch.push({ - path: newPath, - value - }); - } - ); - })(formFieldValue.path, parsed); - - return; - } - - helmValuesPatch.push({ - path: formFieldValue.path, - value: formFieldValue.value - }); - } - ); - - return helmValuesPatch; - })() - }); - }); - - await secretsManager.put({ - path, - secret: valueToSecret(newValue) - }); - } - break; - case "s3": - { - const path = pathJoin(projectVaultTopDirPath_reserved, key); - - assert(); - - const legacyValue = await secretsManager - .get({ path }) - .then( - ({ secret }) => - secretToValue(secret) as v0.ProjectConfigs[typeof key] - ); - - const newValue: v1.ProjectConfigs[typeof key] = { - s3Configs: [], - s3ConfigId_defaultXOnyxia: undefined, - s3ConfigId_explorer: undefined - }; - - legacyValue.customConfigs.forEach((customS3Config_legacy, i) => { - newValue.s3Configs.push({ - creationTime: Date.now() + i, - friendlyName: customS3Config_legacy.accountFriendlyName, - url: customS3Config_legacy.url, - region: customS3Config_legacy.region, - workingDirectoryPath: - customS3Config_legacy.workingDirectoryPath, - pathStyleAccess: customS3Config_legacy.pathStyleAccess, - credentials: customS3Config_legacy.credentials, - bookmarks: [] - }); - }); - - { - const s3Configs = getS3Configs({ - projectConfigsS3: newValue, - s3RegionConfigs: [], - configTestResults: [], - ongoingConfigTests: [], - resolvedAdminBookmarks: [], - username: "johndoe", - projectGroup: undefined, - groupProjects: [] - }); - - for (const [propertyName_legacy, propertyName] of [ - ["indexForXOnyxia", "s3ConfigId_defaultXOnyxia"], - ["indexForExplorer", "s3ConfigId_explorer"] - ] as const) { - if (legacyValue[propertyName_legacy] !== undefined) { - const entry = - newValue.s3Configs[legacyValue[propertyName_legacy]]; - - assert(entry !== undefined); - - const s3Config = s3Configs.find( - s3Config => - s3Config.origin === "project" && - s3Config.creationTime === entry.creationTime - ); - - assert(s3Config !== undefined); - - newValue[propertyName] = s3Config.id; - } else { - newValue[propertyName] = - "a-config-id-that-does-not-exist"; - } - } - } - - await secretsManager.put({ - path, - secret: valueToSecret(newValue) - }); - } - break; - case "clusterNotificationCheckoutTime": - assert< - Equals - >(); - break; - } - } -} diff --git a/web/src/core/usecases/projectManagement/thunks.ts b/web/src/core/usecases/projectManagement/thunks.ts index 11bedfedd..506c061ae 100644 --- a/web/src/core/usecases/projectManagement/thunks.ts +++ b/web/src/core/usecases/projectManagement/thunks.ts @@ -11,7 +11,6 @@ import { updateDefaultS3ProfilesAfterPotentialDeletion } from "core/usecases/_s3 import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import { getProjectVaultTopDirPath_reserved } from "./decoupledLogic/projectVaultTopDirPath_reserved"; import { secretToValue, valueToSecret } from "./decoupledLogic/secretParsing"; -import { projectConfigsMigration } from "./decoupledLogic/projectConfigsMigration"; import { symToStr } from "tsafe/symToStr"; import { type ProjectConfigs, zProjectConfigs } from "./decoupledLogic/ProjectConfigs"; import { clearProjectConfigs } from "./decoupledLogic/clearProjectConfigs"; @@ -69,11 +68,6 @@ export const thunks = { projectVaultTopDirPath }); - await projectConfigsMigration({ - secretsManager, - projectVaultTopDirPath_reserved - }); - const { projectConfigs } = await (async function getProjectConfig(): Promise<{ projectConfigs: ProjectConfigs; }> { diff --git a/web/src/core/usecases/s3CodeSnippets/selectors.ts b/web/src/core/usecases/s3CodeSnippets/selectors.ts index 976550963..44bf73eb0 100644 --- a/web/src/core/usecases/s3CodeSnippets/selectors.ts +++ b/web/src/core/usecases/s3CodeSnippets/selectors.ts @@ -2,7 +2,7 @@ import type { State as RootState } from "core/bootstrap"; import { name } from "./state"; import { createSelector } from "clean-architecture"; import { assert } from "tsafe/assert"; -import * as s3ConfigManagement from "core/usecases/s3ConfigManagement"; +import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; const state = (rootState: RootState) => rootState[name]; @@ -321,15 +321,16 @@ const main = createSelector( export const selectors = { main }; -const s3Config = createSelector(s3ConfigManagement.selectors.s3Configs, s3Configs => - s3Configs.find( - s3Config => - s3Config.origin === "deploymentRegion" && - s3Config.paramsOfCreateS3Client.isStsEnabled +const s3Profile = createSelector(s3ProfilesManagement.selectors.s3Profiles, s3Profiles => + s3Profiles.find( + s3Profile => + s3Profile.origin === "defined in region" && + s3Profile.paramsOfCreateS3Client.isStsEnabled && + s3Profile.isExplorerConfig ) ); export const privateSelectors = { - s3Config, + s3Profile, isRefreshing }; diff --git a/web/src/core/usecases/s3CodeSnippets/thunks.ts b/web/src/core/usecases/s3CodeSnippets/thunks.ts index 5b5a13eff..169bcb8b7 100644 --- a/web/src/core/usecases/s3CodeSnippets/thunks.ts +++ b/web/src/core/usecases/s3CodeSnippets/thunks.ts @@ -1,7 +1,7 @@ import type { Thunks } from "core/bootstrap"; import { actions } from "./state"; import { assert } from "tsafe/assert"; -import * as s3ConfigManagement from "core/usecases/s3ConfigManagement"; +import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; import type { Technology } from "./state"; import { parseUrl } from "core/tools/parseUrl"; import { privateSelectors } from "./selectors"; @@ -14,7 +14,7 @@ export const thunks = { () => (...args): boolean => { const [, getState] = args; - return privateSelectors.s3Config(getState()) !== undefined; + return privateSelectors.s3Profile(getState()) !== undefined; }, /** Refresh is expected to be called whenever the component that use this slice mounts */ refresh: @@ -30,24 +30,24 @@ export const thunks = { dispatch(actions.refreshStarted()); - const s3Config = privateSelectors.s3Config(getState()); + const s3Profile = privateSelectors.s3Profile(getState()); - assert(s3Config !== undefined); + assert(s3Profile !== undefined); const { region, host, port } = (() => { const { host, port = 443 } = parseUrl( - s3Config.paramsOfCreateS3Client.url + s3Profile.paramsOfCreateS3Client.url ); - const region = s3Config.paramsOfCreateS3Client.region; + const region = s3Profile.paramsOfCreateS3Client.region; return { region, host, port }; })(); const { tokens } = await (async () => { const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ClientForSpecificConfig({ - s3ConfigId: s3Config.id + s3ProfilesManagement.protectedThunks.getS3Client({ + profileName: s3Profile.profileName }) ); diff --git a/web/src/core/usecases/s3ConfigConnectionTest/index.ts b/web/src/core/usecases/s3ConfigConnectionTest/index.ts deleted file mode 100644 index 3f3843384..000000000 --- a/web/src/core/usecases/s3ConfigConnectionTest/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./state"; -export * from "./selectors"; -export * from "./thunks"; diff --git a/web/src/core/usecases/s3ConfigConnectionTest/selectors.ts b/web/src/core/usecases/s3ConfigConnectionTest/selectors.ts deleted file mode 100644 index 286f13ffd..000000000 --- a/web/src/core/usecases/s3ConfigConnectionTest/selectors.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { State as RootState } from "core/bootstrap"; -import { createSelector } from "clean-architecture"; -import { name } from "./state"; - -const state = (rootState: RootState) => rootState[name]; - -const configTestResults = createSelector(state, state => state.configTestResults); -const ongoingConfigTests = createSelector(state, state => state.ongoingConfigTests); - -export const protectedSelectors = { - configTestResults, - ongoingConfigTests -}; diff --git a/web/src/core/usecases/s3ConfigConnectionTest/state.ts b/web/src/core/usecases/s3ConfigConnectionTest/state.ts deleted file mode 100644 index 3fb6eecd7..000000000 --- a/web/src/core/usecases/s3ConfigConnectionTest/state.ts +++ /dev/null @@ -1,107 +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"; - -type State = { - configTestResults: ConfigTestResult[]; - ongoingConfigTests: OngoingConfigTest[]; -}; - -export type OngoingConfigTest = { - paramsOfCreateS3Client: ParamsOfCreateS3Client; - workingDirectoryPath: string; -}; - -export type ConfigTestResult = { - paramsOfCreateS3Client: ParamsOfCreateS3Client; - workingDirectoryPath: string; - result: - | { - isSuccess: true; - } - | { - isSuccess: false; - errorMessage: string; - }; -}; - -export const name = "s3ConfigConnectionTest"; - -export const { actions, reducer } = createUsecaseActions({ - name, - initialState: id({ - configTestResults: [], - ongoingConfigTests: [] - }), - reducers: { - testStarted: ( - state, - { - payload - }: { - payload: State["ongoingConfigTests"][number]; - } - ) => { - const { paramsOfCreateS3Client, workingDirectoryPath } = payload; - - if ( - state.ongoingConfigTests.find(e => - same(e, { paramsOfCreateS3Client, workingDirectoryPath }) - ) !== undefined - ) { - return; - } - - state.ongoingConfigTests.push({ - paramsOfCreateS3Client, - workingDirectoryPath - }); - }, - testCompleted: ( - state, - { - payload - }: { - payload: State["configTestResults"][number]; - } - ) => { - const { paramsOfCreateS3Client, workingDirectoryPath, result } = payload; - - remove_from_ongoing: { - const entry = state.ongoingConfigTests.find(e => - same(e, { paramsOfCreateS3Client, workingDirectoryPath }) - ); - - if (entry === undefined) { - break remove_from_ongoing; - } - - state.ongoingConfigTests.splice( - state.ongoingConfigTests.indexOf(entry), - 1 - ); - } - - remove_existing_result: { - const entry = state.configTestResults.find( - e => - same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) && - e.workingDirectoryPath === workingDirectoryPath - ); - - if (entry === undefined) { - break remove_existing_result; - } - - state.configTestResults.splice(state.configTestResults.indexOf(entry), 1); - } - - state.configTestResults.push({ - paramsOfCreateS3Client, - workingDirectoryPath, - result - }); - } - } -}); diff --git a/web/src/core/usecases/s3ConfigConnectionTest/thunks.ts b/web/src/core/usecases/s3ConfigConnectionTest/thunks.ts deleted file mode 100644 index c7deed032..000000000 --- a/web/src/core/usecases/s3ConfigConnectionTest/thunks.ts +++ /dev/null @@ -1,55 +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 = { - testS3Connection: - (params: { - paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts; - workingDirectoryPath: string; - }) => - async (...args) => { - const { paramsOfCreateS3Client, workingDirectoryPath } = params; - - const [dispatch] = args; - - dispatch( - actions.testStarted({ paramsOfCreateS3Client, workingDirectoryPath }) - ); - - const result = await (async () => { - const { createS3Client } = await import("core/adapters/s3Client"); - - const getOidc = () => { - assert(false); - }; - - const s3Client = createS3Client(paramsOfCreateS3Client, getOidc); - - try { - await s3Client.listObjects({ - path: workingDirectoryPath - }); - } catch (error) { - return { - isSuccess: false as const, - errorMessage: String(error) - }; - } - - return { isSuccess: true as const }; - })(); - - dispatch( - actions.testCompleted({ - paramsOfCreateS3Client, - workingDirectoryPath, - result - }) - ); - } -} satisfies Thunks; diff --git a/web/src/core/usecases/s3ConfigCreation/index.ts b/web/src/core/usecases/s3ConfigCreation/index.ts deleted file mode 100644 index 3f3843384..000000000 --- a/web/src/core/usecases/s3ConfigCreation/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./state"; -export * from "./selectors"; -export * from "./thunks"; diff --git a/web/src/core/usecases/s3ConfigCreation/selectors.ts b/web/src/core/usecases/s3ConfigCreation/selectors.ts deleted file mode 100644 index 7b8f264ee..000000000 --- a/web/src/core/usecases/s3ConfigCreation/selectors.ts +++ /dev/null @@ -1,412 +0,0 @@ -import type { State as RootState } from "core/bootstrap"; -import { createSelector } from "clean-architecture"; -import { name } from "./state"; -import { objectKeys } from "tsafe/objectKeys"; -import { assert, type Equals } from "tsafe/assert"; -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"; -import * as s3ConfigConnectionTest from "core/usecases/s3ConfigConnectionTest"; -import { same } from "evt/tools/inDepth/same"; -import { parseProjectS3ConfigId } from "core/usecases/s3ConfigManagement/decoupledLogic/projectS3ConfigId"; - -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 === "workingDirectoryPath" || - 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 formattedFormValuesWorkingDirectoryPath = createSelector( - isReady, - formValues, - formValuesErrors, - (isReady, formValues, formValuesErrors) => { - if (!isReady) { - return null; - } - assert(formValues !== null); - assert(formValuesErrors !== null); - - if (formValuesErrors.workingDirectoryPath !== undefined) { - return undefined; - } - - return ( - formValues.workingDirectoryPath - .trim() - .replace(/\/\//g, "/") // Remove double slashes if any - .replace(/^\//g, "") // Ensure no leading slash - .replace(/\/*$/g, "") + "/" - ); // Enforce trailing slash - } -); - -const action = createSelector(readyState, state => { - if (state === null) { - return null; - } - - return state.action; -}); - -const submittableFormValuesAsProjectS3Config = createSelector( - isReady, - formValues, - formattedFormValuesUrl, - formattedFormValuesWorkingDirectoryPath, - isFormSubmittable, - action, - ( - isReady, - formValues, - formattedFormValuesUrl, - formattedFormValuesWorkingDirectoryPath, - isFormSubmittable, - action - ) => { - if (!isReady) { - return null; - } - assert(formValues !== null); - assert(formattedFormValuesUrl !== null); - assert(formattedFormValuesWorkingDirectoryPath !== null); - assert(formattedFormValuesUrl !== null); - assert(formattedFormValuesWorkingDirectoryPath !== null); - assert(isFormSubmittable !== null); - assert(action !== null); - - if (!isFormSubmittable) { - return undefined; - } - - assert(formattedFormValuesUrl !== undefined); - assert(formattedFormValuesWorkingDirectoryPath !== undefined); - - return id({ - creationTime: (() => { - switch (action.type) { - case "create new config": - return action.creationTime; - case "update existing config": - return parseProjectS3ConfigId({ s3ConfigId: action.s3ConfigId }) - .creationTime; - } - assert>(false); - })(), - friendlyName: formValues.friendlyName.trim(), - url: formattedFormValuesUrl, - region: formValues.region?.trim(), - workingDirectoryPath: formattedFormValuesWorkingDirectoryPath, - 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 - }; - })(), - bookmarks: undefined - }); - } -); - -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, - formattedFormValuesWorkingDirectoryPath, - s3ConfigConnectionTest.protectedSelectors.configTestResults, - s3ConfigConnectionTest.protectedSelectors.ongoingConfigTests, - ( - isReady, - isFormSubmittable, - paramsOfCreateS3Client, - workingDirectoryPath, - configTestResults, - ongoingConfigTests - ): ConnectionTestStatus | null => { - if (!isReady) { - return null; - } - - assert(isFormSubmittable !== null); - assert(paramsOfCreateS3Client !== null); - assert(workingDirectoryPath !== null); - - if (!isFormSubmittable) { - return { status: "not tested" }; - } - - assert(paramsOfCreateS3Client !== undefined); - assert(workingDirectoryPath !== undefined); - - if ( - ongoingConfigTests.find( - e => - same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) && - e.workingDirectoryPath === workingDirectoryPath - ) !== undefined - ) { - return { status: "test ongoing" }; - } - - has_result: { - const { result } = - configTestResults.find( - e => - same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) && - e.workingDirectoryPath === workingDirectoryPath - ) ?? {}; - - 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, - formattedFormValuesWorkingDirectoryPath, - (isReady, formattedFormValuesUrl, formattedFormValuesWorkingDirectoryPath) => { - if (!isReady) { - return null; - } - - assert(formattedFormValuesUrl !== null); - assert(formattedFormValuesWorkingDirectoryPath !== null); - - if ( - formattedFormValuesUrl === undefined || - formattedFormValuesWorkingDirectoryPath === undefined - ) { - return undefined; - } - - const urlObject = new URL(formattedFormValuesUrl); - - const { bucket: bucketName, keyPrefix: objectNamePrefix } = parseS3UriPrefix({ - s3UriPrefix: `s3://${formattedFormValuesWorkingDirectoryPath}`, - strict: false - }); - - const domain = formattedFormValuesUrl - .split(urlObject.protocol)[1] - .split("//")[1] - .replace(/\/$/, ""); - - return { - pathStyle: `${domain}/${bucketName}/${objectNamePrefix}`, - virtualHostedStyle: `${bucketName}.${domain}/${objectNamePrefix}` - }; - } -); - -const isEditionOfAnExistingConfig = createSelector(isReady, action, (isReady, action) => { - if (!isReady) { - return null; - } - - assert(action !== null); - - return action.type === "update existing config"; -}); - -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, - formattedFormValuesWorkingDirectoryPath -}; - -export const selectors = { main }; diff --git a/web/src/core/usecases/s3ConfigCreation/state.ts b/web/src/core/usecases/s3ConfigCreation/state.ts deleted file mode 100644 index 170dd70a6..000000000 --- a/web/src/core/usecases/s3ConfigCreation/state.ts +++ /dev/null @@ -1,101 +0,0 @@ -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; - action: - | { type: "update existing config"; s3ConfigId: string } - | { type: "create new config"; creationTime: number }; - }; - - export namespace Ready { - export type FormValues = { - friendlyName: string; - url: string; - region: string | undefined; - workingDirectoryPath: string; - 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 = "s3ConfigCreation"; - -export const { reducer, actions } = createUsecaseActions({ - name, - initialState: id( - id({ - stateDescription: "not initialized" - }) - ), - reducers: { - initialized: ( - _state, - { - payload - }: { - payload: { - s3ConfigIdToEdit: string | undefined; - initialFormValues: State.Ready["formValues"]; - }; - } - ) => { - const { s3ConfigIdToEdit, initialFormValues } = payload; - - return id({ - stateDescription: "ready", - formValues: initialFormValues, - action: - s3ConfigIdToEdit === undefined - ? { - type: "create new config", - creationTime: Date.now() - } - : { - type: "update existing config", - s3ConfigId: s3ConfigIdToEdit - } - }); - }, - 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/s3ConfigCreation/thunks.ts b/web/src/core/usecases/s3ConfigCreation/thunks.ts deleted file mode 100644 index c55586c13..000000000 --- a/web/src/core/usecases/s3ConfigCreation/thunks.ts +++ /dev/null @@ -1,237 +0,0 @@ -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 s3ConfigManagement from "core/usecases/s3ConfigManagement"; -import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; -import { getWorkingDirectoryPath } from "core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryPath"; -import * as projectManagement from "core/usecases/projectManagement"; -import * as userAuthentication from "core/usecases/userAuthentication"; -import * as s3ConfigConnectionTest from "core/usecases/s3ConfigConnectionTest"; - -export const thunks = { - initialize: - (params: { s3ConfigIdToEdit: string | undefined }) => - async (...args) => { - const { s3ConfigIdToEdit } = params; - - const [dispatch, getState] = args; - - const s3Configs = s3ConfigManagement.selectors.s3Configs(getState()); - - update_existing_config: { - if (s3ConfigIdToEdit === undefined) { - break update_existing_config; - } - - const s3Config = s3Configs.find( - s3Config => s3Config.id === s3ConfigIdToEdit - ); - - assert(s3Config !== undefined); - assert(s3Config.origin === "project"); - - dispatch( - actions.initialized({ - s3ConfigIdToEdit, - initialFormValues: { - friendlyName: s3Config.friendlyName, - url: s3Config.paramsOfCreateS3Client.url, - region: s3Config.region, - workingDirectoryPath: s3Config.workingDirectoryPath, - pathStyleAccess: - s3Config.paramsOfCreateS3Client.pathStyleAccess, - ...(() => { - if ( - s3Config.paramsOfCreateS3Client.credentials === - undefined - ) { - return { - isAnonymous: true, - accessKeyId: undefined, - secretAccessKey: undefined, - sessionToken: undefined - }; - } - - return { - isAnonymous: false, - accessKeyId: - s3Config.paramsOfCreateS3Client.credentials - .accessKeyId, - secretAccessKey: - s3Config.paramsOfCreateS3Client.credentials - .secretAccessKey, - sessionToken: - s3Config.paramsOfCreateS3Client.credentials - .sessionToken - }; - })() - } - }) - ); - - return; - } - - const { s3ConfigCreationFormDefaults } = - deploymentRegionManagement.selectors.currentDeploymentRegion(getState()); - - if (s3ConfigCreationFormDefaults === undefined) { - dispatch( - actions.initialized({ - s3ConfigIdToEdit: undefined, - initialFormValues: { - friendlyName: "", - url: "", - region: undefined, - workingDirectoryPath: "", - pathStyleAccess: false, - isAnonymous: true, - accessKeyId: undefined, - secretAccessKey: undefined, - sessionToken: undefined - } - }) - ); - return; - } - - const workingDirectoryPath = - s3ConfigCreationFormDefaults.workingDirectory === undefined - ? undefined - : getWorkingDirectoryPath({ - context: (() => { - const project = - projectManagement.protectedSelectors.currentProject( - getState() - ); - const { isUserLoggedIn, user } = - userAuthentication.selectors.main(getState()); - - assert(isUserLoggedIn); - - return project.group === undefined - ? { - type: "personalProject" as const, - username: user.username - } - : { - type: "groupProject" as const, - projectGroup: project.group - }; - })(), - workingDirectory: s3ConfigCreationFormDefaults.workingDirectory - }); - - dispatch( - actions.initialized({ - s3ConfigIdToEdit: undefined, - initialFormValues: { - friendlyName: "", - url: s3ConfigCreationFormDefaults.url, - region: s3ConfigCreationFormDefaults.region, - workingDirectoryPath: workingDirectoryPath ?? "", - pathStyleAccess: - s3ConfigCreationFormDefaults.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 projectS3Config = - privateSelectors.submittableFormValuesAsProjectS3Config(getState()); - - assert(projectS3Config !== null); - assert(projectS3Config !== undefined); - - await dispatch( - s3ConfigManagement.protectedThunks.createS3Config({ - projectS3Config - }) - ); - - 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( - s3ConfigConnectionTest.protectedThunks.testS3Connection({ - paramsOfCreateS3Client: { - isStsEnabled: false, - url: projectS3Config.url, - pathStyleAccess: projectS3Config.pathStyleAccess, - region: projectS3Config.region, - credentials: projectS3Config.credentials - }, - workingDirectoryPath: projectS3Config.workingDirectoryPath - }) - ); - } -} satisfies Thunks; diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts deleted file mode 100644 index 767b5388e..000000000 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts +++ /dev/null @@ -1,331 +0,0 @@ -import * as projectManagement from "core/usecases/projectManagement"; -import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; -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 { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; -import { assert, type Equals } from "tsafe/assert"; -import { getProjectS3ConfigId } from "./projectS3ConfigId"; -import type * as s3ConfigConnectionTest from "core/usecases/s3ConfigConnectionTest"; -import type { LocalizedString } from "core/ports/OnyxiaApi"; -import type { ResolvedAdminBookmark } from "./resolveS3AdminBookmarks"; - -export type S3Config = S3Config.FromDeploymentRegion | S3Config.FromProject; - -export namespace S3Config { - type Common = { - id: string; - dataSource: string; - region: string | undefined; - workingDirectoryPath: string; - isXOnyxiaDefault: boolean; - isExplorerConfig: boolean; - }; - - export type FromDeploymentRegion = Common & { - origin: "deploymentRegion"; - paramsOfCreateS3Client: ParamsOfCreateS3Client; - locations: FromDeploymentRegion.Location[]; - }; - - export namespace FromDeploymentRegion { - export type Location = - | Location.Personal - | Location.Project - | Location.AdminBookmark; - - export namespace Location { - type Common = { directoryPath: string }; - - export type Personal = Common & { - type: "personal"; - }; - - export type Project = Common & { - type: "project"; - projectName: string; - }; - export type AdminBookmark = Common & { - type: "bookmark"; - title: LocalizedString; - description?: LocalizedString; - tags: LocalizedString[] | undefined; - }; - } - } - - export type FromProject = Common & { - origin: "project"; - paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts; - creationTime: number; - friendlyName: string; - connectionTestStatus: - | { status: "not tested" } - | { status: "test ongoing" } - | { status: "test failed"; errorMessage: string } - | { status: "test succeeded" }; - }; -} - -export function getS3Configs(params: { - projectConfigsS3: projectManagement.ProjectConfigs["s3"]; - s3RegionConfigs: DeploymentRegion.S3Config[]; - resolvedAdminBookmarks: ResolvedAdminBookmark[]; - configTestResults: s3ConfigConnectionTest.ConfigTestResult[]; - ongoingConfigTests: s3ConfigConnectionTest.OngoingConfigTest[]; - username: string; - projectGroup: string | undefined; - groupProjects: { - name: string; - group: string; - }[]; -}): S3Config[] { - const { - projectConfigsS3: { - s3Configs: s3ProjectConfigs, - s3ConfigId_defaultXOnyxia, - s3ConfigId_explorer - }, - s3RegionConfigs, - resolvedAdminBookmarks, - configTestResults, - ongoingConfigTests, - username, - projectGroup, - groupProjects - } = params; - - const getDataSource = (params: { - url: string; - pathStyleAccess: boolean; - workingDirectoryPath: string; - }): string => { - const { url, pathStyleAccess, workingDirectoryPath } = params; - - let out = url; - - out = out.replace(/^https?:\/\//, "").replace(/\/$/, ""); - - const { bucket: bucketName, keyPrefix: objectName } = parseS3UriPrefix({ - s3UriPrefix: `s3://${workingDirectoryPath}`, - strict: false - }); - - out = pathStyleAccess - ? `${out}/${bucketName}/${objectName}` - : `${bucketName}.${out}/${objectName}`; - - return out; - }; - - const getConnectionTestStatus = (params: { - workingDirectoryPath: string; - paramsOfCreateS3Client: ParamsOfCreateS3Client; - }): S3Config.FromProject["connectionTestStatus"] => { - const { workingDirectoryPath, paramsOfCreateS3Client } = params; - - if ( - ongoingConfigTests.find( - e => - same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) && - e.workingDirectoryPath === workingDirectoryPath - ) !== undefined - ) { - return { status: "test ongoing" }; - } - - has_result: { - const { result } = - configTestResults.find( - e => - same(e.paramsOfCreateS3Client, paramsOfCreateS3Client) && - e.workingDirectoryPath === workingDirectoryPath - ) ?? {}; - - if (result === undefined) { - break has_result; - } - - return result.isSuccess - ? { status: "test succeeded" } - : { status: "test failed", errorMessage: result.errorMessage }; - } - - return { status: "not tested" }; - }; - - const s3Configs: S3Config[] = [ - ...s3ProjectConfigs - .map((c): S3Config.FromProject => { - const id = getProjectS3ConfigId({ - creationTime: c.creationTime - }); - - const workingDirectoryPath = c.workingDirectoryPath; - 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: "project", - creationTime: c.creationTime, - friendlyName: c.friendlyName, - id, - dataSource: getDataSource({ - url, - pathStyleAccess, - workingDirectoryPath - }), - region, - workingDirectoryPath, - paramsOfCreateS3Client, - isXOnyxiaDefault: false, - isExplorerConfig: false, - connectionTestStatus: getConnectionTestStatus({ - paramsOfCreateS3Client, - workingDirectoryPath - }) - }; - }) - .sort((a, b) => b.creationTime - a.creationTime), - ...s3RegionConfigs.map((c, i): S3Config.FromDeploymentRegion => { - const id = `region-${fnv1aHashToHex( - JSON.stringify([c.url, c.sts.oidcParams.clientId ?? ""]) - )}`; - - const workingDirectoryContext = - projectGroup === undefined - ? { - type: "personalProject" as const, - username - } - : { - type: "groupProject" as const, - projectGroup - }; - - const workingDirectoryPath = getWorkingDirectoryPath({ - workingDirectory: c.workingDirectory, - context: workingDirectoryContext - }); - - const personalWorkingDirectoryPath = getWorkingDirectoryPath({ - workingDirectory: c.workingDirectory, - context: { - type: "personalProject" as const, - username - } - }); - - 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 - }; - - const adminBookmarks: S3Config.FromDeploymentRegion.Location.AdminBookmark[] = - (() => { - const entry = resolvedAdminBookmarks.find( - ({ s3ConfigIndex }) => s3ConfigIndex === i - ); - - if (entry === undefined) { - return []; - } - - return entry.bookmarkedDirectories.map( - ({ title, description, fullPath, tags }) => ({ - title, - description, - type: "bookmark", - directoryPath: fullPath, - tags - }) - ); - })(); - - const projectsLocations: S3Config.FromDeploymentRegion.Location.Project[] = - groupProjects.map(({ group }) => { - const directoryPath = getWorkingDirectoryPath({ - workingDirectory: c.workingDirectory, - context: { - type: "groupProject", - projectGroup: group - } - }); - return { type: "project", directoryPath, projectName: group }; - }); - - const dataSource = getDataSource({ - url, - pathStyleAccess, - workingDirectoryPath - }); - - return { - origin: "deploymentRegion", - id, - dataSource, - region, - workingDirectoryPath, - locations: [ - { type: "personal", directoryPath: personalWorkingDirectoryPath }, - ...projectsLocations, - ...adminBookmarks - ], - paramsOfCreateS3Client, - isXOnyxiaDefault: false, - isExplorerConfig: false - }; - }) - ]; - - ( - [ - ["defaultXOnyxia", s3ConfigId_defaultXOnyxia], - ["explorer", s3ConfigId_explorer] - ] as const - ).forEach(([prop, s3ConfigId]) => { - if (s3ConfigId === undefined) { - return; - } - - const s3Config = - s3Configs.find(({ id }) => id === s3ConfigId) ?? - s3Configs.find(s3Config => s3Config.origin === "deploymentRegion"); - - if (s3Config === undefined) { - return; - } - - switch (prop) { - case "defaultXOnyxia": - s3Config.isXOnyxiaDefault = true; - return; - case "explorer": - s3Config.isExplorerConfig = true; - return; - } - assert>(false); - }); - - return s3Configs; -} diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryBucket.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryBucket.ts deleted file mode 100644 index b776713dc..000000000 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryBucket.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { assert, type Equals } from "tsafe/assert"; -import type { DeploymentRegion } from "core/ports/OnyxiaApi"; - -export function getWorkingDirectoryBucketToCreate(params: { - workingDirectory: DeploymentRegion.S3Config["workingDirectory"]; - context: - | { - type: "personalProject"; - username: string; - } - | { - type: "groupProject"; - projectGroup: string; - }; -}): string | undefined { - const { workingDirectory, context } = params; - - switch (workingDirectory.bucketMode) { - case "shared": - return undefined; - case "multi": - return (() => { - switch (context.type) { - case "personalProject": - return `${workingDirectory.bucketNamePrefix}${context.username}`; - case "groupProject": - return `${workingDirectory.bucketNamePrefixGroup}${context.projectGroup}`; - } - assert>(false); - })(); - } - assert>(false); -} diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryPath.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryPath.ts deleted file mode 100644 index 8c310703c..000000000 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getWorkingDirectoryPath.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { assert, type Equals } from "tsafe/assert"; -import type { DeploymentRegion } from "core/ports/OnyxiaApi"; -import { getWorkingDirectoryBucketToCreate } from "./getWorkingDirectoryBucket"; - -export function getWorkingDirectoryPath(params: { - workingDirectory: DeploymentRegion.S3Config["workingDirectory"]; - context: - | { - type: "personalProject"; - username: string; - } - | { - type: "groupProject"; - projectGroup: string; - }; -}): string { - const { workingDirectory, context } = params; - - return ( - (() => { - switch (workingDirectory.bucketMode) { - case "multi": { - const bucketName = getWorkingDirectoryBucketToCreate({ - workingDirectory, - context - }); - assert(bucketName !== undefined); - return bucketName; - } - case "shared": - return [ - workingDirectory.bucketName, - (() => { - switch (context.type) { - case "personalProject": - return `${workingDirectory.prefix}${context.username}`; - case "groupProject": - return `${workingDirectory.prefixGroup}${context.projectGroup}`; - } - assert>(true); - })() - ].join("/"); - } - assert>(false); - })() - .trim() - .replace(/\/\//g, "/") // Remove double slashes if any - .replace(/^\//g, "") // Ensure no leading slash - .replace(/\/+$/g, "") + "/" // Enforce trailing slash - ); -} diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/projectS3ConfigId.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/projectS3ConfigId.ts deleted file mode 100644 index 4ed797345..000000000 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/projectS3ConfigId.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { assert } from "tsafe/assert"; - -const prefix = "project-"; - -export function getProjectS3ConfigId(params: { creationTime: number }): string { - const { creationTime } = params; - - return `${prefix}${creationTime}`; -} - -export function parseProjectS3ConfigId(params: { s3ConfigId: string }): { - creationTime: number; -} { - const { s3ConfigId } = params; - - const creationTimeStr = s3ConfigId.replace(prefix, ""); - - const creationTime = parseInt(creationTimeStr); - - assert(!isNaN(creationTime), "Not a valid s3 project config id"); - - return { creationTime }; -} diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/resolveS3AdminBookmarks.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/resolveS3AdminBookmarks.ts deleted file mode 100644 index 0de91ffa6..000000000 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/resolveS3AdminBookmarks.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { - DeploymentRegion, - LocalizedString, - OidcParams_Partial -} from "core/ports/OnyxiaApi"; -import { assert } from "tsafe/assert"; -import { id } from "tsafe/id"; -import memoizee from "memoizee"; - -export type DeploymentRegion_S3ConfigLike = { - sts: { - oidcParams: OidcParams_Partial; - }; - bookmarkedDirectories: DeploymentRegion.S3Config.BookmarkedDirectory[]; -}; - -assert; - -export type ResolvedAdminBookmark = { - s3ConfigIndex: number; - bookmarkedDirectories: DeploymentRegion.S3Config.BookmarkedDirectory.Common[]; -}; - -export async function resolveS3AdminBookmarks(params: { - deploymentRegion_s3Configs: DeploymentRegion_S3ConfigLike[]; - getDecodedIdToken: (params: { - oidcParams_partial: OidcParams_Partial; - }) => Promise>; -}): Promise<{ - resolvedAdminBookmarks: ResolvedAdminBookmark[]; -}> { - const { deploymentRegion_s3Configs, getDecodedIdToken } = params; - - const resolvedAdminBookmarks = await Promise.all( - deploymentRegion_s3Configs.map(async (s3Config, i) => { - const getDecodedIdToken_memo = memoizee( - () => - getDecodedIdToken({ - oidcParams_partial: s3Config.sts.oidcParams - }), - { promise: true } - ); - - return id({ - s3ConfigIndex: i, - bookmarkedDirectories: ( - await Promise.all( - s3Config.bookmarkedDirectories.map(async entry => { - if (entry.claimName === undefined) { - return [ - id( - { - fullPath: entry.fullPath, - description: entry.description, - tags: entry.tags, - title: entry.title - } - ) - ]; - } - - const { - claimName, - excludedClaimPattern, - includedClaimPattern - } = entry; - - const decodedIdToken = await getDecodedIdToken_memo(); - - const claimValue_arr: string[] = (() => { - const value = decodedIdToken[claimName]; - - if (!value) return []; - - if (typeof value === "string") return [value]; - if (Array.isArray(value)) return value.map(e => `${e}`); - - assert( - false, - () => - `${claimName} not in expected format! ${JSON.stringify(decodedIdToken)}` - ); - })(); - - const includedRegex = includedClaimPattern - ? new RegExp(includedClaimPattern) - : undefined; - const excludedRegex = excludedClaimPattern - ? new RegExp(excludedClaimPattern) - : undefined; - - return claimValue_arr - .map(value => { - if ( - excludedRegex !== undefined && - excludedRegex.test(value) - ) - return []; - - if (includedRegex === undefined) { - return []; - } - - const match = includedRegex.exec(value); - - if (!match) { - return []; - } - - return [ - id( - { - fullPath: substituteTemplateString({ - template: entry.fullPath, - match - }), - title: substituteLocalizedString({ - localizedString: entry.title, - match - }), - description: substituteLocalizedString({ - localizedString: entry.description, - match - }), - tags: substituteLocalizedStringArray({ - array: entry.tags, - match - }) - } - ) - ]; - }) - .flat(); - }) - ) - ).flat() - }); - }) - ); - - return { resolvedAdminBookmarks }; -} - -function substituteTemplateString(params: { - template: string; - match: RegExpExecArray; -}): string { - const { template, match } = params; - return template.replace(/\$(\d+)/g, (_, i) => match[parseInt(i)] ?? ""); -} - -const substituteLocalizedStringArray = (params: { - array: LocalizedString[] | undefined; - match: RegExpExecArray; -}): LocalizedString[] | undefined => { - const { array, match } = params; - - if (array === undefined) return undefined; - - return array.map(str => - substituteLocalizedString({ - localizedString: str, - match - }) - ); -}; - -function substituteLocalizedString(params: { - localizedString: T; - match: RegExpExecArray; -}): T { - const { localizedString: input, match } = params; - - if (input === undefined) return undefined as T; - - if (typeof input === "string") { - return substituteTemplateString({ template: input, match }) as T; - } - - const result = Object.fromEntries( - Object.entries(input).map(([lang, value]) => [ - lang, - typeof value === "string" - ? substituteTemplateString({ template: value, match }) - : value - ]) - ); - - return result as T; -} diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/updateDefaultS3ConfigsAfterPotentialDeletion.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/updateDefaultS3ConfigsAfterPotentialDeletion.ts deleted file mode 100644 index 7113da304..000000000 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/updateDefaultS3ConfigsAfterPotentialDeletion.ts +++ /dev/null @@ -1,71 +0,0 @@ -import * as projectManagement from "core/usecases/projectManagement"; -import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; -import { getS3Configs } from "./getS3Configs"; - -type R = Record< - "s3ConfigId_defaultXOnyxia" | "s3ConfigId_explorer", - | { - isUpdateNeeded: false; - } - | { - isUpdateNeeded: true; - s3ConfigId: string | undefined; - } ->; - -export function updateDefaultS3ConfigsAfterPotentialDeletion(params: { - projectConfigsS3: { - s3Configs: projectManagement.ProjectConfigs.S3Config[]; - s3ConfigId_defaultXOnyxia: string | undefined; - s3ConfigId_explorer: string | undefined; - }; - s3RegionConfigs: DeploymentRegion.S3Config[]; -}): R { - const { projectConfigsS3, s3RegionConfigs } = params; - - const s3Configs = getS3Configs({ - projectConfigsS3, - s3RegionConfigs, - configTestResults: [], - resolvedAdminBookmarks: [], - ongoingConfigTests: [], - username: "johndoe", - projectGroup: undefined, - groupProjects: [] - }); - - const actions: R = { - s3ConfigId_defaultXOnyxia: { - isUpdateNeeded: false - }, - s3ConfigId_explorer: { - isUpdateNeeded: false - } - }; - - for (const propertyName of [ - "s3ConfigId_defaultXOnyxia", - "s3ConfigId_explorer" - ] as const) { - const s3ConfigId_default = projectConfigsS3[propertyName]; - - if (s3ConfigId_default === undefined) { - continue; - } - - if (s3Configs.find(({ id }) => id === s3ConfigId_default) !== undefined) { - continue; - } - - const s3ConfigId_toUseAsDefault = s3Configs.find( - ({ origin }) => origin === "deploymentRegion" - )?.id; - - actions[propertyName] = { - isUpdateNeeded: true, - s3ConfigId: s3ConfigId_toUseAsDefault - }; - } - - return actions; -} diff --git a/web/src/core/usecases/s3ConfigManagement/index.ts b/web/src/core/usecases/s3ConfigManagement/index.ts deleted file mode 100644 index 479cc3f02..000000000 --- a/web/src/core/usecases/s3ConfigManagement/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./state"; -export * from "./selectors"; -export * from "./thunks"; -export type { S3Config } from "./decoupledLogic/getS3Configs"; diff --git a/web/src/core/usecases/s3ConfigManagement/selectors.ts b/web/src/core/usecases/s3ConfigManagement/selectors.ts deleted file mode 100644 index 4586886a7..000000000 --- a/web/src/core/usecases/s3ConfigManagement/selectors.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { createSelector } from "clean-architecture"; -import type { LocalizedString } from "core/ports/OnyxiaApi"; -import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; -import * as projectManagement from "core/usecases/projectManagement"; -import * as s3ConfigConnectionTest from "core/usecases/s3ConfigConnectionTest"; -import * as userAuthentication from "core/usecases/userAuthentication"; -import { assert } from "tsafe/assert"; -import { exclude } from "tsafe/exclude"; -import { getS3Configs, type S3Config } from "./decoupledLogic/getS3Configs"; -import { name } from "./state"; -import type { State as RootState } from "core/bootstrap"; - -const resolvedAdminBookmarks = createSelector( - (state: RootState) => state[name], - state => state.resolvedAdminBookmarks -); - -const s3Configs = createSelector( - createSelector( - projectManagement.protectedSelectors.projectConfig, - projectConfig => projectConfig.s3 - ), - createSelector( - deploymentRegionManagement.selectors.currentDeploymentRegion, - deploymentRegion => deploymentRegion.s3Configs - ), - resolvedAdminBookmarks, - s3ConfigConnectionTest.protectedSelectors.configTestResults, - s3ConfigConnectionTest.protectedSelectors.ongoingConfigTests, - createSelector(userAuthentication.selectors.main, ({ isUserLoggedIn, user }) => { - assert(isUserLoggedIn); - return user.username; - }), - createSelector( - projectManagement.protectedSelectors.currentProject, - project => project.group - ), - createSelector(projectManagement.protectedSelectors.projects, projects => { - return projects - .map(({ name, group }) => (group === undefined ? undefined : { name, group })) - .filter(exclude(undefined)); - }), - ( - projectConfigsS3, - s3RegionConfigs, - resolvedAdminBookmarks, - configTestResults, - ongoingConfigTests, - username, - projectGroup, - groupProjects - ): S3Config[] => - getS3Configs({ - projectConfigsS3, - s3RegionConfigs, - resolvedAdminBookmarks, - configTestResults, - ongoingConfigTests, - username, - projectGroup, - groupProjects - }) -); - -type IndexedS3Locations = - | IndexedS3Locations.AdminCreatedS3Config - | IndexedS3Locations.UserCreatedS3Config; - -namespace IndexedS3Locations { - export namespace AdminCreatedS3Config { - type Common = { directoryPath: string }; - - export type PersonalLocation = Common & { - type: "personal"; - }; - - export type ProjectLocation = Common & { - type: "project"; - projectName: string; - }; - export type AdminBookmarkLocation = Common & { - type: "bookmark"; - title: LocalizedString; - description?: LocalizedString; - tags: LocalizedString[] | undefined; - }; - - export type Location = PersonalLocation | ProjectLocation | AdminBookmarkLocation; - } - - export type AdminCreatedS3Config = { - type: "admin created s3 config"; - locations: AdminCreatedS3Config.Location[]; - }; - - export type UserCreatedS3Config = { - type: "user created s3 config"; - directoryPath: string; - dataSource: string; - }; -} - -const indexedS3Locations = createSelector(s3Configs, (s3Configs): IndexedS3Locations => { - const s3Config = s3Configs.find(({ isExplorerConfig }) => isExplorerConfig); - - assert(s3Config !== undefined); - - switch (s3Config.origin) { - case "deploymentRegion": - return { - type: "admin created s3 config", - locations: s3Config.locations - }; - case "project": - return { - type: "user created s3 config", - directoryPath: s3Config.workingDirectoryPath, - dataSource: s3Config.dataSource - }; - } -}); - -export const selectors = { s3Configs, indexedS3Locations }; - -export const privateSelectors = { - resolvedAdminBookmarks -}; diff --git a/web/src/core/usecases/s3ConfigManagement/state.ts b/web/src/core/usecases/s3ConfigManagement/state.ts deleted file mode 100644 index e97d2c9d1..000000000 --- a/web/src/core/usecases/s3ConfigManagement/state.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ResolvedAdminBookmark } from "./decoupledLogic/resolveS3AdminBookmarks"; -import { - createUsecaseActions, - createObjectThatThrowsIfAccessed -} from "clean-architecture"; - -type State = { - resolvedAdminBookmarks: ResolvedAdminBookmark[]; -}; - -export const name = "s3ConfigManagement"; - -export const { reducer, actions } = createUsecaseActions({ - name, - initialState: createObjectThatThrowsIfAccessed(), - reducers: { - initialized: ( - _, - { - payload - }: { - payload: { - resolvedAdminBookmarks: ResolvedAdminBookmark[]; - }; - } - ) => { - const { resolvedAdminBookmarks } = payload; - - const state: State = { - resolvedAdminBookmarks - }; - - return state; - } - } -}); diff --git a/web/src/core/usecases/s3ConfigManagement/thunks.ts b/web/src/core/usecases/s3ConfigManagement/thunks.ts deleted file mode 100644 index 88cf38457..000000000 --- a/web/src/core/usecases/s3ConfigManagement/thunks.ts +++ /dev/null @@ -1,356 +0,0 @@ -import type { Thunks } from "core/bootstrap"; -import { selectors, privateSelectors } from "./selectors"; -import type { S3Config } from "./decoupledLogic/getS3Configs"; -import * as projectManagement from "core/usecases/projectManagement"; -import type { ProjectConfigs } from "core/usecases/projectManagement"; -import { assert } from "tsafe/assert"; -import type { S3Client } from "core/ports/S3Client"; -import { createUsecaseContextApi } from "clean-architecture"; -import { getProjectS3ConfigId } from "./decoupledLogic/projectS3ConfigId"; -import * as s3ConfigConnectionTest from "core/usecases/s3ConfigConnectionTest"; -import { updateDefaultS3ConfigsAfterPotentialDeletion } from "./decoupledLogic/updateDefaultS3ConfigsAfterPotentialDeletion"; -import structuredClone from "@ungap/structured-clone"; -import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; -import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; -import { resolveS3AdminBookmarks } from "./decoupledLogic/resolveS3AdminBookmarks"; -import { actions } from "./state"; - -export const thunks = { - testS3Connection: - (params: { projectS3ConfigId: string }) => - async (...args) => { - const { projectS3ConfigId } = params; - const [dispatch, getState] = args; - - const s3Configs = selectors.s3Configs(getState()); - - const s3Config = s3Configs.find( - s3Config => s3Config.id === projectS3ConfigId - ); - - assert(s3Config !== undefined); - assert(s3Config.origin === "project"); - - await dispatch( - s3ConfigConnectionTest.protectedThunks.testS3Connection({ - paramsOfCreateS3Client: s3Config.paramsOfCreateS3Client, - workingDirectoryPath: s3Config.workingDirectoryPath - }) - ); - }, - deleteS3Config: - (params: { projectS3ConfigId: string }) => - async (...args) => { - const { projectS3ConfigId } = params; - - const [dispatch, getState] = args; - - const projectConfigsS3 = structuredClone( - projectManagement.protectedSelectors.projectConfig(getState()).s3 - ); - - const i = projectConfigsS3.s3Configs.findIndex( - projectS3Config_i => - getProjectS3ConfigId({ - creationTime: projectS3Config_i.creationTime - }) === projectS3ConfigId - ); - - assert(i !== -1); - - projectConfigsS3.s3Configs.splice(i, 1); - - { - const actions = updateDefaultS3ConfigsAfterPotentialDeletion({ - projectConfigsS3, - s3RegionConfigs: - deploymentRegionManagement.selectors.currentDeploymentRegion( - getState() - ).s3Configs - }); - - await Promise.all( - (["s3ConfigId_defaultXOnyxia", "s3ConfigId_explorer"] as const).map( - async propertyName => { - const action = actions[propertyName]; - - if (!action.isUpdateNeeded) { - return; - } - - projectConfigsS3[propertyName] = action.s3ConfigId; - } - ) - ); - } - - await dispatch( - projectManagement.protectedThunks.updateConfigValue({ - key: "s3", - value: projectConfigsS3 - }) - ); - }, - changeIsDefault: - (params: { - s3ConfigId: string; - usecase: "defaultXOnyxia" | "explorer"; - value: boolean; - }) => - async (...args) => { - const { s3ConfigId, usecase, value } = params; - - const [dispatch, getState] = args; - - const projectConfigsS3 = structuredClone( - projectManagement.protectedSelectors.projectConfig(getState()).s3 - ); - - const propertyName = (() => { - switch (usecase) { - case "defaultXOnyxia": - return "s3ConfigId_defaultXOnyxia"; - case "explorer": - return "s3ConfigId_explorer"; - } - })(); - - { - const currentDefault = projectConfigsS3[propertyName]; - - if (value) { - if (currentDefault === s3ConfigId) { - return; - } - } else { - if (currentDefault !== s3ConfigId) { - return; - } - } - } - - projectConfigsS3[propertyName] = value ? s3ConfigId : undefined; - - await dispatch( - projectManagement.protectedThunks.updateConfigValue({ - key: "s3", - value: projectConfigsS3 - }) - ); - } -} satisfies Thunks; - -export const protectedThunks = { - getS3ClientForSpecificConfig: - (params: { s3ConfigId: string | undefined }) => - async (...args): Promise => { - const { s3ConfigId } = params; - const [, getState, rootContext] = args; - - const { prS3ClientByConfigId } = getContext(rootContext); - - const s3Config = (() => { - const s3Configs = selectors.s3Configs(getState()); - - const s3Config = s3Configs.find(s3Config => s3Config.id === s3ConfigId); - assert(s3Config !== undefined); - - return s3Config; - })(); - - use_cached_s3Client: { - const prS3Client = prS3ClientByConfigId.get(s3Config.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( - s3Config.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 resolvedAdminBookmarks = - privateSelectors.resolvedAdminBookmarks(getState()); - - const KEY = "onyxia:s3:resolvedAdminBookmarks-hash"; - - const hash = fnv1aHashToHex( - JSON.stringify(resolvedAdminBookmarks) - ); - - 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 - }; - } - ); - })(); - - prS3ClientByConfigId.set(s3Config.id, prS3Client); - - return prS3Client; - }, - getS3ConfigAndClientForExplorer: - () => - async ( - ...args - ): Promise => { - const [dispatch, getState] = args; - - const s3Config = selectors - .s3Configs(getState()) - .find(s3Config => s3Config.isExplorerConfig); - - if (s3Config === undefined) { - return undefined; - } - - const s3Client = await dispatch( - protectedThunks.getS3ClientForSpecificConfig({ - s3ConfigId: s3Config.id - }) - ); - - return { s3Client, s3Config }; - }, - createS3Config: - (params: { projectS3Config: ProjectConfigs.S3Config }) => - async (...args) => { - const { projectS3Config } = params; - - const [dispatch, getState] = args; - - const projectConfigsS3 = structuredClone( - projectManagement.protectedSelectors.projectConfig(getState()).s3 - ); - - const i = projectConfigsS3.s3Configs.findIndex( - projectS3Config_i => - getProjectS3ConfigId({ - creationTime: projectS3Config_i.creationTime - }) === - getProjectS3ConfigId({ - creationTime: projectS3Config.creationTime - }) - ); - - if (i < 0) { - projectConfigsS3.s3Configs.push(projectS3Config); - } else { - projectConfigsS3.s3Configs[i] = projectS3Config; - } - - await dispatch( - projectManagement.protectedThunks.updateConfigValue({ - key: "s3", - value: projectConfigsS3 - }) - ); - }, - - initialize: - () => - async (...args) => { - const [dispatch, getState, { onyxiaApi, paramsOfBootstrapCore }] = args; - - const { oidcParams } = await onyxiaApi.getAvailableRegionsAndOidcParams(); - - if (oidcParams === undefined) { - dispatch(actions.initialized({ resolvedAdminBookmarks: [] })); - return; - } - const deploymentRegion = - deploymentRegionManagement.selectors.currentDeploymentRegion(getState()); - - const { resolvedAdminBookmarks } = await resolveS3AdminBookmarks({ - deploymentRegion_s3Configs: deploymentRegion.s3Configs, - getDecodedIdToken: async ({ oidcParams_partial }) => { - const { createOidc, mergeOidcParams } = await import( - "core/adapters/oidc" - ); - - 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; - } - }); - - dispatch(actions.initialized({ resolvedAdminBookmarks })); - } -} satisfies Thunks; - -const { getContext } = createUsecaseContextApi(() => ({ - prS3ClientByConfigId: new Map>() -})); diff --git a/web/src/ui/App/LeftBar.tsx b/web/src/ui/App/LeftBar.tsx index 84860bb8a..c37bdfc47 100644 --- a/web/src/ui/App/LeftBar.tsx +++ b/web/src/ui/App/LeftBar.tsx @@ -25,7 +25,10 @@ export const LeftBar = memo((props: Props) => { } = getCoreSync(); const { isDevModeEnabled } = useCoreState("userConfigs", "userConfigs"); - const isFileExplorerEnabled = useCoreState("fileExplorer", "isFileExplorerEnabled"); + const isS3ExplorerEnabled = useCoreState( + "s3ProfilesManagement", + "isS3ExplorerEnabled" + ); const route = useRoute(); @@ -58,12 +61,6 @@ export const LeftBar = memo((props: Props) => { label: t("account"), link: routes.account().link }, - { - itemId: "projectSettings", - icon: getIconUrlByName("DisplaySettings"), - label: t("projectSettings"), - link: routes.projectSettings().link - }, { groupId: "services", label: t("divider: services features") @@ -93,19 +90,12 @@ export const LeftBar = memo((props: Props) => { ? "available" : "not visible" }, - { - itemId: "fileExplorer", - icon: customIcons.filesSvgUrl, - label: t("fileExplorer"), - link: routes.fileExplorerEntry().link, - availability: isFileExplorerEnabled ? "available" : "not visible" - }, { itemId: "dataExplorer", icon: getIconUrlByName("DocumentScanner"), label: t("dataExplorer"), link: routes.dataExplorer().link, - availability: isFileExplorerEnabled ? "available" : "not visible" + availability: isS3ExplorerEnabled ? "available" : "not visible" }, { itemId: "dataCollection", @@ -125,13 +115,8 @@ export const LeftBar = memo((props: Props) => { itemId: "s3Explorer", icon: customIcons.filesSvgUrl, label: "S3 Explorer", - link: routes.s3Explorer({ - path: "" - }).link, - availability: - isDevModeEnabled && isFileExplorerEnabled - ? "available" - : "not visible" + link: routes.s3Explorer_root().link, + availability: isS3ExplorerEnabled ? "available" : "not visible" }, { groupId: "custom-leftbar-links", @@ -161,8 +146,6 @@ export const LeftBar = memo((props: Props) => { return "home" as const; case "account": return "account"; - case "projectSettings": - return "projectSettings"; case "catalog": case "launcher": return "catalog"; @@ -171,9 +154,6 @@ export const LeftBar = memo((props: Props) => { return "myServices"; case "mySecrets": return "mySecrets"; - case "fileExplorerEntry": - case "myFiles": - return "fileExplorer"; case "sqlOlapShell": return "sqlOlapShell"; case "dataExplorer": @@ -204,12 +184,9 @@ const { i18n } = declareComponentKeys< | "reduce" | "home" | "account" - | "projectSettings" | "catalog" | "myServices" | "mySecrets" - | "myFiles" - | "fileExplorer" | "dataExplorer" | "dataCollection" | "sqlOlapShell" diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index e88c12a21..daf423cba 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -132,132 +132,6 @@ export const translations: Translations<"de"> = { ), "expires in": ({ howMuchTime }) => `Das Token läuft in ${howMuchTime} ab` }, - ProjectSettings: { - "page header title": "Projekteinstellungen", - "page header help title": ({ groupProjectName }) => - groupProjectName === undefined - ? "Einstellungen Ihres persönlichen Projekts" - : `Einstellungen für "${groupProjectName}"`, - "page header help content": ({ - groupProjectName, - doesUserBelongToSomeGroupProject - }) => ( - <> - Diese Seite ermöglicht es Ihnen, die Einstellungen zu konfigurieren, die - auf - {groupProjectName === undefined - ? " Ihr persönliches Projekt" - : ` das ${groupProjectName}`}{" "} - angewendet werden. -
- {groupProjectName !== undefined && ( - <> - Beachten Sie, dass {groupProjectName} ein Gruppenprojekt ist, das - mit anderen Benutzern geteilt wird; die hier vorgenommenen - Änderungen gelten für alle Mitglieder des Projekts. -
- - )} - {doesUserBelongToSomeGroupProject && ( - <> - Sie können zwischen Ihren Projekten wechseln, indem Sie das - Dropdown-Menü in der Kopfzeile verwenden. -
- - )} - Beachten Sie, dass nur Ihr Onyxia-Instanzadministrator neue Projekte - erstellen kann. - - ), - "security-info": "Sicherheitsinformationen", - "s3-configs": "S3-Konfigurationen" - }, - ProjectSettingsS3ConfigTab: { - "add custom config": "Eine benutzerdefinierte S3-Konfiguration hinzufügen" - }, - S3ConfigCard: { - "data source": "Datenquelle", - credentials: "Anmeldedaten", - "sts credentials": - "Dynamisch angeforderte Tokens in Ihrem Auftrag von Onyxia (STS)", - account: "Konto", - "use in services": "In Diensten verwenden", - "use in services helper": `Wenn aktiviert, wird diese Konfiguration standardmäßig in Ihren Diensten verwendet, die eine S3-Integration implementieren.`, - "use for onyxia explorers": "Für Onyxia-Explorer verwenden", - "use for onyxia explorers helper": `Wenn aktiviert, wird diese Konfiguration vom Datei-Explorer und dem Daten-Explorer verwendet.`, - edit: "Bearbeiten", - delete: "Löschen" - }, - AddCustomS3ConfigDialog: { - "dialog title": "Neue benutzerdefinierte S3-Konfiguration", - "dialog subtitle": - "Geben Sie ein benutzerdefiniertes Dienstkonto an oder verbinden Sie sich mit einem anderen S3-kompatiblen Dienst", - cancel: "Abbrechen", - "save config": "Konfiguration speichern", - "update config": "Konfiguration aktualisieren", - "is required": "Dieses Feld ist erforderlich", - "must be an url": "Keine gültige URL", - "not a valid access key id": - "Das sieht nicht nach einer gültigen Zugangsschlüssel-ID aus", - "url textField label": "URL", - "url textField helper text": "URL des S3-Dienstes", - "region textField label": "AWS S3-Region", - "region textField helper text": "Beispiel: eu-west-1, wenn unsicher, leer lassen", - "workingDirectoryPath textField label": "Arbeitsverzeichnispfad", - "workingDirectoryPath textField helper text": ( - <> - Hiermit können Sie den Bucket und das S3-Objektprefix angeben, das Sie im - S3-Dienst besitzen.
- Beispiel: mein-bucket/mein-präfix/ oder{" "} - nur mein-bucket/ wenn Sie den ganzen Bucket besitzen. - - ), - "account credentials": "Kontozugangsdaten", - "friendlyName textField label": "Konfigurationsname", - "friendlyName textField helper text": - "Dies hilft Ihnen nur, diese Konfiguration zu identifizieren. Beispiel: Mein AWS-Bucket", - "isAnonymous switch label": "Anonymer Zugang", - "isAnonymous switch helper text": - "Auf EIN stellen, wenn kein geheimer Zugangsschlüssel erforderlich ist", - "accessKeyId textField label": "Zugangsschlüssel-ID", - "accessKeyId textField helper text": "Beispiel: 1A2B3C4D5E6F7G8H9I0J", - "secretAccessKey textField label": "Geheimer Zugangsschlüssel", - "sessionToken textField label": "Sitzungstoken", - "sessionToken textField helper text": "Optional, leer lassen, wenn unsicher", - "url style": "URL-Stil", - "url style helper text": `Geben Sie an, wie Ihr S3-Server die URL für das Herunterladen von Dateien formatiert.`, - "path style label": ({ example }) => ( - <> - Pfadstil - {example !== undefined && ( - <> - :  - {example}meine-daten.parquet - - )} - - ), - "virtual-hosted style label": ({ example }) => ( - <> - Virtual-hosted-Stil - {example !== undefined && ( - <> - :  - {example}meine-daten.parquet - - )} - - ) - }, - TestS3ConnectionButton: { - "test connection": "Verbindung testen", - "test connection failed": ({ errorMessage }) => ( - <> - Verbindungstest fehlgeschlagen mit Fehler:
- {errorMessage} - - ) - }, AccountUserInterfaceTab: { title: "Konfiguration der Benutzeroberfläche", "enable dark mode": "Dunkelmodus aktivieren", @@ -312,43 +186,6 @@ export const translations: Translations<"de"> = { "reset helper dialogs helper text": "Die Hilfsdialoge zurücksetzen, die Sie aufgefordert haben, nicht mehr anzuzeigen" }, - FileExplorerEntry: { - "page title - file explorer": "Datei-Explorer", - "what this page is used for - file explorer": "Speichern Sie hier Ihre Dateien.", - "help content": ({ accountTabLink, docHref }) => ( - <> - Lesen Sie - - unsere Dokumentation - - .   - MinIO-Clients konfigurieren. - - ), - "title personal": "Meine Daten", - "description personal": "Ihre eigenen Dateien und Datensätze.", - "title project": ({ projectName }) => `Projekt ${projectName}`, - "description project": ({ projectName }) => - `Gemeinsamer Speicherplatz für das Projekt ${projectName}`, - tags: ({ type }) => { - switch (type) { - case "personal": - return "Meine Daten"; - case "project": - return "Gruppendaten"; - } - } - }, - S3EntryCard: { - "space path": "Pfad des Bereichs" - }, - FileExplorerDisabledDialog: { - "dialog title": "Kein S3-Server konfiguriert", - "dialog body": - "Für diese Instanz ist kein S3-Server konfiguriert. Sie können jedoch manuell einen hinzufügen, um den S3-Dateiexplorer zu aktivieren.", - cancel: "Abbrechen", - "go to settings": "Zu den Einstellungen gehen" - }, ConfirmBucketCreationAttemptDialog: { "bucket does not exist title": ({ bucket }) => `Der Bucket ${bucket} existiert nicht`, @@ -599,18 +436,15 @@ export const translations: Translations<"de"> = { reduce: "Reduzieren", home: "Startseite", account: "Mein Konto", - projectSettings: "Projekteinstellungen", catalog: "Servicekatalog", myServices: "Meine Dienste", mySecrets: "Meine Geheimnisse", - myFiles: "Meine Dateien", "divider: services features": "Funktionen im Zusammenhang mit Diensten", "divider: external services features": "Funktionen im Zusammenhang mit externen Diensten", "divider: onyxia instance specific features": "Funktionen spezifisch für diese Onyxia-Instanz", dataExplorer: "Daten-Explorer", - fileExplorer: "Datei-Explorer", dataCollection: "Sammlungs-Explorer", sqlOlapShell: "SQL OLAP-Shell" }, diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 48ec41772..a0b7897fc 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -129,129 +129,6 @@ export const translations: Translations<"en"> = { ), "expires in": ({ howMuchTime }) => `The token expires ${howMuchTime}` }, - ProjectSettings: { - "page header title": "Project Settings", - "page header help title": ({ groupProjectName }) => - groupProjectName === undefined - ? "Settings of your personal project" - : `Settings for "${groupProjectName}"`, - "page header help content": ({ - groupProjectName, - doesUserBelongToSomeGroupProject - }) => ( - <> - This page allows you to configure the settings that apply to - {groupProjectName === undefined - ? " your personal project" - : ` the ${groupProjectName}`} - . -
- {groupProjectName !== undefined && ( - <> - Be aware that {groupProjectName} is a group project shared with - other users; the settings you change here will apply to all - project members. -
- - )} - {doesUserBelongToSomeGroupProject && ( - <> - You can switch between your projects using the dropdown menu in - the header. -
- - )} - Note that only your Onyxia instance administrator can create new projects. - - ), - "security-info": "Security Information", - "s3-configs": "S3 Configurations" - }, - ProjectSettingsS3ConfigTab: { - "add custom config": "Add a custom S3 configuration" - }, - S3ConfigCard: { - "data source": "Data source", - credentials: "Credentials", - "sts credentials": "Tokens dynamically requested on your behalf by Onyxia (STS)", - account: "Account", - "use in services": "Use in services", - "use in services helper": `If enabled, this configuration will be used by - default in your services that implement an S3 integration.`, - "use for onyxia explorers": "Use for Onyxia explorers", - "use for onyxia explorers helper": `If enabled this configuration will be used - by the file explorer and the data explorer.`, - edit: "Edit", - delete: "Delete" - }, - AddCustomS3ConfigDialog: { - "dialog title": "New custom S3 configuration", - "dialog subtitle": - "Specify a custom service account or connect to another S3 compatible service", - cancel: "Cancel", - "save config": "Save configuration", - "update config": "Update configuration", - "is required": "This field is required", - "must be an url": "Not a valid URL", - "not a valid access key id": "This doesn't look like a valid access key id", - "url textField label": "URL", - "url textField helper text": "URL of the S3 service", - "region textField label": "AWS S3 Region", - "region textField helper text": "Example: eu-west-1, if not sure, leave empty", - "workingDirectoryPath textField label": "Working directory path", - "workingDirectoryPath textField helper text": ( - <> - This let you specify the bucket and the S3 object prefix you own on the S3 - service.
- Example: my-bucket/my-prefix/ or just my-bucket/{" "} - if you own the whole bucket. - - ), - "account credentials": "Account credentials", - "friendlyName textField label": "Configuration Name", - "friendlyName textField helper text": - "This is just to help you identify this configuration. Example: My AWS bucket", - "isAnonymous switch label": "Anonymous access", - "isAnonymous switch helper text": "Set to ON if no secret access key is required", - "accessKeyId textField label": "Access key ID", - "accessKeyId textField helper text": "Example: 1A2B3C4D5E6F7G8H9I0J", - "secretAccessKey textField label": "Secret access key", - "sessionToken textField label": "Session token", - "sessionToken textField helper text": "Optional, leave empty if not sure", - "url style": "URL style", - "url style helper text": `Specify how your S3 server formats the URL for downloading files.`, - "path style label": ({ example }) => ( - <> - Path style - {example !== undefined && ( - <> - :  - {example}my-dataset.parquet - - )} - - ), - "virtual-hosted style label": ({ example }) => ( - <> - Virtual-hosted style - {example !== undefined && ( - <> - :  - {example}my-dataset.parquet - - )} - - ) - }, - TestS3ConnectionButton: { - "test connection": "Test connection", - "test connection failed": ({ errorMessage }) => ( - <> - Test connection failed with error:
- {errorMessage} - - ) - }, AccountUserInterfaceTab: { title: "Interface preferences", "enable dark mode": "Enable dark mode", @@ -301,46 +178,6 @@ export const translations: Translations<"en"> = { "reset helper dialogs helper text": "Reset message windows that have been requested not to be shown again" }, - FileExplorerEntry: { - "page title - file explorer": "File Explorer", - "what this page is used for - file explorer": - "Here you can browse your S3 Buckets.", - "help content": ({ accountTabLink, docHref }) => ( - <> - Read{" "} - - our documentation - - .   - - Configure the minio clients - . - - ), - "title personal": "My data", - "description personal": "Your own files and datasets.", - "title project": ({ projectName }) => `Project ${projectName}`, - "description project": ({ projectName }) => - `Shared storage space for project ${projectName}`, - tags: ({ type }) => { - switch (type) { - case "personal": - return "My data"; - case "project": - return "Group data"; - } - } - }, - S3EntryCard: { - "space path": "Space path" - }, - FileExplorerDisabledDialog: { - "dialog title": "No S3 server configured", - "dialog body": - "There's no S3 server configured for this instance. But you can add one manually for enabling the S3 file explorer.", - cancel: "Cancel", - "go to settings": "Go to settings" - }, ConfirmBucketCreationAttemptDialog: { "bucket does not exist title": ({ bucket }) => `The ${bucket} bucket does not exist`, @@ -589,17 +426,14 @@ export const translations: Translations<"en"> = { reduce: "Reduce", home: "Home", account: "My account", - projectSettings: "Project settings", catalog: "Service catalog", myServices: "My Services", mySecrets: "My Secrets", - myFiles: "My Files", "divider: services features": "Services features", "divider: external services features": "External services features", "divider: onyxia instance specific features": "Onyxia instance specific features", dataExplorer: "Data Explorer", dataCollection: "Data Collection", - fileExplorer: "File Explorer", sqlOlapShell: "SQL Olap Shell" }, AutoLogoutCountdown: { diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index f97d75b86..e5c1e8791 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -131,134 +131,6 @@ export const translations: Translations<"en"> = { ), "expires in": ({ howMuchTime }) => `El token expira ${howMuchTime}` }, - ProjectSettings: { - "page header title": "Configuración del Proyecto", - "page header help title": ({ groupProjectName }) => - groupProjectName === undefined - ? "Configuración de tu proyecto personal" - : `Configuración para "${groupProjectName}"`, - "page header help content": ({ - groupProjectName, - doesUserBelongToSomeGroupProject - }) => ( - <> - Esta página te permite configurar los ajustes que se aplican - {groupProjectName === undefined - ? " a tu proyecto personal" - : ` al ${groupProjectName}`} - . -
- {groupProjectName !== undefined && ( - <> - Ten en cuenta que {groupProjectName} es un proyecto grupal - compartido con otros usuarios; los ajustes que cambies aquí se - aplicarán a todos los miembros del proyecto. -
- - )} - {doesUserBelongToSomeGroupProject && ( - <> - Puedes cambiar entre tus proyectos usando el menú desplegable en - el encabezado. -
- - )} - Ten en cuenta que solo el administrador de tu instancia de Onyxia puede - crear nuevos proyectos. - - ), - "security-info": "Información de Seguridad", - "s3-configs": "Configuraciones de S3" - }, - ProjectSettingsS3ConfigTab: { - "add custom config": "Agregar una configuración S3 personalizada" - }, - S3ConfigCard: { - "data source": "Fuente de datos", - credentials: "Credenciales", - "sts credentials": - "Tokens solicitados dinámicamente en tu nombre por Onyxia (STS)", - account: "Cuenta", - "use in services": "Usar en servicios", - "use in services helper": `Si está habilitado, esta configuración se utilizará por - defecto en tus servicios que implementan una integración S3.`, - "use for onyxia explorers": "Usar para exploradores de Onyxia", - "use for onyxia explorers helper": `Si está habilitado, esta configuración será utilizada - por el explorador de archivos y el explorador de datos.`, - edit: "Editar", - delete: "Eliminar" - }, - AddCustomS3ConfigDialog: { - "dialog title": "Nueva configuración S3 personalizada", - "dialog subtitle": - "Especifica una cuenta de servicio personalizada o conéctate a otro servicio compatible con S3", - cancel: "Cancelar", - "save config": "Guardar configuración", - "update config": "Actualizar configuración", - "is required": "Este campo es obligatorio", - "must be an url": "No es una URL válida", - "not a valid access key id": "Esto no parece una ID de clave de acceso válida", - "url textField label": "URL", - "url textField helper text": "URL del servicio S3", - "region textField label": "Región de AWS S3", - "region textField helper text": - "Ejemplo: eu-west-1, si no estás seguro, déjalo vacío", - "workingDirectoryPath textField label": "Ruta del directorio de trabajo", - "workingDirectoryPath textField helper text": ( - <> - Esto te permite especificar el bucket y el prefijo del objeto S3 que - posees en el servicio S3.
- Ejemplo: mi-bucket/mi-prefijo/ o solo mi-bucket/{" "} - si posees todo el bucket. - - ), - "account credentials": "Credenciales de cuenta", - "friendlyName textField label": "Nombre de configuración", - "friendlyName textField helper text": - "Esto es solo para ayudarle a identificar esta configuración. Ejemplo: Mi bucket de AWS", - "isAnonymous switch label": "Acceso anónimo", - "isAnonymous switch helper text": - "Activa esta opción si no se requiere una clave de acceso secreto", - "accessKeyId textField label": "ID de clave de acceso", - "accessKeyId textField helper text": "Ejemplo: 1A2B3C4D5E6F7G8H9I0J", - "secretAccessKey textField label": "Clave de acceso secreto", - "sessionToken textField label": "Token de sesión", - "sessionToken textField helper text": "Opcional, déjalo vacío si no estás seguro", - "url style": "Estilo de URL", - "url style helper text": - "Especifica cómo tu servidor S3 formatea la URL para descargar archivos.", - "path style label": ({ example }) => ( - <> - Estilo de ruta - {example !== undefined && ( - <> - :  - {example}mi-dataset.parquet - - )} - - ), - "virtual-hosted style label": ({ example }) => ( - <> - Estilo hospedado virtualmente - {example !== undefined && ( - <> - :  - {example}mi-dataset.parquet - - )} - - ) - }, - TestS3ConnectionButton: { - "test connection": "Probar conexión", - "test connection failed": ({ errorMessage }) => ( - <> - La prueba de conexión falló con el error:
- {errorMessage} - - ) - }, AccountUserInterfaceTab: { title: "Preferencias de la interfaz", "enable dark mode": "Activar modo oscuro", @@ -312,46 +184,6 @@ export const translations: Translations<"en"> = { "reset helper dialogs helper text": "Restablecer ventanas de mensajes que se han solicitado no mostrar nuevamente" }, - FileExplorerEntry: { - "page title - file explorer": "Explorador de archivos", - "what this page is used for - file explorer": - "Aquí puedes explorar tus Buckets de S3.", - "help content": ({ accountTabLink, docHref }) => ( - <> - Lee{" "} - - nuestra documentación - - .   - - Configura los clientes de minio - . - - ), - "title personal": "Mis datos", - "description personal": "Tus propios archivos y conjuntos de datos.", - "title project": ({ projectName }) => `Proyecto ${projectName}`, - "description project": ({ projectName }) => - `Espacio de almacenamiento compartido para el proyecto ${projectName}`, - tags: ({ type }) => { - switch (type) { - case "personal": - return "Mis datos"; - case "project": - return "Datos del grupo"; - } - } - }, - S3EntryCard: { - "space path": "Ruta del espacio" - }, - FileExplorerDisabledDialog: { - "dialog title": "No hay servidor S3 configurado", - "dialog body": - "No hay ningún servidor S3 configurado para esta instancia. Pero puedes agregar uno manualmente para habilitar el explorador de archivos S3.", - 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?", @@ -601,18 +433,15 @@ export const translations: Translations<"en"> = { reduce: "Reducir", home: "Inicio", account: "Mi cuenta", - projectSettings: "Configuración del proyecto", catalog: "Catálogo de servicios", myServices: "Mis servicios", mySecrets: "Mis secretos", - myFiles: "Mis archivos", "divider: services features": "Funciones de los servicios", "divider: external services features": "Funciones de los servicios externos", "divider: onyxia instance specific features": "Funciones específicas de la instancia de Onyxia", dataExplorer: "Explorador de datos", dataCollection: "Explorador de colecciones", - fileExplorer: "Explorador de archivos", sqlOlapShell: "SQL Olap Shell" }, AutoLogoutCountdown: { diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 55a477f95..43e230694 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -130,134 +130,6 @@ export const translations: Translations<"fi"> = { ), "expires in": ({ howMuchTime }) => `Pääte vanhenee ${howMuchTime} kuluttua` }, - ProjectSettings: { - "page header title": "Projektiasetukset", - "page header help title": ({ groupProjectName }) => - groupProjectName === undefined - ? "Henkilökohtaisen projektisi asetukset" - : `Asetukset "${groupProjectName}"`, - "page header help content": ({ - groupProjectName, - doesUserBelongToSomeGroupProject - }) => ( - <> - Tällä sivulla voit määrittää asetuksia, jotka koskevat - {groupProjectName === undefined - ? " henkilökohtaista projektiasi" - : ` ${groupProjectName} projektia`} - . -
- {groupProjectName !== undefined && ( - <> - Huomaa, että {groupProjectName} on ryhmäprojekti, joka on jaettu - muiden käyttäjien kanssa; tällä sivulla tekemäsi muutokset - koskevat kaikkia projektin jäseniä. -
- - )} - {doesUserBelongToSomeGroupProject && ( - <> - Voit vaihtaa projekteja käyttämällä pudotusvalikkoa - otsikkopalkissa. -
- - )} - Huomaa, että vain Onyxia-instanssisi ylläpitäjä voi luoda uusia - projekteja. - - ), - "security-info": "Turvallisuustiedot", - "s3-configs": "S3-konfiguraatiot" - }, - ProjectSettingsS3ConfigTab: { - "add custom config": "Lisää mukautettu S3-kokoonpano" - }, - S3ConfigCard: { - "data source": "Tietolähde", - credentials: "Tunnistetiedot", - "sts credentials": - "Dynaamisesti pyydetyt tokenit puolestasi Onyxian toimesta (STS)", - account: "Tili", - "use in services": "Käytä palveluissa", - "use in services helper": `Jos otettu käyttöön, tätä konfiguraatiota käytetään - oletusarvoisesti palveluissasi, jotka toteuttavat S3-integraation.`, - "use for onyxia explorers": "Käytä Onyxia-tutkijoille", - "use for onyxia explorers helper": `Jos otettu käyttöön, tätä konfiguraatiota käytetään - tiedostonhallintaohjelmassa ja data-analysaattorissa.`, - edit: "Muokkaa", - delete: "Poista" - }, - AddCustomS3ConfigDialog: { - "dialog title": "Uusi mukautettu S3-kokoonpano", - "dialog subtitle": - "Määritä mukautettu palvelutili tai yhdistä toiseen S3-yhteensopivaan palveluun", - cancel: "Peruuta", - "save config": "Tallenna kokoonpano", - "update config": "Päivitä kokoonpano", - "is required": "Tämä kenttä on pakollinen", - "must be an url": "Ei ole kelvollinen URL-osoite", - "not a valid access key id": "Tämä ei näytä kelvolliselta pääsyavain-ID:ltä", - "url textField label": "URL", - "url textField helper text": "S3-palvelun URL-osoite", - "region textField label": "AWS S3-alue", - "region textField helper text": - "Esimerkki: eu-west-1, jos epävarma, jätä tyhjäksi", - "workingDirectoryPath textField label": "Työkansion polku", - "workingDirectoryPath textField helper text": ( - <> - Tämän avulla voit määrittää ämpärin ja S3-objektin etuliitteen, joka - sinulla on S3-palvelussa.
- Esimerkki: minun-ämpäri/etuliitteeni/ tai{" "} - vain minun-ämpäri/ jos omistat koko ämpärin. - - ), - "account credentials": "Tilin tunnistetiedot", - "friendlyName textField label": "Konfiguraation nimi", - "friendlyName textField helper text": - "Tämä auttaa sinua tunnistamaan tämän konfiguraation. Esimerkki: Minun AWS-bucket", - "isAnonymous switch label": "Anonyymi pääsy", - "isAnonymous switch helper text": - "Aseta PÄÄLLE, jos salainen pääsyavain ei ole tarpeen", - - "accessKeyId textField label": "Pääsyavaimen tunnus", - "accessKeyId textField helper text": "Esimerkki: 1A2B3C4D5E6F7G8H9I0J", - "secretAccessKey textField label": "Salainen pääsyavain", - "sessionToken textField label": "Istuntotunnus", - "sessionToken textField helper text": "Valinnainen, jätä tyhjäksi, jos epävarma", - "url style": "URL-tyyli", - "url style helper text": `Määritä, miten S3-palvelimesi muotoilee tiedostojen lataamisen URL-osoitteita.`, - "path style label": ({ example }) => ( - <> - Polkutyyli - {example !== undefined && ( - <> - :  - {example}tietoaineisto.parquet - - )} - - ), - "virtual-hosted style label": ({ example }) => ( - <> - Virtual-hosted tyyli - {example !== undefined && ( - <> - :  - {example}tietoaineisto.parquet - - )} - - ) - }, - TestS3ConnectionButton: { - "test connection": "Testaa yhteys", - "test connection failed": ({ errorMessage }) => ( - <> - Yhteystestaus epäonnistui virheellä:
- {errorMessage} - - ) - }, AccountUserInterfaceTab: { title: "Käyttöliittymän asetukset", "enable dark mode": "Ota tumma tila käyttöön", @@ -308,44 +180,6 @@ export const translations: Translations<"fi"> = { "reset helper dialogs helper text": "Nollaa ohjeviestit, joista on pyydetty, ettei niitä näytetä uudelleen" }, - FileExplorerEntry: { - "page title - file explorer": "Tiedostoselain", - "what this page is used for - file explorer": - "Täällä voit selata S3 Bucket -tiedostojasi.", - "help content": ({ accountTabLink, docHref }) => ( - <> - Lue{" "} - - dokumentaatiomme - - .   - Määritä Minio-asiakkaat. - - ), - "title personal": "Omat tietoni", - "description personal": "Omat tiedostosi ja tietoaineistosi.", - "title project": ({ projectName }) => `Projekti ${projectName}`, - "description project": ({ projectName }) => - `Projektin ${projectName} yhteinen tallennustila`, - tags: ({ type }) => { - switch (type) { - case "personal": - return "Omat tiedot"; - case "project": - return "Ryhmän tiedot"; - } - } - }, - S3EntryCard: { - "space path": "Tilapolku" - }, - FileExplorerDisabledDialog: { - "dialog title": "S3-palvelinta ei ole määritetty", - "dialog body": - "Tälle instanssille ei ole määritetty S3-palvelinta. Voit kuitenkin lisätä sellaisen manuaalisesti ottaaksesi käyttöön S3-tiedostonhallinnan.", - 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?", @@ -594,17 +428,14 @@ export const translations: Translations<"fi"> = { reduce: "Pienennä", home: "Koti", account: "Oma tili", - projectSettings: "Projektin asetukset", catalog: "Palvelukatalogi", myServices: "Omat palvelut", mySecrets: "Omat salaisuudet", - myFiles: "Omat tiedostot", "divider: services features": "Palvelun ominaisuudet", "divider: external services features": "Ulkoisten palveluiden ominaisuudet", "divider: onyxia instance specific features": "Onyxia-instanssin erityisominaisuudet", dataExplorer: "Data Explorer", - fileExplorer: "Tiedostonhallinta", dataCollection: "Kokoelmien selains", sqlOlapShell: "SQL OLAP-kuori" }, diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index e6cf381e4..c12c2c241 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -134,134 +134,6 @@ export const translations: Translations<"fr"> = { ), "expires in": ({ howMuchTime }) => `Le token expire ${howMuchTime}` }, - ProjectSettings: { - "page header title": "Paramètres du projet", - "page header help title": ({ groupProjectName }) => - groupProjectName === undefined - ? "Paramètres de votre projet personnel" - : `Paramètres pour "${groupProjectName}"`, - "page header help content": ({ - groupProjectName, - doesUserBelongToSomeGroupProject - }) => ( - <> - Cette page vous permet de configurer les paramètres qui s'appliquent - {groupProjectName === undefined - ? " à votre projet personnel" - : ` au ${groupProjectName}`} - . -
- {groupProjectName !== undefined && ( - <> - Soyez conscient que {groupProjectName} est un projet de groupe - partagé avec d'autres utilisateurs ; les modifications que vous - effectuez ici s'appliqueront à tous les membres du projet. -
- - )} - {doesUserBelongToSomeGroupProject && ( - <> - Vous pouvez passer d'un projet à l'autre en utilisant le menu - déroulant dans l'en-tête. -
- - )} - Notez que seul l'administrateur de votre instance Onyxia peut créer de - nouveaux projets. - - ), - "security-info": "Informations de sécurité", - "s3-configs": "Configurations S3" - }, - ProjectSettingsS3ConfigTab: { - "add custom config": "Ajouter une configuration S3 personnalisée" - }, - S3ConfigCard: { - "data source": "Source de données", - credentials: "Identifiants", - "sts credentials": "Jetons demandés dynamiquement en votre nom par Onyxia (STS)", - account: "Compte", - "use in services": "Utiliser dans les services", - "use in services helper": `Si activé, cette configuration sera utilisée par - défaut dans vos services qui implémentent une intégration S3.`, - "use for onyxia explorers": "Utiliser pour les explorateurs Onyxia", - "use for onyxia explorers helper": `Si activé, cette configuration sera utilisée - par l'explorateur de fichiers et l'explorateur de données.`, - edit: "Modifier", - delete: "Supprimer" - }, - AddCustomS3ConfigDialog: { - "dialog title": "Nouvelle configuration S3 personnalisée", - "dialog subtitle": - "Spécifiez un compte de service personnalisé ou connectez-vous à un autre service compatible S3", - cancel: "Annuler", - "save config": "Enregistrer la configuration", - "update config": "Mettre à jour la configuration", - "is required": "Ce champ est requis", - "must be an url": "URL non valide", - "not a valid access key id": - "Cela ne semble pas être un identifiant de clé d'accès valide", - "url textField label": "URL", - "url textField helper text": "URL du service S3", - "region textField label": "Région AWS S3", - "region textField helper text": - "Exemple : eu-west-1, si vous n'êtes pas sûr, laissez vide", - "workingDirectoryPath textField label": "Chemin du répertoire de travail", - "workingDirectoryPath textField helper text": ( - <> - Cela vous permet de spécifier le bucket et le préfixe de l'objet S3 que - vous possédez sur le service S3.
- Exemple : mon-bucket/mon-préfixe/ ou{" "} - juste mon-bucket/ si vous possédez tout le bucket. - - ), - "account credentials": "Identifiants du compte", - "friendlyName textField label": "Nom de la configuration", - "friendlyName textField helper text": - "Ceci est juste pour vous aider à identifier cette configuration. Exemple : Mon bucket AWS", - "isAnonymous switch label": "Accès anonyme", - "isAnonymous switch helper text": - "Mettre sur ON si aucune clé d'accès secrète n'est requise", - "accessKeyId textField label": "ID de clé d'accès", - "accessKeyId textField helper text": "Exemple : 1A2B3C4D5E6F7G8H9I0J", - "secretAccessKey textField label": "Clé d'accès secrète", - "sessionToken textField label": "Jeton de session", - "sessionToken textField helper text": - "Facultatif, laissez vide si vous n'êtes pas sûr", - "url style": "Style d'URL", - "url style helper text": `Spécifiez comment votre serveur S3 formate l'URL pour télécharger des fichiers.`, - "path style label": ({ example }) => ( - <> - Path style - {example !== undefined && ( - <> - :  - {example}mon-dataset.parquet - - )} - - ), - "virtual-hosted style label": ({ example }) => ( - <> - Virtual-hosted style - {example !== undefined && ( - <> - :  - {example}mon-dataset.parquet - - )} - - ) - }, - TestS3ConnectionButton: { - "test connection": "Tester la connexion", - "test connection failed": ({ errorMessage }) => ( - <> - Échec du test de connexion avec l'erreur :
- {errorMessage} - - ) - }, AccountUserInterfaceTab: { title: "Configurer le mode d'interface", "enable dark mode": "Activer le mode sombre", @@ -315,46 +187,6 @@ export const translations: Translations<"fr"> = { "reset helper dialogs helper text": "Réinitialiser les fenêtres de messages que vous avez demandé de ne plus afficher" }, - FileExplorerEntry: { - "page title - file explorer": "Explorateur de fichiers", - "what this page is used for - file explorer": - "Stocker ici vos fichiers de données.", - "help content": ({ accountTabLink, docHref }) => ( - <> - Lire{" "} - - notre documentation - - .   - - Configurer les clients MinIO - . - - ), - "title personal": "Mes données", - "description personal": "Vos propres fichiers et jeux de données.", - "title project": ({ projectName }) => `Projet ${projectName}`, - "description project": ({ projectName }) => - `Espace de stockage partagé pour le projet ${projectName}`, - tags: ({ type }) => { - switch (type) { - case "personal": - return "Mes données"; - case "project": - return "Données de groupe"; - } - } - }, - S3EntryCard: { - "space path": "Chemin de l'espace" - }, - FileExplorerDisabledDialog: { - "dialog title": "Aucun serveur S3 configuré", - "dialog body": - "Il n'y a aucun serveur S3 configuré pour cette instance. Mais vous pouvez en ajouter un manuellement pour activer l'explorateur de fichiers S3.", - 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 ?", @@ -604,18 +436,15 @@ export const translations: Translations<"fr"> = { reduce: "Réduire", home: "Accueil", account: "Mon compte", - projectSettings: "Paramètres du projet", catalog: "Catalogue de services", myServices: "Mes services", mySecrets: "Mes secrets", - myFiles: "Mes fichiers", "divider: services features": "Fonctionnalités relative aux services", "divider: external services features": "Fonctionnalités relative aux services externes", "divider: onyxia instance specific features": "Fonctionnalités spécifiques à cette instance d'Onyxia", dataExplorer: "Explorateur de Données", - fileExplorer: "Explorateur de Fichiers", dataCollection: "Explorateur de Collections", sqlOlapShell: "Coquille SQL OLAP" }, diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index 2b48524a5..04a009d54 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -130,134 +130,6 @@ export const translations: Translations<"it"> = { ), "expires in": ({ howMuchTime }) => `Il token scade in ${howMuchTime}` }, - ProjectSettings: { - "page header title": "Impostazioni del Progetto", - "page header help title": ({ groupProjectName }) => - groupProjectName === undefined - ? "Impostazioni del tuo progetto personale" - : `Impostazioni per "${groupProjectName}"`, - "page header help content": ({ - groupProjectName, - doesUserBelongToSomeGroupProject - }) => ( - <> - Questa pagina ti permette di configurare le impostazioni applicabili a - {groupProjectName === undefined - ? " il tuo progetto personale" - : ` il progetto ${groupProjectName}`} - . -
- {groupProjectName !== undefined && ( - <> - Sii consapevole che {groupProjectName} è un progetto di gruppo - condiviso con altri utenti; le modifiche che apporti qui si - applicheranno a tutti i membri del progetto. -
- - )} - {doesUserBelongToSomeGroupProject && ( - <> - Puoi passare da un progetto all'altro utilizzando il menu a - tendina nell'intestazione. -
- - )} - Nota che solo l'amministratore della tua istanza Onyxia può creare nuovi - progetti. - - ), - "security-info": "Informazioni sulla Sicurezza", - "s3-configs": "Configurazioni S3" - }, - ProjectSettingsS3ConfigTab: { - "add custom config": "Aggiungi una configurazione S3 personalizzata" - }, - S3ConfigCard: { - "data source": "Fonte dei dati", - credentials: "Credenziali", - "sts credentials": "Token richiesti dinamicamente per tuo conto da Onyxia (STS)", - account: "Account", - "use in services": "Utilizza nei servizi", - "use in services helper": `Se abilitato, questa configurazione verrà utilizzata - di default nei tuoi servizi che implementano un'integrazione S3.`, - "use for onyxia explorers": "Utilizza per gli esploratori Onyxia", - "use for onyxia explorers helper": `Se abilitato, questa configurazione verrà utilizzata - dall'esploratore di file e dall'esploratore di dati.`, - edit: "Modifica", - delete: "Elimina" - }, - AddCustomS3ConfigDialog: { - "dialog title": "Nuova configurazione S3 personalizzata", - "dialog subtitle": - "Specifica un account di servizio personalizzato o connettiti ad un altro servizio compatibile con S3", - cancel: "Annulla", - "save config": "Salva configurazione", - "update config": "Aggiorna configurazione", - "is required": "Questo campo è richiesto", - "must be an url": "URL non valido", - "not a valid access key id": "Non sembra un ID chiave di accesso valido", - "url textField label": "URL", - "url textField helper text": "URL del servizio S3", - "region textField label": "Regione AWS S3", - "region textField helper text": - "Esempio: eu-west-1, se non sei sicuro, lascia vuoto", - "workingDirectoryPath textField label": "Percorso della directory di lavoro", - "workingDirectoryPath textField helper text": ( - <> - Questo ti permette di specificare il bucket e il prefisso dell'oggetto S3 - che possiedi sul servizio S3.
- Esempio: il-mio-bucket/il-mio-prefisso/ o{" "} - solo il-mio-bucket/ se possiedi l'intero bucket. - - ), - "account credentials": "Credenziali dell'account", - "friendlyName textField label": "Nome della configurazione", - "friendlyName textField helper text": - "Questo serve solo ad aiutarti a identificare questa configurazione. Esempio: Il mio bucket AWS", - - "isAnonymous switch label": "Accesso anonimo", - "isAnonymous switch helper text": - "Impostare su ON se non è richiesta una chiave di accesso segreta", - - "accessKeyId textField label": "ID chiave di accesso", - "accessKeyId textField helper text": "Esempio: 1A2B3C4D5E6F7G8H9I0J", - "secretAccessKey textField label": "Chiave di accesso segreta", - "sessionToken textField label": "Token di sessione", - "sessionToken textField helper text": "Opzionale, lascia vuoto se non sei sicuro", - "url style": "Stile URL", - "url style helper text": `Specifica come il tuo server S3 formatta l'URL per il download dei file.`, - "path style label": ({ example }) => ( - <> - Stile del percorso - {example !== undefined && ( - <> - :  - {example}il-mio-dataset.parquet - - )} - - ), - "virtual-hosted style label": ({ example }) => ( - <> - Stile virtual-hosted - {example !== undefined && ( - <> - :  - {example}il-mio-dataset.parquet - - )} - - ) - }, - TestS3ConnectionButton: { - "test connection": "Testa la connessione", - "test connection failed": ({ errorMessage }) => ( - <> - Test della connessione fallito con errore:
- {errorMessage} - - ) - }, AccountUserInterfaceTab: { title: "Configurare la modalità di interfaccia", "enable dark mode": "Attivare la modalità scura", @@ -310,45 +182,6 @@ export const translations: Translations<"it"> = { "reset helper dialogs helper text": "Ripristinare le finestre di messaggi che hai richiesto di non mostrare più" }, - FileExplorerEntry: { - "page title - file explorer": "Esplora file", - "what this page is used for - file explorer": "Archivia qui i tuoi file di dati.", - "help content": ({ accountTabLink, docHref }) => ( - <> - Leggere{" "} - - la nostra documentazione - - .   - - Configurare i client MinIO - . - - ), - "title personal": "I miei dati", - "description personal": "I tuoi file e dataset personali.", - "title project": ({ projectName }) => `Progetto ${projectName}`, - "description project": ({ projectName }) => - `Spazio di archiviazione condiviso per il progetto ${projectName}`, - tags: ({ type }) => { - switch (type) { - case "personal": - return "I miei dati"; - case "project": - return "Dati del gruppo"; - } - } - }, - S3EntryCard: { - "space path": "Percorso dello spazio" - }, - FileExplorerDisabledDialog: { - "dialog title": "Nessun server S3 configurato", - "dialog body": - "Non è stato configurato nessun server S3 per questa istanza. Tuttavia, è possibile aggiungerne uno manualmente per abilitare l'esploratore file S3.", - 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?", @@ -402,8 +235,9 @@ export const translations: Translations<"it"> = { la nostra documentazione .   - Configurare il tuo Vault CLI locale - . + + Configurare il tuo Vault CLI locale + . ) }, @@ -597,17 +431,14 @@ export const translations: Translations<"it"> = { reduce: "Ridurre", home: "Home", account: "Il mio account", - projectSettings: "Impostazioni del progetto", catalog: "Catalogo di servizi", myServices: "I miei servizi", mySecrets: "I miei segreti", - myFiles: "I miei file", "divider: services features": "Funzioni relative ai servizi", "divider: external services features": "Funzioni relative ai servizi esterni", "divider: onyxia instance specific features": "Funzioni specifiche di questa istanza di Onyxia", dataExplorer: "Esploratore di Dati", - fileExplorer: "Esploratore di File", dataCollection: "Esploratore di Collezioni", sqlOlapShell: "Guscio SQL OLAP" }, diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 663cd2e35..279dfe1e5 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -130,135 +130,6 @@ export const translations: Translations<"nl"> = { ), "expires in": ({ howMuchTime }) => `Het token vervalt in ${howMuchTime}` }, - ProjectSettings: { - "page header title": "Projectinstellingen", - "page header help title": ({ groupProjectName }) => - groupProjectName === undefined - ? "Instellingen van uw persoonlijke project" - : `Instellingen voor "${groupProjectName}"`, - "page header help content": ({ - groupProjectName, - doesUserBelongToSomeGroupProject - }) => ( - <> - Deze pagina stelt u in staat de instellingen te configureren die van - toepassing zijn op - {groupProjectName === undefined - ? " uw persoonlijke project" - : ` het ${groupProjectName} project`} - . -
- {groupProjectName !== undefined && ( - <> - Wees u ervan bewust dat {groupProjectName} een groepsproject is - gedeeld met andere gebruikers; de wijzigingen die u hier - aanbrengt, zijn van toepassing op alle leden van het project. -
- - )} - {doesUserBelongToSomeGroupProject && ( - <> - U kunt tussen uw projecten wisselen via het dropdownmenu in de - kop. -
- - )} - Let op: alleen de beheerder van uw Onyxia instantie kan nieuwe projecten - aanmaken. - - ), - "security-info": "Veiligheidsinformatie", - "s3-configs": "S3-configuraties" - }, - ProjectSettingsS3ConfigTab: { - "add custom config": "Voeg een aangepaste S3-configuratie toe" - }, - S3ConfigCard: { - "data source": "Gegevensbron", - credentials: "Inloggegevens", - "sts credentials": "Dynamisch aangevraagde tokens namens u door Onyxia (STS)", - account: "Account", - "use in services": "Gebruiken in diensten", - "use in services helper": `Indien ingeschakeld, zal deze configuratie standaard worden gebruikt in uw diensten die een S3-integratie implementeren.`, - "use for onyxia explorers": "Gebruiken voor Onyxia-verkenners", - "use for onyxia explorers helper": `Indien ingeschakeld zal deze configuratie worden gebruikt - door de bestandsverkenner en de gegevensverkenner.`, - edit: "Bewerken", - delete: "Verwijderen" - }, - AddCustomS3ConfigDialog: { - "dialog title": "Nieuwe aangepaste S3-configuratie", - "dialog subtitle": - "Specificeer een aangepast serviceaccount of verbind met een andere S3-compatibele service", - cancel: "Annuleren", - "save config": "Configuratie opslaan", - "update config": "Configuratie bijwerken", - "is required": "Dit veld is verplicht", - "must be an url": "Geen geldige URL", - "not a valid access key id": "Dit lijkt geen geldige toegangssleutel-ID te zijn", - "url textField label": "URL", - "url textField helper text": "URL van de S3-service", - "region textField label": "AWS S3 Regio", - "region textField helper text": - "Voorbeeld: eu-west-1, laat leeg indien niet zeker", - "workingDirectoryPath textField label": "Pad van werkdirectory", - "workingDirectoryPath textField helper text": ( - <> - Hiermee kunt u de bucket en het S3-objectprefix specificeren dat u bezit - op de S3-service.
- Voorbeeld: mijn-bucket/mijn-prefix/ of{" "} - alleen mijn-bucket/ als u de hele bucket bezit. - - ), - "account credentials": "Accountgegevens", - "friendlyName textField label": "Configuratienaam", - "friendlyName textField helper text": - "Dit helpt je alleen om deze configuratie te identificeren. Voorbeeld: Mijn AWS-bucket", - - "isAnonymous switch label": "Anonieme toegang", - "isAnonymous switch helper text": - "Zet op AAN als er geen geheime toegangssleutel nodig is", - - "accessKeyId textField label": "Toegangssleutel-ID", - "accessKeyId textField helper text": "Voorbeeld: 1A2B3C4D5E6F7G8H9I0J", - "secretAccessKey textField label": "Geheime toegangssleutel", - "sessionToken textField label": "Sessietoken", - "sessionToken textField helper text": - "Optioneel, laat leeg als u het niet zeker weet", - "url style": "URL-stijl", - "url style helper text": `Specificeer hoe uw S3-server de URL formatteert voor het downloaden van bestanden.`, - "path style label": ({ example }) => ( - <> - Padstijl - {example !== undefined && ( - <> - :  - {example}mijn-dataset.parquet - - )} - - ), - "virtual-hosted style label": ({ example }) => ( - <> - Virtueel-gehoste stijl - {example !== undefined && ( - <> - :  - {example}mijn-dataset.parquet - - )} - - ) - }, - TestS3ConnectionButton: { - "test connection": "Verbinding testen", - "test connection failed": ({ errorMessage }) => ( - <> - Verbindingstest mislukt met fout:
- {errorMessage} - - ) - }, AccountUserInterfaceTab: { title: "De interfacemodus configureren", "enable dark mode": "Donkere modus activeren", @@ -312,43 +183,6 @@ export const translations: Translations<"nl"> = { "reset helper dialogs helper text": "De berichtvensters waarvan u heeft gevraagd ze niet meer weer te geven, opnieuw initialiseren" }, - FileExplorerEntry: { - "page title - file explorer": "Bestandsverkenner", - "what this page is used for - file explorer": "Sla hier uw gegevensbestanden op.", - "help content": ({ accountTabLink, docHref }) => ( - <> - Lezen{" "} - - onze documentatie - - .   - Minio-clients instellen. - - ), - "title personal": "Mijn gegevens", - "description personal": "Je eigen bestanden en datasets.", - "title project": ({ projectName }) => `Project ${projectName}`, - "description project": ({ projectName }) => - `Gedeelde opslagruimte voor project ${projectName}`, - tags: ({ type }) => { - switch (type) { - case "personal": - return "Mijn gegevens"; - case "project": - return "Groepsgegevens"; - } - } - }, - S3EntryCard: { - "space path": "Ruimtepad" - }, - FileExplorerDisabledDialog: { - "dialog title": "Geen S3-server geconfigureerd", - "dialog body": - "Er is geen S3-server geconfigureerd voor deze instantie. Je kunt er echter handmatig een toevoegen om de S3-bestandsverkenner in te schakelen.", - 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?", @@ -596,18 +430,15 @@ export const translations: Translations<"nl"> = { reduce: "Verkleinen", home: "Onthaal", account: "Mijn account", - projectSettings: "Projectinstellingen", catalog: "Catalogus van de diensten", myServices: "Mijn diensten", mySecrets: "Mijn geheimen", - myFiles: "Mijn bestanden", "divider: services features": "Functionaliteiten met betrekking tot de diensten", "divider: external services features": "Functionaliteiten met betrekking tot de externe diensten", "divider: onyxia instance specific features": "Functionaliteiten die specifiek zijn voor deze instantie van Onyxia", dataExplorer: "Data Verkenner", - fileExplorer: "Bestanden Verkenner", dataCollection: "Collecties Verkenner", sqlOlapShell: "SQL OLAP Shell" }, diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 437babdc8..ec4de4741 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -131,134 +131,6 @@ export const translations: Translations<"no"> = { ), "expires in": ({ howMuchTime }) => `Token går ut om ${howMuchTime}` }, - ProjectSettings: { - "page header title": "Prosjektinnstillinger", - "page header help title": ({ groupProjectName }) => - groupProjectName === undefined - ? "Innstillinger for ditt personlige prosjekt" - : `Innstillinger for "${groupProjectName}"`, - "page header help content": ({ - groupProjectName, - doesUserBelongToSomeGroupProject - }) => ( - <> - Denne siden lar deg konfigurere innstillingene som gjelder for - {groupProjectName === undefined - ? " ditt personlige prosjekt" - : ` ${groupProjectName}-prosjektet`} - . -
- {groupProjectName !== undefined && ( - <> - Vær oppmerksom på at {groupProjectName} er et gruppeprosjekt delt - med andre brukere; endringene du gjør her vil gjelde for alle - medlemmer av prosjektet. -
- - )} - {doesUserBelongToSomeGroupProject && ( - <> - Du kan bytte mellom dine prosjekter ved å bruke rullegardinmenyen - i overskriften. -
- - )} - Merk at bare administratoren for din Onyxia-instans kan opprette nye - prosjekter. - - ), - "security-info": "Sikkerhetsinformasjon", - "s3-configs": "S3-konfigurasjoner" - }, - ProjectSettingsS3ConfigTab: { - "add custom config": "Legg til en tilpasset S3-konfigurasjon" - }, - S3ConfigCard: { - "data source": "Datakilde", - credentials: "Legitimasjon", - "sts credentials": - "Token som dynamisk etterspørres på dine vegne av Onyxia (STS)", - account: "Konto", - "use in services": "Bruk i tjenester", - "use in services helper": `Hvis aktivert, vil denne konfigurasjonen brukes som standard i dine tjenester som implementerer en S3-integrasjon.`, - "use for onyxia explorers": "Bruk for Onyxia utforskere", - "use for onyxia explorers helper": `Hvis aktivert, vil denne konfigurasjonen brukes - av filutforskeren og datautforskeren.`, - edit: "Rediger", - delete: "Slett" - }, - AddCustomS3ConfigDialog: { - "dialog title": "Ny tilpasset S3-konfigurasjon", - "dialog subtitle": - "Angi en tilpasset tjenestekonto eller koble til en annen S3-kompatibel tjeneste", - cancel: "Avbryt", - "save config": "Lagre konfigurasjon", - "update config": "Oppdater konfigurasjon", - "is required": "Dette feltet er påkrevd", - "must be an url": "Ikke en gyldig URL", - "not a valid access key id": "Dette ser ikke ut som en gyldig tilgangsnøkkel-ID", - "url textField label": "URL", - "url textField helper text": "URL til S3-tjenesten", - "region textField label": "AWS S3-region", - "region textField helper text": - "Eksempel: eu-west-1, hvis du er usikker, la det stå tomt", - "workingDirectoryPath textField label": "Arbeidsmappesti", - "workingDirectoryPath textField helper text": ( - <> - Dette lar deg spesifisere bøtten og S3-objektprefikset du eier på - S3-tjenesten.
- Eksempel: min-bøtte/mitt-prefiks/ eller{" "} - kun min-bøtte/ hvis du eier hele bøtten. - - ), - "account credentials": "Kontoinformasjon", - "friendlyName textField label": "Konfigurasjonsnavn", - "friendlyName textField helper text": - "Dette er bare for å hjelpe deg med å identifisere denne konfigurasjonen. Eksempel: Min AWS-bøtte", - - "isAnonymous switch label": "Anonym tilgang", - "isAnonymous switch helper text": - "Sett til PÅ hvis ingen hemmelig tilgangsnøkkel er nødvendig", - - "accessKeyId textField label": "Tilgangsnøkkel-ID", - "accessKeyId textField helper text": "Eksempel: 1A2B3C4D5E6F7G8H9I0J", - "secretAccessKey textField label": "Hemmelig tilgangsnøkkel", - "sessionToken textField label": "Sesjonstoken", - "sessionToken textField helper text": "Valgfritt, la være tom hvis usikker", - "url style": "URL-stil", - "url style helper text": `Spesifiser hvordan din S3-server formaterer URL-en for nedlasting av filer.`, - "path style label": ({ example }) => ( - <> - Sti-stil - {example !== undefined && ( - <> - :  - {example}mitt-datasett.parquet - - )} - - ), - "virtual-hosted style label": ({ example }) => ( - <> - Virtuelt-vertsbasert stil - {example !== undefined && ( - <> - :  - {example}mitt-datasett.parquet - - )} - - ) - }, - TestS3ConnectionButton: { - "test connection": "Test forbindelse", - "test connection failed": ({ errorMessage }) => ( - <> - Test av forbindelse feilet med feil:
- {errorMessage} - - ) - }, AccountUserInterfaceTab: { title: "Grensesnittspreferanser", "enable dark mode": "Skru på mørk modus", @@ -308,43 +180,6 @@ export const translations: Translations<"no"> = { "reset helper dialogs helper text": "Tilbakestill meldingsvinduer som er bedt om å ikke vises igjen" }, - FileExplorerEntry: { - "page title - file explorer": "Filutforsker", - "what this page is used for - file explorer": "Her kan du bla gjennom S3-bøtter.", - "help content": ({ accountTabLink, docHref }) => ( - <> - Les{" "} - - dokumentasjonen vår - - .   - Konfigurer minio-klientene. - - ), - "title personal": "Mine data", - "description personal": "Dine egne filer og datasett.", - "title project": ({ projectName }) => `Prosjekt ${projectName}`, - "description project": ({ projectName }) => - `Felles lagringsområde for prosjektet ${projectName}`, - tags: ({ type }) => { - switch (type) { - case "personal": - return "Mine data"; - case "project": - return "Gruppedata"; - } - } - }, - S3EntryCard: { - "space path": "Områdesti" - }, - FileExplorerDisabledDialog: { - "dialog title": "Ingen S3-server konfigurert", - "dialog body": - "Det er ingen S3-server konfigurert for denne instansen. Men du kan legge til en manuelt for å aktivere S3-filutforskeren.", - 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å?", @@ -595,17 +430,14 @@ export const translations: Translations<"no"> = { reduce: "Reduser", home: "Hjem", account: "Min konto", - projectSettings: "Prosjektinnstillinger", catalog: "Tjenestekatalog", myServices: "Mine tjenester", mySecrets: "Mine hemmeligheter", - myFiles: "Mine filer", "divider: services features": "Tjenestefunksjoner", "divider: external services features": "Eksterne tjenestefunksjoner", "divider: onyxia instance specific features": "Onyxia-instansspesifikke funksjoner", dataExplorer: "Datautforsker", - fileExplorer: "Filutforsker", dataCollection: "Samlingseksplorer", sqlOlapShell: "SQL OLAP-Skall" }, diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index cb8690d1b..900c46e75 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -120,123 +120,6 @@ export const translations: Translations<"zh-CN"> = { ), "expires in": ({ howMuchTime }) => `该令牌有效期至 ${howMuchTime}` }, - ProjectSettings: { - "page header title": "项目设置", - "page header help title": ({ groupProjectName }) => - groupProjectName === undefined - ? "您个人项目的设置" - : `“${groupProjectName}”的设置`, - "page header help content": ({ - groupProjectName, - doesUserBelongToSomeGroupProject - }) => ( - <> - 本页面允许您配置适用于 - {groupProjectName === undefined - ? " 您的个人项目" - : ` ${groupProjectName}项目`}{" "} - 的设置。 -
- {groupProjectName !== undefined && ( - <> - 请注意,${groupProjectName}是一个与其他用户共享的团队项目; - 您在此处所做的设置更改将适用于所有项目成员。 -
- - )} - {doesUserBelongToSomeGroupProject && ( - <> - 您可以使用标题中的下拉菜单在您的项目之间切换。 -
- - )} - 请注意,只有您的Onyxia实例管理员可以创建新项目。 - - ), - "security-info": "安全信息", - "s3-configs": "S3 配置" - }, - ProjectSettingsS3ConfigTab: { - "add custom config": "添加自定义S3配置" - }, - S3ConfigCard: { - "data source": "数据源", - credentials: "凭证", - "sts credentials": "由Onyxia代表您动态请求的令牌 (STS)", - account: "账户", - "use in services": "在服务中使用", - "use in services helper": `如果启用,此配置将默认用于实现S3集成的服务中。`, - "use for onyxia explorers": "用于Onyxia探索器", - "use for onyxia explorers helper": `如果启用,此配置将被文件浏览器和数据浏览器使用。`, - edit: "编辑", - delete: "删除" - }, - AddCustomS3ConfigDialog: { - "dialog title": "新的自定义 S3 配置", - "dialog subtitle": "指定自定义服务账户或连接到另一个兼容 S3 的服务", - cancel: "取消", - "save config": "保存配置", - "update config": "更新配置", - "is required": "此字段为必填项", - "must be an url": "不是有效的 URL", - "not a valid access key id": "这不像是一个有效的访问密钥 ID", - "url textField label": "URL", - "url textField helper text": "S3 服务的 URL", - "region textField label": "AWS S3 区域", - "region textField helper text": "例如:eu-west-1,如果不确定,请留空", - "workingDirectoryPath textField label": "工作目录路径", - "workingDirectoryPath textField helper text": ( - <> - 这可以让你指定在 S3 服务上你拥有的桶和 S3 对象前缀。
- 例如:我的桶/我的前缀/仅我的桶/{" "} - 如果你拥有整个桶。 - - ), - "account credentials": "账户凭证", - "friendlyName textField label": "配置名称", - "friendlyName textField helper text": - "这只是帮助您识别此配置。例如:我的 AWS 存储桶", - "isAnonymous switch label": "匿名访问", - "isAnonymous switch helper text": "如果不需要密钥,请将其设置为开启", - "accessKeyId textField label": "访问密钥 ID", - "accessKeyId textField helper text": "例如:1A2B3C4D5E6F7G8H9I0J", - "secretAccessKey textField label": "秘密访问密钥", - "sessionToken textField label": "会话令牌", - "sessionToken textField helper text": "可选的,如果不确定请留空", - "url style": "URL 样式", - "url style helper text": `指定您的 S3 服务器如何格式化下载文件的 URL。`, - "path style label": ({ example }) => ( - <> - 路径样式 - {example !== undefined && ( - <> - :  - {example}我的数据集.parquet - - )} - - ), - "virtual-hosted style label": ({ example }) => ( - <> - 虚拟托管样式 - {example !== undefined && ( - <> - :  - {example}我的数据集.parquet - - )} - - ) - }, - TestS3ConnectionButton: { - "test connection": "测试连接", - "test connection failed": ({ errorMessage }) => ( - <> - 测试连接失败,错误信息:
- {errorMessage} - - ) - }, AccountUserInterfaceTab: { title: "配置界面模式", "enable dark mode": "开启深色模式", @@ -277,41 +160,6 @@ export const translations: Translations<"zh-CN"> = { reset: "重置", "reset helper dialogs helper text": "重置您要求不再显示的消息窗口" }, - FileExplorerEntry: { - "page title - file explorer": "文件资源管理器", - "what this page is used for - file explorer": "在此处存储您的数据.", - "help content": ({ accountTabLink, docHref }) => ( - <> - 阅读{" "} - - 我们的文档 - - 。  - 配置 Minio 客户端。 - - ), - "title personal": "我的数据", - "description personal": "您自己的文件和数据集。", - "title project": ({ projectName }) => `项目 ${projectName}`, - "description project": ({ projectName }) => `项目 ${projectName} 的共享存储空间`, - tags: ({ type }) => { - switch (type) { - case "personal": - return "我的数据"; - case "project": - return "群组数据"; - } - } - }, - S3EntryCard: { - "space path": "空间路径" - }, - FileExplorerDisabledDialog: { - "dialog title": "未配置S3服务器", - "dialog body": "此实例未配置S3服务器。但您可以手动添加一个,以启用S3文件浏览器。", - cancel: "取消", - "go to settings": "前往设置" - }, ConfirmBucketCreationAttemptDialog: { "bucket does not exist title": ({ bucket }) => `存储桶 ${bucket} 不存在`, "bucket does not exist body": "要立即尝试创建吗?", @@ -551,16 +399,13 @@ export const translations: Translations<"zh-CN"> = { reduce: "缩小", home: "我的主页", account: "我的账号", - projectSettings: "项目设置", catalog: "服务目录", myServices: "我的服务", mySecrets: "我的密钥", - myFiles: "我的文档", "divider: services features": "服务功能", "divider: external services features": "外部服务功能", "divider: onyxia instance specific features": "Onyxia实例特定功能", dataExplorer: "数据浏览器", - fileExplorer: "文件浏览器", dataCollection: "集合浏览器", sqlOlapShell: "SQL OLAP 外壳" }, diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index df0d6c1bc..b2a1ba5d3 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -16,23 +16,20 @@ export type ComponentKey = | import("ui/pages/mySecrets/SecretsExplorer/SecretsExplorerItems").I18n | import("ui/pages/mySecrets/SecretsExplorer/SecretsExplorerItems/SecretsExplorerItem").I18n | import("ui/pages/mySecrets/MySecretsEditor/MySecretsEditorRow").I18n - | 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/s3Explorer/Explorer").I18n | import("ui/pages/s3Explorer/S3ConfigDialogs/CreateOrUpdateProfileDialog").I18n - | import("ui/pages/fileExplorer/Explorer/Explorer").I18n - | import("ui/pages/fileExplorer/Explorer/ExplorerButtonBar").I18n - | import("ui/pages/fileExplorer/Explorer/ExplorerItems").I18n - | import("ui/pages/fileExplorer/Explorer/ExplorerItems/ExplorerItem").I18n - | import("ui/pages/fileExplorer/Explorer/ExplorerUploadModal/ExplorerUploadModalDropArea").I18n - | import("ui/pages/fileExplorer/Explorer/ExplorerUploadModal/ExplorerUploadProgress").I18n - | import("ui/pages/fileExplorer/Explorer/ExplorerUploadModal/ExplorerUploadModal").I18n - | import("ui/pages/fileExplorer/Explorer/ListExplorer/ListExplorerItems").I18n - | import("ui/pages/fileExplorer/Explorer/ExplorerDownloadSnackbar").I18n - | import("ui/pages/fileExplorer/ShareFile/ShareDialog").I18n - | import("ui/pages/fileExplorer/ShareFile/SelectTime").I18n + | import("ui/pages/s3Explorer/headless/Explorer/Explorer").I18n + | import("ui/pages/s3Explorer/headless/Explorer/ExplorerButtonBar").I18n + | import("ui/pages/s3Explorer/headless/Explorer/ExplorerItems").I18n + | import("ui/pages/s3Explorer/headless/Explorer/ExplorerItems/ExplorerItem").I18n + | import("ui/pages/s3Explorer/headless/Explorer/ExplorerUploadModal/ExplorerUploadModalDropArea").I18n + | import("ui/pages/s3Explorer/headless/Explorer/ExplorerUploadModal/ExplorerUploadProgress").I18n + | import("ui/pages/s3Explorer/headless/Explorer/ExplorerUploadModal/ExplorerUploadModal").I18n + | import("ui/pages/s3Explorer/headless/Explorer/ListExplorer/ListExplorerItems").I18n + | import("ui/pages/s3Explorer/headless/Explorer/ExplorerDownloadSnackbar").I18n + | import("ui/pages/s3Explorer/headless/ShareFile/ShareDialog").I18n + | import("ui/pages/s3Explorer/headless/ShareFile/SelectTime").I18n | import("ui/App/Header/Header").I18n | import("ui/App/LeftBar").I18n | import("ui/App/AutoLogoutCountdown").I18n @@ -50,11 +47,6 @@ export type ComponentKey = | import("ui/pages/account/AccountKubernetesTab").I18n | import("ui/pages/account/AccountUserInterfaceTab").I18n | import("ui/pages/account/AccountVaultTab").I18n - | import("ui/pages/projectSettings/Page").I18n - | import("ui/pages/projectSettings/ProjectSettingsS3ConfigTab/ProjectSettingsS3ConfigTab").I18n - | import("ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigCard").I18n - | import("ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/AddCustomS3ConfigDialog").I18n - | import("ui/pages/projectSettings/ProjectSettingsS3ConfigTab/TestS3ConnectionButton").I18n | import("ui/App/Footer").I18n | import("ui/pages/catalog/Page").I18n | import("ui/pages/catalog/CatalogChartCard").I18n diff --git a/web/src/ui/pages/fileExplorer/Page.tsx b/web/src/ui/pages/fileExplorer/Page.tsx deleted file mode 100644 index 8ffbff3a9..000000000 --- a/web/src/ui/pages/fileExplorer/Page.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import { tss } from "tss"; -import { PageHeader } from "onyxia-ui/PageHeader"; -import { useEffect } from "react"; -import { useConstCallback } from "powerhooks/useConstCallback"; -import { copyToClipboard } from "ui/tools/copyToClipboard"; -import { useCoreState, getCoreSync } from "core"; -import { Explorer, type ExplorerProps } from "./Explorer"; -import { routes, useRoute } from "ui/routes"; -import { routeGroup } from "./route"; -import { Evt } from "evt"; -import type { Param0 } from "tsafe"; -import { useConst } from "powerhooks/useConst"; -import { assert } from "tsafe/assert"; -import { env } from "env"; -import { getIconUrlByName, customIcons } from "lazy-icons"; -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"; -import { useEvt } from "evt/hooks"; -import { - ConfirmBucketCreationAttemptDialog, - type ConfirmBucketCreationAttemptDialogProps -} from "ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog"; - -const Page = withLoader({ - loader: enforceLogin, - Component: FileExplorer -}); -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)); - - const { t } = useTranslation("FileExplorerEntry"); - - const { - isCurrentWorkingDirectoryLoaded, - navigationError, - commandLogsEntries, - isNavigationOngoing, - uploadProgress, - currentWorkingDirectoryView, - pathMinDepth, - viewMode, - shareView, - isDownloadPreparing - } = useCoreState("fileExplorer", "main"); - - const { - functions: { fileExplorer } - } = getCoreSync(); - - const evtIsSnackbarOpen = useConst(() => Evt.create(isDownloadPreparing)); - - useEffect(() => { - evtIsSnackbarOpen.state = isDownloadPreparing; - }, [isDownloadPreparing]); - - useEffect(() => { - fileExplorer.initialize({ - directoryPath: route.params.path, - viewMode: route.params.mode - }); - }, []); - - 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 { classes, cx, css } = useStyles(); - - useEffect(() => { - if (currentWorkingDirectoryView === undefined) { - return; - } - routes[route.name]({ - ...route.params, - path: currentWorkingDirectoryView.directoryPath, - mode: viewMode - }).push(); - }); - - const evtExplorerAction = useConst(() => Evt.create()); - - const onOpenFile = useConstCallback(({ basename }) => { - //TODO use dataExplorer thunk - if ( - basename.endsWith(".parquet") || - basename.endsWith(".csv") || - basename.endsWith(".json") - ) { - const { path } = route.params; - - assert(path !== undefined); - - routes - .dataExplorer({ - source: `s3://${path.replace(/\/*$/g, "")}/${basename}` - }) - .push(); - return; - } - - fileExplorer.getFileDownloadUrl({ basename }).then(window.open); - }); - - const onRequestFilesUpload = useConstCallback( - ({ files }) => - fileExplorer.uploadFiles({ - files - }) - ); - - const onNavigate = useConstCallback( - ({ directoryPath }) => { - if (directoryPath === "") { - routes.fileExplorerEntry().push(); - return; - } - - fileExplorer.changeCurrentDirectory({ directoryPath }); - } - ); - - if (!isCurrentWorkingDirectoryLoaded) { - return ( -
- {(() => { - if (navigationError !== undefined) { - return ( -
- {navigationError.errorCase} - -
- ); - } - - return ; - })()} -
- ); - } - - return ( -
- - - -
- ); -} - -const useStyles = tss.withName({ FileExplorer }).create({ - root: { - height: "100%", - display: "flex", - flexDirection: "column" - }, - explorer: { - overflow: "hidden", - flex: 1, - width: "100%" - } -}); diff --git a/web/src/ui/pages/fileExplorer/index.ts b/web/src/ui/pages/fileExplorer/index.ts deleted file mode 100644 index 9cf4bc637..000000000 --- a/web/src/ui/pages/fileExplorer/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { lazy, memo } from "react"; -export * from "./route"; -export const LazyComponent = memo(lazy(() => import("./Page"))); diff --git a/web/src/ui/pages/fileExplorer/route.ts b/web/src/ui/pages/fileExplorer/route.ts deleted file mode 100644 index 8e1acd9af..000000000 --- a/web/src/ui/pages/fileExplorer/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { id } from "tsafe"; -import { - defineRoute, - createGroup, - param, - type ValueSerializer, - noMatch -} from "type-route"; -import { type ViewMode, viewModes } from "./shared/types"; - -export const routeDefs = { - myFiles: defineRoute( - { - path: param.path.trailing.ofType({ - parse: raw => decodeURIComponent(raw), // decode the path - stringify: value => encodeURI(value) // encode when creating URL - }), - mode: param.query.optional - .ofType( - id>({ - parse: raw => - !id(viewModes).includes(raw) - ? noMatch - : (raw as ViewMode), - stringify: value => value - }) - ) - .default(viewModes[0]) - }, - ({ path }) => [`/file-explorer/${path}`, `/my-files/${path}`] - ) -}; - -export const routeGroup = createGroup(routeDefs); diff --git a/web/src/ui/pages/fileExplorerEntry/FileExplorerDisabledDialog.stories.tsx b/web/src/ui/pages/fileExplorerEntry/FileExplorerDisabledDialog.stories.tsx deleted file mode 100644 index c4d9adbf1..000000000 --- a/web/src/ui/pages/fileExplorerEntry/FileExplorerDisabledDialog.stories.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { FileExplorerDisabledDialog } from "./FileExplorerDisabledDialog"; - -const meta = { - title: "Pages/MyFiles/FileExplorerDisabledDialog", - component: FileExplorerDisabledDialog -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: {} -}; diff --git a/web/src/ui/pages/fileExplorerEntry/FileExplorerDisabledDialog.tsx b/web/src/ui/pages/fileExplorerEntry/FileExplorerDisabledDialog.tsx deleted file mode 100644 index a4ad07b7d..000000000 --- a/web/src/ui/pages/fileExplorerEntry/FileExplorerDisabledDialog.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { memo } from "react"; -import { Dialog } from "onyxia-ui/Dialog"; -import { Button } from "onyxia-ui/Button"; -import { routes } from "ui/routes"; -import { declareComponentKeys, useTranslation } from "ui/i18n"; - -export const FileExplorerDisabledDialog = memo(() => { - const onClose = () => routes.home().push(); - const { t } = useTranslation({ FileExplorerDisabledDialog }); - - return ( - - - - - } - /> - ); -}); - -const { i18n } = declareComponentKeys< - "dialog title" | "dialog body" | "cancel" | "go to settings" ->()({ FileExplorerDisabledDialog }); - -export type I18n = typeof i18n; diff --git a/web/src/ui/pages/fileExplorerEntry/Page.tsx b/web/src/ui/pages/fileExplorerEntry/Page.tsx deleted file mode 100644 index 5540b2b73..000000000 --- a/web/src/ui/pages/fileExplorerEntry/Page.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { tss } from "tss"; -import { PageHeader } from "onyxia-ui/PageHeader"; -import { getIconUrlByName, customIcons } from "lazy-icons"; -import { declareComponentKeys, useTranslation, useResolveLocalizedString } from "ui/i18n"; -import { env } from "env"; -import { routes } from "ui/routes"; -import { useCoreState } from "core"; -import { FileExplorerDisabledDialog } from "./FileExplorerDisabledDialog"; -import type { Link } from "type-route"; -import { S3Entries } from "./S3Entries/S3Entries"; -import { withLoader } from "ui/tools/withLoader"; -import { enforceLogin } from "ui/shared/enforceLogin"; - -const Page = withLoader({ - loader: enforceLogin, - Component: FileExplorerEntry -}); -export default Page; - -function FileExplorerEntry() { - const isFileExplorerEnabled = useCoreState("fileExplorer", "isFileExplorerEnabled"); - if (!isFileExplorerEnabled) { - return ; - } - return ; -} - -function FileExplorerEntry_enabled() { - const { classes } = useStyles(); - - const { t } = useTranslation({ FileExplorerEntry }); - - const indexedS3Locations = useCoreState("s3ConfigManagement", "indexedS3Locations"); - - const { resolveLocalizedString } = useResolveLocalizedString({ - labelWhenMismatchingLanguage: false - }); - - if (indexedS3Locations.type === "user created s3 config") { - routes["myFiles"]({ path: indexedS3Locations.directoryPath }).replace(); - return; - } - - if (indexedS3Locations.locations.length < 2) { - routes["myFiles"]({ - path: indexedS3Locations.locations[0].directoryPath - }).replace(); - return; - } - - const entries = indexedS3Locations.locations.map(location => ({ - type: location.type, - directoryPath: location.directoryPath, - ...(() => { - switch (location.type) { - case "bookmark": - return { - title: resolveLocalizedString(location.title), - description: - location.description !== undefined - ? resolveLocalizedString(location.description) - : undefined, - tags: - location.tags !== undefined - ? location.tags.map(tag => resolveLocalizedString(tag)) - : undefined - }; - case "personal": - return { - title: t(`title ${location.type}`), - description: t(`description ${location.type}`), - tags: [t("tags", { type: location.type })] - }; - case "project": - return { - title: t(`title ${location.type}`, { - projectName: location.projectName - }), - description: t(`description ${location.type}`, { - projectName: location.projectName - }), - tags: [t("tags", { type: location.type })] - }; - } - })() - })); - - return ( -
- - -
- ); -} - -const useStyles = tss.withName({ FileExplorerEntry }).create({ - root: { - height: "100%", - display: "flex", - flexDirection: "column" - }, - body: { overflow: "auto" } -}); - -const { i18n } = declareComponentKeys< - | "page title - file explorer" - | "what this page is used for - file explorer" - | { - K: "help content"; - P: { - docHref: string; - accountTabLink: Link; - }; - R: JSX.Element; - } - | "title personal" - | "description personal" - | { K: "title project"; P: { projectName: string }; R: string } - | { K: "description project"; P: { projectName: string }; R: string } - | { - K: "tags"; - P: { type: "project" | "personal" }; - R: string; - } ->()({ FileExplorerEntry }); -export type I18n = typeof i18n; diff --git a/web/src/ui/pages/fileExplorerEntry/S3Entries/S3Entries.tsx b/web/src/ui/pages/fileExplorerEntry/S3Entries/S3Entries.tsx deleted file mode 100644 index e72eb5f10..000000000 --- a/web/src/ui/pages/fileExplorerEntry/S3Entries/S3Entries.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import Grid from "@mui/material/Grid2"; -import { S3EntryCard } from "./S3EntryCard"; -import { routes } from "ui/routes"; - -type S3Entry = { - type: "personal" | "project" | "bookmark"; - directoryPath: string; - title: string; - description: string | undefined; - tags: string[] | undefined; -}; - -type Props = { - className?: string; - entries: S3Entry[]; -}; - -export function S3Entries(props: Props) { - const { entries, className } = props; - - return ( - - {entries.map(entry => ( - - - routes.myFiles({ path: entry.directoryPath }).push() - } - tags={entry.tags} - /> - - ))} - - ); -} diff --git a/web/src/ui/pages/fileExplorerEntry/S3Entries/S3EntryCard.tsx b/web/src/ui/pages/fileExplorerEntry/S3Entries/S3EntryCard.tsx deleted file mode 100644 index c68df26bd..000000000 --- a/web/src/ui/pages/fileExplorerEntry/S3Entries/S3EntryCard.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { tss } from "tss"; -import { alpha } from "@mui/material/styles"; -import Card from "@mui/material/Card"; -import CardActionArea from "@mui/material/CardActionArea"; -import CardContent from "@mui/material/CardContent"; -import Chip from "@mui/material/Chip"; -import Box from "@mui/material/Box"; -import { Text } from "onyxia-ui/Text"; -import { getIconUrlByName } from "lazy-icons"; -import { Icon } from "onyxia-ui/Icon"; -import { declareComponentKeys, useTranslation } from "ui/i18n"; - -type DataSourceType = "personal" | "project" | "bookmark"; - -type Props = { - title: string; - description: string | undefined; - path: string; - type: DataSourceType; - onCardClick: () => void; - tags: string[] | undefined; -}; - -export function S3EntryCard(props: Props) { - const { title, description, path, type, tags, onCardClick } = props; - - const { classes } = useStyles({ type }); - const { t } = useTranslation({ S3EntryCard }); - return ( - - - - - - - - {title} - - {description && {description}} - - {`${t("space path")} : ${path}`} - - - {tags !== undefined && - tags.map(tag => ( - - ))} - - - - - ); -} - -const useStyles = tss - .withParams<{ type: DataSourceType }>() - .withName({ S3EntryCard }) - .create(({ theme, type }) => { - const typeColors = { - personal: theme.colors.useCases.alertSeverity.success.main, - project: theme.colors.useCases.alertSeverity.info.main, - bookmark: theme.colors.useCases.alertSeverity.warning.main - }; - - return { - card: { - borderRadius: 8, - backgroundColor: - type === "personal" - ? alpha(typeColors.personal, 0.25) - : theme.colors.useCases.surfaces.surface1, - height: "100%" - }, - path: { - color: theme.colors.useCases.typography.textSecondary - }, - chip: { - backgroundColor: typeColors[type], - color: theme.muiTheme.palette.getContrastText(typeColors[type]) - } - }; - }); - -const { i18n } = declareComponentKeys<"space path">()({ - S3EntryCard -}); -export type I18n = typeof i18n; diff --git a/web/src/ui/pages/fileExplorerEntry/index.ts b/web/src/ui/pages/fileExplorerEntry/index.ts deleted file mode 100644 index a24a816ef..000000000 --- a/web/src/ui/pages/fileExplorerEntry/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { lazy, memo } from "react"; -export * from "./route"; - -export const LazyComponent = memo(lazy(() => import("./Page"))); diff --git a/web/src/ui/pages/fileExplorerEntry/route.ts b/web/src/ui/pages/fileExplorerEntry/route.ts deleted file mode 100644 index ccde27ed4..000000000 --- a/web/src/ui/pages/fileExplorerEntry/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineRoute, createGroup } from "type-route"; - -export const routeDefs = { - fileExplorerEntry: defineRoute([`/file-explorer`, `/my-files`]) -}; - -export const routeGroup = createGroup(routeDefs); diff --git a/web/src/ui/pages/home/Page.tsx b/web/src/ui/pages/home/Page.tsx index 1a1c48c2b..835e7e394 100644 --- a/web/src/ui/pages/home/Page.tsx +++ b/web/src/ui/pages/home/Page.tsx @@ -33,7 +33,10 @@ function Home() { }); const { isUserLoggedIn, user } = useCoreState("userAuthentication", "main"); - const isFileExplorerEnabled = useCoreState("fileExplorer", "isFileExplorerEnabled"); + const isS3ExplorerEnabled = useCoreState( + "s3ProfilesManagement", + "isS3ExplorerEnabled" + ); const { t } = useTranslation({ Home }); @@ -145,7 +148,7 @@ function Home() { url: "https://join.slack.com/t/3innovation/shared_invite/zt-1hnzukjcn-6biCSmVy4qvyDGwbNI~sWg" } }, - ...(!isFileExplorerEnabled + ...(!isS3ExplorerEnabled ? [] : [ { @@ -154,7 +157,7 @@ function Home() { description: t("cardText3"), button: { label: t("cardButton3"), - url: routes.fileExplorerEntry().link.href + url: routes.s3Explorer_root().link.href } } ]) @@ -162,7 +165,7 @@ function Home() { } return env.HOMEPAGE_CARDS; - }, [t, isFileExplorerEnabled]); + }, [t, isS3ExplorerEnabled]); return (
diff --git a/web/src/ui/pages/index.ts b/web/src/ui/pages/index.ts index be6cec1ff..5bea643c7 100644 --- a/web/src/ui/pages/index.ts +++ b/web/src/ui/pages/index.ts @@ -4,16 +4,13 @@ import * as account from "./account"; import * as catalog from "./catalog"; import * as launcher from "./launcher"; import * as home from "./home"; -import * as myFiles from "./fileExplorer"; import * as mySecrets from "./mySecrets"; import * as myService from "./myService"; import * as myServices from "./myServices"; import * as page404 from "./page404"; -import * as projectSettings from "./projectSettings"; import * as document from "./document"; import * as sqlOlapShell from "./sqlOlapShell"; import * as dataExplorer from "./dataExplorer"; -import * as fileExplorer from "./fileExplorerEntry"; import * as dataCollection from "./dataCollection"; import * as s3Explorer from "./s3Explorer"; @@ -23,16 +20,13 @@ export const pages = { catalog, launcher, home, - myFiles, mySecrets, myService, myServices, page404, - projectSettings, document, sqlOlapShell, dataExplorer, - fileExplorer, dataCollection, s3Explorer }; diff --git a/web/src/ui/pages/launcher/Page.tsx b/web/src/ui/pages/launcher/Page.tsx index 2e1ef9a9f..c1b2f2e15 100644 --- a/web/src/ui/pages/launcher/Page.tsx +++ b/web/src/ui/pages/launcher/Page.tsx @@ -266,10 +266,7 @@ function Launcher() { myServicesSavedConfigsExtendedLink: routes.myServices({ isSavedConfigsExtended: true }).link, - - projectS3ConfigLink: routes.projectSettings({ - tabId: "s3-configs" - }).link + projectS3ConfigLink: routes.s3Explorer_root().link })); const { resolveLocalizedString } = useResolveLocalizedString({ diff --git a/web/src/ui/pages/projectSettings/Page.tsx b/web/src/ui/pages/projectSettings/Page.tsx deleted file mode 100644 index 296e6852b..000000000 --- a/web/src/ui/pages/projectSettings/Page.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { Tabs } from "onyxia-ui/Tabs"; -import { type TabId, tabIds } from "./tabIds"; -import { ProjectSettingsSecurityInfosTab } from "./ProjectSettingsSecurityInfosTab"; -import { ProjectSettingsS3ConfigTab } from "./ProjectSettingsS3ConfigTab"; -import { useTranslation } from "ui/i18n"; -import { PageHeader } from "onyxia-ui/PageHeader"; -import { tss } from "tss"; -import { declareComponentKeys } from "i18nifty"; -import { assert, type Equals } from "tsafe/assert"; -import { routes, useRoute } from "ui/routes"; -import { routeGroup } from "./route"; -import { useCoreState } from "core"; -import { getIconUrlByName, customIcons } from "lazy-icons"; -import { withLoader } from "ui/tools/withLoader"; -import { enforceLogin } from "ui/shared/enforceLogin"; - -const Page = withLoader({ - loader: enforceLogin, - Component: ProjectSettings -}); -export default Page; - -function ProjectSettings() { - const route = useRoute(); - assert(routeGroup.has(route)); - - const { t } = useTranslation({ ProjectSettings }); - - const groupProjectName = useCoreState("projectManagement", "groupProjectName"); - const doesUserBelongToSomeGroupProject = useCoreState( - "projectManagement", - "doesUserBelongToSomeGroupProject" - ); - - const { classes } = useStyles(); - - return ( -
- - ({ id: tabId, title: t(tabId) }))} - activeTabId={route.params.tabId} - maxTabCount={5} - onRequestChangeActiveTab={tabId => - routes[route.name]({ - ...route.params, - tabId - }).push() - } - > - {(() => { - switch (route.params.tabId) { - case "security-info": - return ; - case "s3-configs": - return ; - } - assert>(false); - })()} - -
- ); -} - -const { i18n } = declareComponentKeys< - | TabId - | "page header title" - | { - K: "page header help title"; - P: { - groupProjectName: string | undefined; - }; - } - | { - K: "page header help content"; - P: { - doesUserBelongToSomeGroupProject: boolean; - groupProjectName: string | undefined; - }; - R: JSX.Element; - } ->()({ - ProjectSettings -}); -export type I18n = typeof i18n; - -const useStyles = tss.withName({ ProjectSettings }).create(({ theme }) => ({ - root: { - height: "100%", - overflow: "auto" - }, - tabs: { - borderRadius: 8, - overflow: "hidden", - boxShadow: theme.shadows[1] - } -})); diff --git a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/ProjectSettingsS3ConfigTab.tsx b/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/ProjectSettingsS3ConfigTab.tsx deleted file mode 100644 index c23b1a16e..000000000 --- a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/ProjectSettingsS3ConfigTab.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { memo } from "react"; -import { useCoreState, getCoreSync } from "core"; -import { S3ConfigDialogs, type S3ConfigDialogsProps } from "./S3ConfigDialogs"; -import { useConst } from "powerhooks/useConst"; -import { Evt, type UnpackEvt } from "evt"; -import { S3ConfigCard } from "./S3ConfigCard"; -import { Button } from "onyxia-ui/Button"; -import { getIconUrlByName } from "lazy-icons"; -import { tss } from "tss"; -import { - MaybeAcknowledgeConfigVolatilityDialog, - type MaybeAcknowledgeConfigVolatilityDialogProps -} from "ui/shared/MaybeAcknowledgeConfigVolatilityDialog"; -import { Deferred } from "evt/tools/Deferred"; -import { declareComponentKeys, useTranslation } from "ui/i18n"; - -export type Props = { - className?: string; -}; - -export const ProjectSettingsS3ConfigTab = memo((props: Props) => { - const { className } = props; - - const { - evtConfirmCustomS3ConfigDeletionDialogOpen, - evtAddCustomS3ConfigDialogOpen, - evtMaybeAcknowledgeConfigVolatilityDialogOpen - } = useConst(() => ({ - evtConfirmCustomS3ConfigDeletionDialogOpen: - Evt.create< - UnpackEvt< - S3ConfigDialogsProps["evtConfirmCustomS3ConfigDeletionDialogOpen"] - > - >(), - evtAddCustomS3ConfigDialogOpen: - Evt.create< - UnpackEvt - >(), - evtMaybeAcknowledgeConfigVolatilityDialogOpen: - Evt.create() - })); - - const s3Configs = useCoreState("s3ConfigManagement", "s3Configs"); - const canInjectPersonalInfos = useCoreState( - "projectManagement", - "canInjectPersonalInfos" - ); - const { - functions: { s3ConfigManagement } - } = getCoreSync(); - - const { classes, css, theme } = useStyles(); - - const { t } = useTranslation({ ProjectSettingsS3ConfigTab }); - - return ( - <> -
-
- {s3Configs.map(s3Config => ( - { - if (s3Config.origin !== "project") { - return undefined; - } - - return () => - s3ConfigManagement.deleteS3Config({ - projectS3ConfigId: s3Config.id - }); - })()} - onIsExplorerConfigChange={value => - s3ConfigManagement.changeIsDefault({ - s3ConfigId: s3Config.id, - usecase: "explorer", - value - }) - } - onIsOnyxiaDefaultChange={value => - s3ConfigManagement.changeIsDefault({ - s3ConfigId: s3Config.id, - usecase: "defaultXOnyxia", - value - }) - } - onEdit={(() => { - if (s3Config.origin !== "project") { - return undefined; - } - - return () => - evtAddCustomS3ConfigDialogOpen.post({ - s3ConfigIdToEdit: s3Config.id - }); - })()} - onTestConnection={(() => { - if (s3Config.origin !== "project") { - return undefined; - } - - return () => - s3ConfigManagement.testS3Connection({ - projectS3ConfigId: s3Config.id - }); - })()} - canInjectPersonalInfos={canInjectPersonalInfos} - /> - ))} -
- - -
- - - ); -}); - -const useStyles = tss.withName({ ProjectSettingsS3ConfigTab }).create(({ theme }) => ({ - cardsWrapper: { - display: "flex", - flexWrap: "wrap", - gap: theme.spacing(3), - marginBottom: theme.spacing(4), - ...theme.spacing.rightLeft("padding", 3) - }, - card: { - flexBasis: `calc(50% - ${theme.spacing(3) / 2}px)` - } -})); - -const { i18n } = declareComponentKeys<"add custom config">()({ - ProjectSettingsS3ConfigTab -}); -export type I18n = typeof i18n; diff --git a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigCard.tsx b/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigCard.tsx deleted file mode 100644 index 9be7d30b2..000000000 --- a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigCard.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import { Text } from "onyxia-ui/Text"; -import Switch from "@mui/material/Switch"; -import { getIconUrlByName } from "lazy-icons"; -import { Button } from "onyxia-ui/Button"; -import { tss } from "tss"; -import { TestS3ConnectionButton } from "./TestS3ConnectionButton"; -import { Icon } from "onyxia-ui/Icon"; -import Tooltip from "@mui/material/Tooltip"; -import { declareComponentKeys, useTranslation } from "ui/i18n"; -import type { S3Config } from "core/usecases/s3ConfigManagement"; -import { assert } from "tsafe/assert"; - -type Props = { - className?: string; - s3Config: S3Config; - onDelete: (() => void) | undefined; - onIsExplorerConfigChange: (value: boolean) => void; - onIsOnyxiaDefaultChange: (value: boolean) => void; - onEdit: (() => void) | undefined; - onTestConnection: (() => void) | undefined; - canInjectPersonalInfos: boolean; -}; - -export function S3ConfigCard(props: Props) { - const { - className, - s3Config, - onDelete, - onIsExplorerConfigChange, - onIsOnyxiaDefaultChange, - onEdit, - onTestConnection, - canInjectPersonalInfos - } = props; - - const { classes, cx, css, theme } = useStyles(); - - const { t } = useTranslation({ S3ConfigCard }); - - return ( -
-
- {t("data source")}: -     - - - {s3Config.dataSource} - - {s3Config.region === "" ? null : <> - {s3Config.region}} - -
-
- {(() => { - switch (s3Config.origin) { - case "deploymentRegion": - return ( - <> - {t("credentials")}: -     - {t("sts credentials")} - - ); - case "project": - return ( - <> - {t("account")}: -     - {s3Config.friendlyName} - - ); - } - })()} -
-
- {t("use in services")} - - - -   - onIsOnyxiaDefaultChange(event.target.checked)} - inputProps={{ "aria-label": "controlled" }} - /> -
-
- {t("use for onyxia explorers")} - - - -   - onIsExplorerConfigChange(event.target.checked)} - inputProps={{ "aria-label": "controlled" }} - /> -
-
- {s3Config.origin === "project" && - (assert(onTestConnection !== undefined), - ( - - ))} -
-
- {onEdit !== undefined && ( - - )} - {onDelete !== undefined && ( - - )} -
-
-
- ); -} - -const useStyles = tss.withName({ S3ConfigCard }).create(({ theme }) => ({ - root: { - padding: theme.spacing(3), - borderRadius: theme.spacing(2), - backgroundColor: theme.colors.useCases.surfaces.surface1, - boxShadow: theme.shadows[3], - "&:hover": { - boxShadow: theme.shadows[6] - } - }, - line: { - marginBottom: theme.spacing(3), - display: "flex", - alignItems: "center" - }, - helpIcon: { - marginLeft: theme.spacing(2), - fontSize: "inherit", - ...(() => { - const factor = 1.1; - return { width: `${factor}em`, height: `${factor}em` }; - })() - } -})); - -const { i18n } = declareComponentKeys< - | "data source" - | "credentials" - | "sts credentials" - | "account" - | "use in services" - | "use in services helper" - | "use for onyxia explorers" - | "use for onyxia explorers helper" - | "edit" - | "delete" ->()({ S3ConfigCard }); -export type I18n = typeof i18n; diff --git a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx b/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx deleted file mode 100644 index aadb38d93..000000000 --- a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx +++ /dev/null @@ -1,448 +0,0 @@ -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<{ - s3ConfigIdToEdit: string | undefined; - }>; -}; - -export const AddCustomS3ConfigDialog = memo((props: AddCustomS3ConfigDialogProps) => { - const { evtOpen } = props; - - const { t } = useTranslation({ AddCustomS3ConfigDialog }); - - const { - functions: { s3ConfigCreation } - } = getCoreSync(); - - const { isReady } = useCoreState("s3ConfigCreation", "main"); - - useEvt( - ctx => - evtOpen.attach(ctx, ({ s3ConfigIdToEdit }) => - s3ConfigCreation.initialize({ s3ConfigIdToEdit }) - ), - [evtOpen] - ); - - const onCloseFactory = useCallbackFactory(([isSubmit]: [boolean]) => { - if (isSubmit) { - s3ConfigCreation.submit(); - } else { - s3ConfigCreation.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, - connectionTestStatus, - isFormSubmittable, - isEditionOfAnExistingConfig - } = useCoreState("s3ConfigCreation", "main"); - - const { - functions: { s3ConfigCreation } - } = 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( - "s3ConfigCreation", - "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 - }) - } - /> - - s3ConfigCreation.changeValue({ - key: "workingDirectoryPath", - 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/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog.stories.tsx b/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog.stories.tsx deleted file mode 100644 index 28ab4fe67..000000000 --- a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog.stories.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { ConfirmCustomS3ConfigDeletionDialog } from "./ConfirmCustomS3ConfigDeletionDialog"; -import { Evt } from "evt"; -import { action } from "@storybook/addon-actions"; -import { Button } from "onyxia-ui/Button"; - -const meta = { - title: "Pages/ProjectSettings/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog", - component: ConfirmCustomS3ConfigDeletionDialog -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - render: () => { - const evtOpen = Evt.create<{ - resolveDoProceed: (doProceed: boolean) => void; - }>(); - - const openDialog = () => { - evtOpen.post({ - resolveDoProceed: doProceed => { - action(`User decision ${doProceed ? "proceed" : "cancel"}`)(); - } - }); - }; - - return ( - <> - - - - ); - } -}; diff --git a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog.tsx b/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog.tsx deleted file mode 100644 index b3295584b..000000000 --- a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/S3ConfigDialogs.tsx b/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/S3ConfigDialogs.tsx deleted file mode 100644 index 6580ca930..000000000 --- a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/S3ConfigDialogs.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/index.ts b/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/index.ts deleted file mode 100644 index ca3ec4149..000000000 --- a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/S3ConfigDialogs/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./S3ConfigDialogs"; diff --git a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/TestS3ConnectionButton.stories.tsx b/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/TestS3ConnectionButton.stories.tsx deleted file mode 100644 index 950e7fcec..000000000 --- a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/TestS3ConnectionButton.stories.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { TestS3ConnectionButton } from "./TestS3ConnectionButton"; -import { action } from "@storybook/addon-actions"; - -const meta = { - title: "Pages/ProjectSettings/S3ConfigTab/TestS3ConnectionButton", - component: TestS3ConnectionButton -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - connectionTestStatus: { - status: "not tested" - }, - onTestConnection: action("onTestConnection") - } -}; - -export const Testing: Story = { - args: { - connectionTestStatus: { - status: "test ongoing" - }, - onTestConnection: action("onTestConnection") - } -}; - -export const Success: Story = { - args: { - connectionTestStatus: { - status: "test succeeded" - }, - onTestConnection: action("onTestConnection") - } -}; - -export const Failed: Story = { - args: { - connectionTestStatus: { - status: "test failed", - errorMessage: "Connection failed due to invalid credentials" - }, - onTestConnection: action("onTestConnection") - } -}; diff --git a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/TestS3ConnectionButton.tsx b/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/TestS3ConnectionButton.tsx deleted file mode 100644 index d06d8c9e4..000000000 --- a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/TestS3ConnectionButton.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { Button } from "onyxia-ui/Button"; -import type { S3Config } from "core/usecases/s3ConfigManagement"; -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; - connectionTestStatus: S3Config.FromProject["connectionTestStatus"]; - onTestConnection: (() => void) | undefined; -}; - -export function TestS3ConnectionButton(props: Props) { - const { className, connectionTestStatus, onTestConnection } = props; - - const { cx, classes, css, theme } = useStyles(); - - const { t } = useTranslation({ TestS3ConnectionButton }); - - return ( -
- - {(() => { - if (connectionTestStatus.status === "test ongoing") { - return ; - } - - switch (connectionTestStatus.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/projectSettings/ProjectSettingsS3ConfigTab/index.ts b/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/index.ts deleted file mode 100644 index 16d5167de..000000000 --- a/web/src/ui/pages/projectSettings/ProjectSettingsS3ConfigTab/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./ProjectSettingsS3ConfigTab"; diff --git a/web/src/ui/pages/projectSettings/ProjectSettingsSecurityInfosTab.tsx b/web/src/ui/pages/projectSettings/ProjectSettingsSecurityInfosTab.tsx deleted file mode 100644 index 33dcea77d..000000000 --- a/web/src/ui/pages/projectSettings/ProjectSettingsSecurityInfosTab.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { memo } from "react"; -import { SettingField } from "ui/shared/SettingField"; -import { useCoreState, getCoreSync } from "core"; -import { copyToClipboard } from "ui/tools/copyToClipboard"; - -export type Props = { - className?: string; -}; - -export const ProjectSettingsSecurityInfosTab = memo((props: Props) => { - const { className } = props; - - const { - functions: { projectManagement } - } = getCoreSync(); - - const servicePassword = useCoreState("projectManagement", "servicePassword"); - const groupProjectName = useCoreState("projectManagement", "groupProjectName"); - - return ( -
- copyToClipboard(servicePassword)} - onRequestServicePasswordRenewal={() => - projectManagement.renewServicePassword() - } - /> -
- ); -}); diff --git a/web/src/ui/pages/projectSettings/index.ts b/web/src/ui/pages/projectSettings/index.ts deleted file mode 100644 index 9cf4bc637..000000000 --- a/web/src/ui/pages/projectSettings/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { lazy, memo } from "react"; -export * from "./route"; -export const LazyComponent = memo(lazy(() => import("./Page"))); diff --git a/web/src/ui/pages/projectSettings/route.ts b/web/src/ui/pages/projectSettings/route.ts deleted file mode 100644 index e68556f3d..000000000 --- a/web/src/ui/pages/projectSettings/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineRoute, param, noMatch, createGroup } from "type-route"; -import type { ValueSerializer } from "type-route"; -import { id } from "tsafe/id"; -import { tabIds, type TabId } from "./tabIds"; - -export const routeDefs = { - projectSettings: defineRoute( - { - tabId: param.path.optional - .ofType( - id>({ - parse: raw => - !id(tabIds).includes(raw) - ? noMatch - : (raw as TabId), - stringify: value => value - }) - ) - .default(tabIds[0]) - }, - ({ tabId }) => `/project-settings/${tabId}` - ) -}; - -export const routeGroup = createGroup(routeDefs); diff --git a/web/src/ui/pages/projectSettings/tabIds.ts b/web/src/ui/pages/projectSettings/tabIds.ts deleted file mode 100644 index c6b272f9d..000000000 --- a/web/src/ui/pages/projectSettings/tabIds.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const tabIds = ["s3-configs", "security-info"] as const; - -export type TabId = (typeof tabIds)[number]; diff --git a/web/src/ui/pages/s3Explorer/Explorer.tsx b/web/src/ui/pages/s3Explorer/Explorer.tsx index 9e2a43803..3558d1e06 100644 --- a/web/src/ui/pages/s3Explorer/Explorer.tsx +++ b/web/src/ui/pages/s3Explorer/Explorer.tsx @@ -5,7 +5,7 @@ import { useCoreState, getCoreSync } from "core"; import { Explorer as HeadlessExplorer, type ExplorerProps as HeadlessExplorerProps -} from "../fileExplorer/Explorer"; +} from "./headless/Explorer"; import { routes } from "ui/routes"; import { Evt } from "evt"; import type { Param0 } from "tsafe"; @@ -85,7 +85,6 @@ function Explorer_inner(props: Props) { isNavigationOngoing, uploadProgress, currentWorkingDirectoryView, - pathMinDepth, viewMode, shareView, isDownloadPreparing @@ -287,7 +286,6 @@ function Explorer_inner(props: Props) { onRefresh={onRefresh} onDeleteItems={onDeleteItems} onCopyPath={onCopyPath} - pathMinDepth={pathMinDepth} onOpenFile={onOpenFile} viewMode={viewMode} onViewModeChange={fileExplorer.changeViewMode} diff --git a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx b/web/src/ui/pages/s3Explorer/headless/Explorer/Explorer.tsx similarity index 99% rename from web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx rename to web/src/ui/pages/s3Explorer/headless/Explorer/Explorer.tsx index 1009bdf92..06feb5cf8 100644 --- a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx +++ b/web/src/ui/pages/s3Explorer/headless/Explorer/Explorer.tsx @@ -76,7 +76,6 @@ export type ExplorerProps = { onDeleteItems: (params: { items: Item[] }) => void; onCreateNewEmptyDirectory: (params: { basename: string }) => void; onCopyPath: (params: { path: string }) => void; - pathMinDepth: number; onOpenFile: (params: { basename: string }) => void; shareView: ShareView | undefined; onShareFileOpen: (params: { fileBasename: string }) => void; @@ -143,7 +142,6 @@ export const Explorer = memo((props: ExplorerProps) => { onOpenFile, onRequestFilesUpload, filesBeingUploaded, - pathMinDepth, onViewModeChange, viewMode, isBucketPolicyFeatureEnabled, @@ -412,7 +410,6 @@ export const Explorer = memo((props: ExplorerProps) => {
Date: Thu, 29 Jan 2026 06:47:48 +0100 Subject: [PATCH 59/59] Effort to cut the legacy s3Config logic and completly move to s3Profile --- .../core/ports/OnyxiaApi/DeploymentRegion.ts | 152 ++++-------- .../selectors.ts | 57 +---- .../s3ProfilesCreationUiController/thunks.ts | 18 +- .../decoupledLogic/s3Profiles.ts | 48 +--- ...DefaultS3ProfilesAfterPotentialDeletion.ts | 87 ------- .../decoupledLogic/userConfigsS3Bookmarks.ts | 8 +- .../_s3Next/s3ProfilesManagement/selectors.ts | 45 ++-- .../_s3Next/s3ProfilesManagement/state.ts | 10 + .../_s3Next/s3ProfilesManagement/thunks.ts | 224 ++++++------------ .../decoupledLogic/ProjectConfigs.ts | 59 ++--- .../decoupledLogic/clearProjectConfigs.ts | 4 +- .../core/usecases/projectManagement/thunks.ts | 80 +------ 12 files changed, 211 insertions(+), 581 deletions(-) delete mode 100644 web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts diff --git a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts index b3d4e6d27..7386eaa6d 100644 --- a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts +++ b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts @@ -19,21 +19,10 @@ export type DeploymentRegion = { } | undefined; initScriptUrl: string; - s3Configs: DeploymentRegion.S3Config[]; - s3ConfigCreationFormDefaults: - | (Pick & { - workingDirectory: DeploymentRegion.S3Config["workingDirectory"] | undefined; - }) + s3Profiles: DeploymentRegion.S3Profile[]; + s3Profiles_defaultValuesOfCreationForm: + | Pick | undefined; - _s3Next: { - s3Profiles: DeploymentRegion.S3Next.S3Profile[]; - s3Profiles_defaultValuesOfCreationForm: - | Pick< - DeploymentRegion.S3Next.S3Profile, - "url" | "pathStyleAccess" | "region" - > - | undefined; - }; allowedURIPatternForUserDefinedInitScript: string; kafka: @@ -115,114 +104,55 @@ export type DeploymentRegion = { | undefined; }; export namespace DeploymentRegion { - /** https://github.com/InseeFrLab/onyxia-api/blob/main/docs/region-configuration.md#s3 */ - export type S3Config = { + export type S3Profile = { + profileName: string | undefined; url: string; pathStyleAccess: boolean; region: string | undefined; sts: { url: string | undefined; durationSeconds: number | undefined; - role: - | { - roleARN: string; - roleSessionName: string; - } - | undefined; + roles: S3Profile.StsRole[]; oidcParams: OidcParams_Partial; }; - workingDirectory: + bookmarks: S3Profile.Bookmark[]; + }; + + export namespace S3Profile { + export type StsRole = { + roleARN: string; + roleSessionName: string; + profileName: string; + } & ( | { - bucketMode: "shared"; - bucketName: string; - prefix: string; - prefixGroup: string; + claimName: undefined; + includedClaimPattern?: never; + excludedClaimPattern?: never; } | { - bucketMode: "multi"; - bucketNamePrefix: string; - bucketNamePrefixGroup: string; - }; - bookmarkedDirectories: S3Config.BookmarkedDirectory[]; - }; - - export namespace S3Config { - export type BookmarkedDirectory = - | BookmarkedDirectory.Static - | BookmarkedDirectory.Dynamic; - - export namespace BookmarkedDirectory { - export type Common = { - fullPath: string; - title: LocalizedString; - description: LocalizedString | undefined; - tags: LocalizedString[] | undefined; - }; - - export type Static = Common & { - claimName: undefined; - }; - - export type Dynamic = Common & { - claimName: string; - includedClaimPattern: string | undefined; - excludedClaimPattern: string | undefined; - }; - } - } - - export namespace S3Next { - /** https://github.com/InseeFrLab/onyxia-api/blob/main/docs/region-configuration.md#s3 */ - export type S3Profile = { - profileName: string | undefined; - url: string; - pathStyleAccess: boolean; - region: string | undefined; - sts: { - url: string | undefined; - durationSeconds: number | undefined; - roles: S3Profile.StsRole[]; - oidcParams: OidcParams_Partial; - }; - bookmarks: S3Profile.Bookmark[]; - }; - - export namespace S3Profile { - export type StsRole = { - roleARN: string; - roleSessionName: string; - profileName: string; - } & ( - | { - claimName: undefined; - includedClaimPattern?: never; - excludedClaimPattern?: never; - } - | { - claimName: string; - includedClaimPattern: string | undefined; - excludedClaimPattern: string | undefined; - } - ); + claimName: string; + includedClaimPattern: string | undefined; + excludedClaimPattern: string | undefined; + } + ); - export type Bookmark = { - s3UriPrefix: string; - title: LocalizedString; - description: LocalizedString | undefined; - tags: LocalizedString[]; - forProfileNames: 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[]; + forProfileNames: string[]; + } & ( + | { + claimName: undefined; + includedClaimPattern?: never; + excludedClaimPattern?: never; + } + | { + claimName: string; + includedClaimPattern: string | undefined; + excludedClaimPattern: string | undefined; + } + ); } } diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts index 56e6aa880..14f0ded95 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts @@ -5,7 +5,6 @@ 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 s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; import * as projectManagement from "core/usecases/projectManagement"; @@ -172,7 +171,7 @@ const formattedFormValuesUrl = createSelector( } ); -const submittableFormValuesAsProjectS3Config = createSelector( +const submittableFormValuesAsS3Profile_vault = createSelector( isReady, formValues, formattedFormValuesUrl, @@ -206,30 +205,30 @@ const submittableFormValuesAsProjectS3Config = createSelector( assert(formattedFormValuesUrl !== undefined); - const projectS3Config_current = (() => { + const s3Profile_vault_current = (() => { if (creationTimeOfProfileToEdit === undefined) { return undefined; } - const projectS3Config_current = projectConfig.s3.s3Configs.find( + const s3Profile_vault_current = projectConfig.s3Profiles.find( s3Config => s3Config.creationTime === creationTimeOfProfileToEdit ); - assert(projectS3Config_current !== undefined); + assert(s3Profile_vault_current !== undefined); - return projectS3Config_current; + return s3Profile_vault_current; })(); return id< - Omit & { + Omit & { creationTime: number | undefined; } >({ creationTime: - projectS3Config_current === undefined + s3Profile_vault_current === undefined ? undefined - : projectS3Config_current.creationTime, - friendlyName: formValues.profileName.trim(), + : s3Profile_vault_current.creationTime, + profileName: formValues.profileName.trim(), url: formattedFormValuesUrl, region: formValues.region?.trim(), pathStyleAccess: formValues.pathStyleAccess, @@ -247,39 +246,10 @@ const submittableFormValuesAsProjectS3Config = createSelector( sessionToken: formValues.sessionToken }; })(), - // TODO: Delete once we move on - workingDirectoryPath: - projectS3Config_current === undefined - ? "mybucket/my/prefix/" - : projectS3Config_current.workingDirectoryPath, bookmarks: - projectS3Config_current === undefined + s3Profile_vault_current === undefined ? [] - : projectS3Config_current.bookmarks - }); - } -); - -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 + : s3Profile_vault_current.bookmarks }); } ); @@ -360,9 +330,8 @@ const main = createSelector( export const privateSelectors = { formattedFormValuesUrl, - submittableFormValuesAsProjectS3Config, - formValuesErrors, - paramsOfCreateS3Client + submittableFormValuesAsS3Profile_vault, + formValuesErrors }; export const selectors = { main }; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts index 796842440..966c0103f 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts @@ -73,9 +73,7 @@ export const thunks = { } const { s3Profiles_defaultValuesOfCreationForm } = - deploymentRegionManagement.selectors.currentDeploymentRegion( - getState() - )._s3Next; + deploymentRegionManagement.selectors.currentDeploymentRegion(getState()); if (s3Profiles_defaultValuesOfCreationForm === undefined) { dispatch( @@ -126,17 +124,17 @@ export const thunks = { async (...args) => { const [dispatch, getState] = args; - const s3Config_vault = - privateSelectors.submittableFormValuesAsProjectS3Config(getState()); + const s3Profile_vault = + privateSelectors.submittableFormValuesAsS3Profile_vault(getState()); - assert(s3Config_vault !== null); - assert(s3Config_vault !== undefined); + assert(s3Profile_vault !== null); + assert(s3Profile_vault !== undefined); await dispatch( s3ProfilesManagement.protectedThunks.createOrUpdateS3Profile({ - s3Config_vault: { - ...s3Config_vault, - creationTime: s3Config_vault.creationTime ?? Date.now() + s3Profile_vault: { + ...s3Profile_vault, + creationTime: s3Profile_vault.creationTime ?? Date.now() } }) ); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts index fd773d289..360777778 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -1,7 +1,7 @@ import * as projectManagement from "core/usecases/projectManagement"; import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; -import { assert, type Equals } from "tsafe"; +import { assert } from "tsafe"; import type { LocalizedString } from "core/ports/OnyxiaApi"; import type { ResolvedTemplateBookmark } from "./resolveTemplatedBookmark"; import type { ResolvedTemplateStsRole } from "./resolveTemplatedStsRole"; @@ -13,8 +13,6 @@ export type S3Profile = S3Profile.DefinedInRegion | S3Profile.CreatedByUser; export namespace S3Profile { type Common = { profileName: string; - isXOnyxiaDefault: boolean; - isExplorerConfig: boolean; bookmarks: Bookmark[]; }; @@ -38,11 +36,11 @@ export namespace S3Profile { export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { fromVault: { - projectConfigs_s3: projectManagement.ProjectConfigs["s3"]; + s3Profiles: projectManagement.ProjectConfigs.S3Profile[]; userConfigs_s3BookmarksStr: string | null; }; fromRegion: { - s3Profiles: DeploymentRegion.S3Next.S3Profile[]; + s3Profiles: DeploymentRegion.S3Profile[]; // NOTE: The resolvedXXX can be undefined only when the function is used to // the stablish the default profiles (for explorer and services) resolvedTemplatedBookmarks: @@ -62,7 +60,7 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { const { fromVault, fromRegion } = params; const s3Profiles: S3Profile[] = [ - ...fromVault.projectConfigs_s3.s3Configs + ...fromVault.s3Profiles .map((c): S3Profile.CreatedByUser => { const url = c.url; const pathStyleAccess = c.pathStyleAccess; @@ -78,11 +76,9 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { return { origin: "created by user (or group project member)", - profileName: c.friendlyName, + profileName: c.profileName, creationTime: c.creationTime, paramsOfCreateS3Client, - isXOnyxiaDefault: false, - isExplorerConfig: false, bookmarks: (c.bookmarks ?? []).map( ({ displayName, s3UriPrefixObj }) => ({ displayName, @@ -191,9 +187,7 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { s3UriPrefixObj: entry.s3UriPrefixObj })) ], - paramsOfCreateS3Client, - isXOnyxiaDefault: false, - isExplorerConfig: false + paramsOfCreateS3Client }; }; @@ -249,35 +243,5 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { } } - ( - [ - ["defaultXOnyxia", fromVault.projectConfigs_s3.s3ConfigId_defaultXOnyxia], - ["explorer", fromVault.projectConfigs_s3.s3ConfigId_explorer] - ] as const - ).forEach(([prop, profileName]) => { - if (profileName === undefined) { - return; - } - - const s3Profile = - s3Profiles.find(s3Profile => s3Profile.profileName === profileName) ?? - 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 deleted file mode 100644 index 5e4d04325..000000000 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts +++ /dev/null @@ -1,87 +0,0 @@ -import * as projectManagement from "core/usecases/projectManagement"; -import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; -import { aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet } from "./s3Profiles"; - -type R = Record< - "profileName_defaultXOnyxia" | "profileName_explorer", - | { - isUpdateNeeded: false; - } - | { - isUpdateNeeded: true; - profileName: string | undefined; - } ->; - -export function updateDefaultS3ProfilesAfterPotentialDeletion(params: { - fromRegion: { s3Profiles: DeploymentRegion.S3Next.S3Profile[] }; - fromVault: { - projectConfigs_s3: projectManagement.ProjectConfigs["s3"]; - }; -}): R { - const { fromRegion, fromVault } = params; - - const s3Profiles = aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ - fromRegion: { - s3Profiles: fromRegion.s3Profiles.map(s3Profile => { - return { - ...s3Profile, - profileName: s3Profile.profileName ?? "" - }; - }), - resolvedTemplatedBookmarks: undefined, - resolvedTemplatedStsRoles: undefined - }, - fromVault: { - projectConfigs_s3: fromVault.projectConfigs_s3, - userConfigs_s3BookmarksStr: null - } - }); - - const actions: R = { - profileName_defaultXOnyxia: { - isUpdateNeeded: false - }, - profileName_explorer: { - isUpdateNeeded: false - } - }; - - for (const propertyName of [ - "s3ConfigId_defaultXOnyxia", // TODO: Rename - "s3ConfigId_explorer" // TODO: Rename - ] as const) { - const profileName_default = fromVault.projectConfigs_s3[propertyName]; - - if (profileName_default === undefined) { - continue; - } - - if ( - s3Profiles.find(({ profileName }) => profileName === profileName_default) !== - undefined - ) { - continue; - } - - const profileName_newDefault = s3Profiles.find( - ({ origin }) => origin === "defined in region" - )?.profileName; - - actions[ - (() => { - switch (propertyName) { - case "s3ConfigId_defaultXOnyxia": - return "profileName_defaultXOnyxia"; - case "s3ConfigId_explorer": - return "profileName_explorer"; - } - })() - ] = { - isUpdateNeeded: true, - profileName: profileName_newDefault - }; - } - - return actions; -} diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts index 7a5c9495d..8e91b2aa2 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts @@ -1,10 +1,11 @@ import type { S3UriPrefixObj } from "core/tools/S3Uri"; import { z } from "zod"; import { assert, type Equals, id, is } from "tsafe"; +import type { OptionalIfCanBeUndefined } from "core/tools/OptionalIfCanBeUndefined"; export type UserProfileS3Bookmark = { profileName: string; - displayName: string | null; + displayName: string | undefined; s3UriPrefixObj: S3UriPrefixObj; }; @@ -28,14 +29,15 @@ const zUserProfileS3Bookmark = (() => { const zTargetType = z.object({ profileName: z.string(), - displayName: z.union([z.string(), z.null()]), + displayName: z.union([z.string(), z.undefined()]), s3UriPrefixObj: zS3UriPrefixObj }); type InferredType = z.infer; - assert>; + assert>>; + // @ts-expect-error return id>(zTargetType); })(); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts index 591e5c434..c9a1645b9 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts @@ -10,13 +10,15 @@ import { name } from "./state"; import type { State as RootState } from "core/bootstrap"; import * as userAuthentication from "core/usecases/userAuthentication"; +const state = (rootState: RootState) => rootState[name]; + const resolvedTemplatedBookmarks = createSelector( - (state: RootState) => state[name], + state, state => state.resolvedTemplatedBookmarks ); const resolvedTemplatedStsRoles = createSelector( - (state: RootState) => state[name], + state, state => state.resolvedTemplatedStsRoles ); @@ -28,17 +30,17 @@ const userConfigs_s3BookmarksStr = createSelector( const s3Profiles = createSelector( createSelector( projectManagement.protectedSelectors.projectConfig, - projectConfig => projectConfig.s3 + projectConfig => projectConfig.s3Profiles ), createSelector( deploymentRegionManagement.selectors.currentDeploymentRegion, - deploymentRegion => deploymentRegion._s3Next.s3Profiles + deploymentRegion => deploymentRegion.s3Profiles ), resolvedTemplatedBookmarks, resolvedTemplatedStsRoles, userConfigs_s3BookmarksStr, ( - projectConfigs_s3, + s3Profiles_vault, s3Profiles_region, resolvedTemplatedBookmarks, resolvedTemplatedStsRoles, @@ -46,7 +48,7 @@ const s3Profiles = createSelector( ): S3Profile[] => aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ fromVault: { - projectConfigs_s3, + s3Profiles: s3Profiles_vault, userConfigs_s3BookmarksStr }, fromRegion: { @@ -62,20 +64,35 @@ const isS3ExplorerEnabled = (rootState: RootState) => { const { isUserLoggedIn } = userAuthentication.selectors.main(rootState); if (!isUserLoggedIn) { - const { s3Configs } = - deploymentRegionManagement.selectors.currentDeploymentRegion(rootState); - - return s3Configs.length !== 0; - } else { return ( - s3Profiles(rootState).find(s3Profile => s3Profile.isExplorerConfig) !== - undefined + deploymentRegionManagement.selectors.currentDeploymentRegion(rootState) + .s3Profiles.length !== 0 ); } + + return s3Profiles(rootState).length !== 0; }; +const ambientS3Profile = createSelector( + s3Profiles, + createSelector(state, state => state.ambientProfileName), + (s3Profiles, ambientProfileName) => { + return ( + s3Profiles.find( + ambientProfileName === undefined + ? () => false + : s3Profiles => s3Profiles.profileName === ambientProfileName + ) ?? + s3Profiles.find(s3Profile => s3Profile.profileName === "default") ?? + s3Profiles.find(s3Profile => s3Profile.origin === "defined in region") ?? + s3Profiles.find(() => true) + ); + } +); + export const selectors = { s3Profiles, isS3ExplorerEnabled }; export const protectedSelectors = { - resolvedTemplatedBookmarks + resolvedTemplatedBookmarks, + ambientS3Profile }; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts index ad4f77ebd..26999f9f0 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts @@ -6,6 +6,7 @@ import type { ResolvedTemplateBookmark } from "./decoupledLogic/resolveTemplated import type { ResolvedTemplateStsRole } from "./decoupledLogic/resolveTemplatedStsRole"; type State = { + ambientProfileName: string | undefined; resolvedTemplatedBookmarks: { correspondingS3ConfigIndexInRegion: number; bookmarks: ResolvedTemplateBookmark[]; @@ -36,11 +37,20 @@ export const { reducer, actions } = createUsecaseActions({ const { resolvedTemplatedBookmarks, resolvedTemplatedStsRoles } = payload; const state: State = { + ambientProfileName: undefined, resolvedTemplatedBookmarks, resolvedTemplatedStsRoles }; return state; + }, + ambientProfileChanged: ( + state, + { payload }: { payload: { profileName: string } } + ) => { + const { profileName } = payload; + + state.ambientProfileName = profileName; } } }); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts index a3b48f5c3..b84505a6e 100644 --- a/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts @@ -3,7 +3,6 @@ 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 { updateDefaultS3ProfilesAfterPotentialDeletion } from "./decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion"; import structuredClone from "@ungap/structured-clone"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; @@ -19,71 +18,7 @@ import { serializeUserConfigsS3Bookmarks } from "./decoupledLogic/userConfigsS3Bookmarks"; import * as userConfigs from "core/usecases/userConfigs"; - -export const thunks = { - deleteS3Profile: - (params: { profileName: string }) => - async (...args) => { - const { profileName } = params; - - const [dispatch, getState] = args; - - const projectConfigs_s3 = structuredClone( - projectManagement.protectedSelectors.projectConfig(getState()).s3 - ); - - const i = projectConfigs_s3.s3Configs.findIndex( - s3Profile => s3Profile.friendlyName === profileName - ); - - assert(i !== -1); - - projectConfigs_s3.s3Configs.splice(i, 1); - - { - const actions = updateDefaultS3ProfilesAfterPotentialDeletion({ - fromRegion: { - s3Profiles: - deploymentRegionManagement.selectors.currentDeploymentRegion( - getState() - )._s3Next.s3Profiles - }, - fromVault: { - projectConfigs_s3 - } - }); - - for (const propertyName of [ - "profileName_defaultXOnyxia", - "profileName_explorer" - ] as const) { - const action = actions[propertyName]; - - if (!action.isUpdateNeeded) { - continue; - } - - projectConfigs_s3[ - (() => { - switch (propertyName) { - case "profileName_defaultXOnyxia": - return "s3ConfigId_defaultXOnyxia"; - case "profileName_explorer": - return "s3ConfigId_explorer"; - } - })() - ] = action.profileName; - } - } - - await dispatch( - projectManagement.protectedThunks.updateConfigValue({ - key: "s3", - value: projectConfigs_s3 - }) - ); - } -} satisfies Thunks; +import { removeDuplicates } from "evt/tools/reducers/removeDuplicates"; const globalContext = { prS3ClientByProfileName: new Map>() @@ -203,16 +138,14 @@ export const protectedThunks = { return prS3Client; }, - getS3ProfileAndClientForExplorer: + getAmbientS3ProfileAndClient: () => async ( ...args ): Promise => { const [dispatch, getState] = args; - const s3Profile = selectors - .s3Profiles(getState()) - .find(s3Profile => s3Profile.isExplorerConfig); + const s3Profile = protectedSelectors.ambientS3Profile(getState()); if (s3Profile === undefined) { return undefined; @@ -227,52 +160,64 @@ export const protectedThunks = { return { s3Client, s3Profile }; }, createOrUpdateS3Profile: - (params: { s3Config_vault: projectManagement.ProjectConfigs.S3Config }) => + (params: { s3Profile_vault: projectManagement.ProjectConfigs.S3Profile }) => async (...args) => { - const { s3Config_vault } = params; + const { s3Profile_vault } = params; const [dispatch, getState] = args; - const fromVault = structuredClone( - projectManagement.protectedSelectors.projectConfig(getState()).s3 + const s3Profiles_vault = structuredClone( + projectManagement.protectedSelectors.projectConfig(getState()).s3Profiles ); - const i = fromVault.s3Configs.findIndex( - projectS3Config_i => - projectS3Config_i.creationTime === s3Config_vault.creationTime + const i = s3Profiles_vault.findIndex( + s3Profile_vault_i => + s3Profile_vault_i.creationTime === s3Profile_vault.creationTime ); - update_default_selected: { - if (i === -1) { - break update_default_selected; - } + if (i === -1) { + s3Profiles_vault.push(s3Profile_vault); + } else { + s3Profiles_vault[i] = s3Profile_vault; + } - const s3Config_vault_current = fromVault.s3Configs[i]; + assert( + s3Profiles_vault + .map(s3Profile => s3Profile.profileName) + .reduce(...removeDuplicates()).length === + s3Profiles_vault.length + ); - if (s3Config_vault.friendlyName === s3Config_vault.friendlyName) { - break update_default_selected; - } + await dispatch( + projectManagement.protectedThunks.updateConfigValue({ + key: "s3Profiles", + value: s3Profiles_vault + }) + ); + }, + deleteS3Profile: + (params: { profileName: string }) => + async (...args) => { + const { profileName } = params; - for (const propertyName of [ - "s3ConfigId_defaultXOnyxia", - "s3ConfigId_explorer" - ] as const) { - if (fromVault[propertyName] === s3Config_vault_current.friendlyName) { - fromVault[propertyName] = s3Config_vault.friendlyName; - } - } - } + const [dispatch, getState] = args; - if (i < 0) { - fromVault.s3Configs.push(s3Config_vault); - } else { - fromVault.s3Configs[i] = s3Config_vault; - } + const s3Profiles_vault = structuredClone( + projectManagement.protectedSelectors.projectConfig(getState()).s3Profiles + ); + + const i = s3Profiles_vault.findIndex( + s3Profile => s3Profile.profileName === profileName + ); + + assert(i !== -1); + + s3Profiles_vault.splice(i, 1); await dispatch( projectManagement.protectedThunks.updateConfigValue({ - key: "s3", - value: fromVault + key: "s3Profiles", + value: s3Profiles_vault }) ); }, @@ -305,20 +250,20 @@ export const protectedThunks = { switch (s3Profile.origin) { case "created by user (or group project member)": { - const projectConfigs_s3 = structuredClone( + const s3Profiles_vault = structuredClone( projectManagement.protectedSelectors.projectConfig(getState()) - .s3 + .s3Profiles ); - const s3Config_vault = projectConfigs_s3.s3Configs.find( - s3Config => s3Config.creationTime === s3Profile.creationTime + const s3Profile_vault = s3Profiles_vault.find( + s3Profile => s3Profile.creationTime === s3Profile.creationTime ); - assert(s3Config_vault !== undefined); + assert(s3Profile_vault !== undefined); - s3Config_vault.bookmarks ??= []; + s3Profile_vault.bookmarks ??= []; - const index = s3Config_vault.bookmarks.findIndex(bookmark => + const index = s3Profile_vault.bookmarks.findIndex(bookmark => same(bookmark.s3UriPrefixObj, s3UriPrefixObj) ); @@ -331,9 +276,9 @@ export const protectedThunks = { }; if (index === -1) { - s3Config_vault.bookmarks.push(bookmark_new); + s3Profile_vault.bookmarks.push(bookmark_new); } else { - s3Config_vault.bookmarks[index] = bookmark_new; + s3Profile_vault.bookmarks[index] = bookmark_new; } } break; @@ -341,15 +286,15 @@ export const protectedThunks = { { assert(index !== -1); - s3Config_vault.bookmarks.splice(index, 1); + s3Profile_vault.bookmarks.splice(index, 1); } break; } await dispatch( projectManagement.protectedThunks.updateConfigValue({ - key: "s3", - value: projectConfigs_s3 + key: "s3Profiles", + value: s3Profiles_vault }) ); } @@ -374,7 +319,7 @@ export const protectedThunks = { { const bookmark_new = { profileName: s3Profile.profileName, - displayName: action.displayName ?? null, + displayName: action.displayName, s3UriPrefixObj }; @@ -406,52 +351,21 @@ export const protectedThunks = { break; } }, - changeIsDefault: - (params: { - profileName: string; - usecase: "defaultXOnyxia" | "explorer"; - value: boolean; - }) => - async (...args) => { - const { profileName, usecase, value } = params; + changeAmbientProfile: + (params: { profileName: string }) => + (...args) => { + const { profileName } = 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 === profileName) { - return; - } - } else { - if (s3ProfileId_currentDefault !== profileName) { - return; - } - } - } - - fromVault[propertyName] = value ? profileName : undefined; + const s3Profiles = selectors.s3Profiles(getState()); - await dispatch( - projectManagement.protectedThunks.updateConfigValue({ - key: "s3", - value: fromVault - }) + assert( + s3Profiles.find(s3Profile => s3Profile.profileName === profileName) !== + undefined ); + + dispatch(actions.ambientProfileChanged({ profileName })); }, initialize: () => diff --git a/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts b/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts index 9d19877b6..ef5429185 100644 --- a/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts +++ b/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts @@ -8,27 +8,19 @@ import { zStringifyableAtomic } from "core/tools/Stringifyable"; import type { S3UriPrefixObj } from "core/tools/S3Uri"; export type ProjectConfigs = { - __modelVersion: 1; + __modelVersion: 2; servicePassword: string; - restorableConfigs: ProjectConfigs.RestorableServiceConfig[]; - s3: { - s3Configs: ProjectConfigs.S3Config[]; - // TODO: Rename to profileName_defaultXOnyxia - s3ConfigId_defaultXOnyxia: string | undefined; - // TODO: Rename to profileName_explorer - s3ConfigId_explorer: string | undefined; - }; + restorableServiceConfigs: ProjectConfigs.RestorableServiceConfig[]; + s3Profiles: ProjectConfigs.S3Profile[]; clusterNotificationCheckoutTime: number; }; export namespace ProjectConfigs { - export type S3Config = { + export type S3Profile = { + profileName: string; creationTime: number; - // TODO: Rename this to profileName - friendlyName: string; url: string; region: string | undefined; - workingDirectoryPath: string; pathStyleAccess: boolean; credentials: | { @@ -37,10 +29,10 @@ export namespace ProjectConfigs { sessionToken: string | undefined; } | undefined; - bookmarks: S3Config.Bookmark[] | undefined; + bookmarks: S3Profile.Bookmark[] | undefined; }; - export namespace S3Config { + export namespace S3Profile { export type Bookmark = { displayName: string | undefined; s3UriPrefixObj: S3UriPrefixObj; @@ -53,7 +45,7 @@ export namespace ProjectConfigs { catalogId: string; chartName: string; chartVersion: string; - s3ConfigId: string | undefined; + s3ProfileName: string | undefined; helmValuesPatch: { path: (string | number)[]; value: StringifyableAtomic | undefined; @@ -87,7 +79,7 @@ const zRestorableServiceConfig = (() => { catalogId: z.string(), chartName: z.string(), chartVersion: z.string(), - s3ConfigId: z.union([z.string(), z.undefined()]), + s3ProfileName: z.union([z.string(), z.undefined()]), helmValuesPatch: z.array(zHelmValuesPatch) }); @@ -98,7 +90,7 @@ const zRestorableServiceConfig = (() => { })(); const zS3Credentials = (() => { - type TargetType = Exclude; + type TargetType = Exclude; const zTargetType = z.object({ accessKeyId: z.string(), @@ -113,7 +105,7 @@ const zS3Credentials = (() => { })(); const zS3ConfigBookmark = (() => { - type TargetType = ProjectConfigs.S3Config.Bookmark; + type TargetType = ProjectConfigs.S3Profile.Bookmark; const zTargetType = z.object({ displayName: z.union([z.string(), z.undefined()]), @@ -129,12 +121,12 @@ const zS3ConfigBookmark = (() => { return id>(zTargetType); })(); -const zS3Config = (() => { - type TargetType = ProjectConfigs.S3Config; +const zS3Profile = (() => { + type TargetType = ProjectConfigs.S3Profile; const zTargetType = z.object({ creationTime: z.number(), - friendlyName: z.string(), + profileName: z.string(), url: z.string(), region: z.union([z.string(), z.undefined()]), workingDirectoryPath: z.string(), @@ -143,22 +135,7 @@ const zS3Config = (() => { bookmarks: z.union([z.array(zS3ConfigBookmark), z.undefined()]) }); - assert, OptionalIfCanBeUndefined>>(); - - // @ts-expect-error - return id>(zTargetType); -})(); - -const zS3 = (() => { - type TargetType = ProjectConfigs["s3"]; - - const zTargetType = z.object({ - s3Configs: z.array(zS3Config), - s3ConfigId_defaultXOnyxia: z.union([z.string(), z.undefined()]), - s3ConfigId_explorer: z.union([z.string(), z.undefined()]) - }); - - assert, OptionalIfCanBeUndefined>>(); + assert, OptionalIfCanBeUndefined>>; // @ts-expect-error return id>(zTargetType); @@ -168,10 +145,10 @@ export const zProjectConfigs = (() => { type TargetType = ProjectConfigs; const zTargetType = z.object({ - __modelVersion: z.literal(1), + __modelVersion: z.literal(2), servicePassword: z.string(), - restorableConfigs: z.array(zRestorableServiceConfig), - s3: zS3, + restorableServiceConfigs: z.array(zRestorableServiceConfig), + s3Profiles: z.array(zS3Profile), clusterNotificationCheckoutTime: z.number() }); diff --git a/web/src/core/usecases/projectManagement/decoupledLogic/clearProjectConfigs.ts b/web/src/core/usecases/projectManagement/decoupledLogic/clearProjectConfigs.ts index 0c57fbf99..44978c58c 100644 --- a/web/src/core/usecases/projectManagement/decoupledLogic/clearProjectConfigs.ts +++ b/web/src/core/usecases/projectManagement/decoupledLogic/clearProjectConfigs.ts @@ -27,8 +27,8 @@ export async function clearProjectConfigs(params: { [ "__modelVersion", "servicePassword", - "restorableConfigs", - "s3", + "restorableServiceConfigs", + "s3Profiles", "clusterNotificationCheckoutTime" ] as const ).map(async key => { diff --git a/web/src/core/usecases/projectManagement/thunks.ts b/web/src/core/usecases/projectManagement/thunks.ts index 506c061ae..e89583078 100644 --- a/web/src/core/usecases/projectManagement/thunks.ts +++ b/web/src/core/usecases/projectManagement/thunks.ts @@ -7,11 +7,8 @@ import { protectedSelectors } from "./selectors"; import * as userConfigs from "core/usecases/userConfigs"; import { same } from "evt/tools/inDepth"; import { id } from "tsafe/id"; -import { updateDefaultS3ProfilesAfterPotentialDeletion } from "core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion"; -import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import { getProjectVaultTopDirPath_reserved } from "./decoupledLogic/projectVaultTopDirPath_reserved"; import { secretToValue, valueToSecret } from "./decoupledLogic/secretParsing"; -import { symToStr } from "tsafe/symToStr"; import { type ProjectConfigs, zProjectConfigs } from "./decoupledLogic/ProjectConfigs"; import { clearProjectConfigs } from "./decoupledLogic/clearProjectConfigs"; import { Mutex } from "async-mutex"; @@ -21,11 +18,8 @@ export const thunks = { changeProject: (params: { projectId: string }) => async (...args) => { - const [ - dispatch, - getState, - { onyxiaApi, secretsManager, paramsOfBootstrapCore } - ] = args; + const [dispatch, , { onyxiaApi, secretsManager, paramsOfBootstrapCore }] = + args; const { projectId } = params; @@ -125,59 +119,6 @@ export const thunks = { await prOnboarding; - maybe_update_pinned_default_s3_configs: { - const actions = updateDefaultS3ProfilesAfterPotentialDeletion({ - fromRegion: { - s3Profiles: - deploymentRegionManagement.selectors.currentDeploymentRegion( - getState() - )._s3Next.s3Profiles - }, - fromVault: { - projectConfigs_s3: projectConfigs.s3 - } - }); - - let needUpdate = false; - - for (const propertyName of [ - "profileName_defaultXOnyxia", - "profileName_explorer" - ] as const) { - const action = actions[propertyName]; - - if (!action.isUpdateNeeded) { - continue; - } - - needUpdate = true; - - projectConfigs.s3[ - (() => { - switch (propertyName) { - case "profileName_defaultXOnyxia": - return "s3ConfigId_defaultXOnyxia"; - case "profileName_explorer": - return "s3ConfigId_explorer"; - } - })() - ] = action.profileName; - } - - if (!needUpdate) { - break maybe_update_pinned_default_s3_configs; - } - - { - const { s3 } = projectConfigs; - - await secretsManager.put({ - path: pathJoin(projectVaultTopDirPath_reserved, symToStr({ s3 })), - secret: valueToSecret(s3) - }); - } - } - const projectWithInjectedPersonalInfos = projects.map(project => ({ ...project, doInjectPersonalInfos: @@ -216,8 +157,8 @@ export const thunks = { const keys = [ "__modelVersion", "servicePassword", - "restorableConfigs", - "s3", + "restorableServiceConfigs", + "s3Profiles", "clusterNotificationCheckoutTime" ] as const; @@ -227,7 +168,7 @@ function getDefaultConfig(key_: K): ProjectConfi const key = key_ as keyof ProjectConfigs; switch (key) { case "__modelVersion": { - const out: ProjectConfigs[typeof key] = 1; + const out: ProjectConfigs[typeof key] = 2; // @ts-expect-error return out; } @@ -236,18 +177,13 @@ function getDefaultConfig(key_: K): ProjectConfi // @ts-expect-error return out; } - case "restorableConfigs": { + case "restorableServiceConfigs": { const out: ProjectConfigs[typeof key] = []; // @ts-expect-error return out; } - case "s3": { - const out: ProjectConfigs[typeof key] = { - s3Configs: [], - // NOTE: We will set to the correct default at initialization - s3ConfigId_defaultXOnyxia: "a-config-id-that-does-not-exist", - s3ConfigId_explorer: "a-config-id-that-does-not-exist" - }; + case "s3Profiles": { + const out: ProjectConfigs[typeof key] = []; // @ts-expect-error return out; }