From ea440e9d71c35cec0eb115dc7ce11bcd4408bb0f Mon Sep 17 00:00:00 2001 From: garronej Date: Sat, 11 Apr 2026 16:48:49 +0200 Subject: [PATCH 01/14] Implement s3 profile modal (WIP) --- web/src/core/usecases/index.ts | 4 +- .../core/usecases/s3CodeSnippets/selectors.ts | 345 ------------ web/src/core/usecases/s3CodeSnippets/state.ts | 92 ---- .../core/usecases/s3CodeSnippets/thunks.ts | 90 ---- .../usecases/s3ExplorerUiController/evt.ts | 16 + .../usecases/s3ExplorerUiController/thunks.ts | 7 - .../selectors.ts | 137 +---- .../s3ProfilesCreationUiController/state.ts | 69 +-- .../s3ProfilesCreationUiController/thunks.ts | 46 +- .../decoupledLogic/codeSnippets.ts | 504 ++++++++++++++++++ .../index.ts | 0 .../selectors.ts | 101 ++++ .../s3ProfilesDetailsUiController/state.ts | 89 ++++ .../s3ProfilesDetailsUiController/thunks.ts | 93 ++++ web/src/ui/i18n/resources/de.tsx | 11 - web/src/ui/i18n/resources/en.tsx | 12 - web/src/ui/i18n/resources/es.tsx | 13 - web/src/ui/i18n/resources/fi.tsx | 12 - web/src/ui/i18n/resources/fr.tsx | 13 - web/src/ui/i18n/resources/it.tsx | 17 +- web/src/ui/i18n/resources/nl.tsx | 11 - web/src/ui/i18n/resources/no.tsx | 12 - web/src/ui/i18n/resources/zh-CN.tsx | 10 - web/src/ui/i18n/types.ts | 15 - .../ui/pages/account/AccountStorageTab.tsx | 210 -------- web/src/ui/pages/account/Page.tsx | 8 +- web/src/ui/pages/account/accountTabIds.ts | 1 - web/src/ui/pages/s3Explorer/ProfileModal.tsx | 267 ++++++++++ .../S3ProfileDetails/S3ProfileDetails.tsx | 40 ++ .../s3ProfileModal/S3ProfileDetails/index.ts | 1 + .../S3ProfileForm/S3ProfileForm.tsx | 63 +++ .../s3ProfileModal/S3ProfileForm/index.ts | 1 + 32 files changed, 1242 insertions(+), 1068 deletions(-) delete mode 100644 web/src/core/usecases/s3CodeSnippets/selectors.ts delete mode 100644 web/src/core/usecases/s3CodeSnippets/state.ts delete mode 100644 web/src/core/usecases/s3CodeSnippets/thunks.ts create mode 100644 web/src/core/usecases/s3ProfilesDetailsUiController/decoupledLogic/codeSnippets.ts rename web/src/core/usecases/{s3CodeSnippets => s3ProfilesDetailsUiController}/index.ts (100%) create mode 100644 web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts create mode 100644 web/src/core/usecases/s3ProfilesDetailsUiController/state.ts create mode 100644 web/src/core/usecases/s3ProfilesDetailsUiController/thunks.ts delete mode 100644 web/src/ui/pages/account/AccountStorageTab.tsx create mode 100644 web/src/ui/pages/s3Explorer/ProfileModal.tsx create mode 100644 web/src/ui/shared/codex/s3ProfileModal/S3ProfileDetails/S3ProfileDetails.tsx create mode 100644 web/src/ui/shared/codex/s3ProfileModal/S3ProfileDetails/index.ts create mode 100644 web/src/ui/shared/codex/s3ProfileModal/S3ProfileForm/S3ProfileForm.tsx create mode 100644 web/src/ui/shared/codex/s3ProfileModal/S3ProfileForm/index.ts diff --git a/web/src/core/usecases/index.ts b/web/src/core/usecases/index.ts index 6903bcc17..0c6209d24 100644 --- a/web/src/core/usecases/index.ts +++ b/web/src/core/usecases/index.ts @@ -12,7 +12,7 @@ import * as userAuthentication from "./userAuthentication"; import * as userProfileForm from "./userProfileForm"; import * as userConfigs from "./userConfigs"; import * as secretsEditor from "./secretsEditor"; -import * as s3CodeSnippets from "./s3CodeSnippets"; +import * as s3ProfilesDetailsUiController from "./s3ProfilesDetailsUiController"; import * as k8sCodeSnippets from "./k8sCodeSnippets"; import * as vaultCredentials from "./vaultCredentials"; import * as sqlOlapShell from "./sqlOlapShell"; @@ -40,7 +40,7 @@ export const usecases = { userProfileForm, userConfigs, secretsEditor, - s3CodeSnippets, + s3ProfilesDetailsUiController, k8sCodeSnippets, vaultCredentials, sqlOlapShell, diff --git a/web/src/core/usecases/s3CodeSnippets/selectors.ts b/web/src/core/usecases/s3CodeSnippets/selectors.ts deleted file mode 100644 index 072689fe4..000000000 --- a/web/src/core/usecases/s3CodeSnippets/selectors.ts +++ /dev/null @@ -1,345 +0,0 @@ -import type { State as RootState } from "core/bootstrap"; -import { name } from "./state"; -import { createSelector } from "clean-architecture"; -import { assert } from "tsafe/assert"; -import * as s3ProfilesManagement from "core/usecases/s3ProfilesManagement"; - -const state = (rootState: RootState) => rootState[name]; - -const readyState = createSelector(state, state => { - if (state.stateDescription !== "ready") { - return null; - } - - return state; -}); - -const isReady = createSelector(readyState, state => state !== null); - -const selectedTechnology = createSelector(readyState, state => { - if (state === null) { - return null; - } - - return state.selectedTechnology; -}); - -const credentials = createSelector(readyState, state => { - if (state === null) { - return null; - } - - return state.credentials; -}); - -const initScript = createSelector( - isReady, - selectedTechnology, - credentials, - (isReady, selectedTechnology, credentials) => { - if (!isReady) { - return null; - } - - assert(selectedTechnology !== null); - assert(credentials !== null); - - return { - fileBasename: ((): string => { - switch (selectedTechnology) { - case "R (aws.S3)": - case "R (paws)": - return "credentials.R"; - case "Python (s3fs)": - case "Python (boto3)": - case "Python (polars)": - return "credentials.py"; - case "shell environment variables": - case "MC client": - return ".bashrc"; - case "s3cmd": - return ".s3cmd"; - case "rclone": - return "rclone.conf"; - } - })(), - scriptCode: ((): string => { - switch (selectedTechnology) { - case "R (aws.S3)": - return ` -install.packages("aws.s3", repos = "https://cloud.R-project.org") - -Sys.setenv("AWS_ACCESS_KEY_ID" = "${credentials.AWS_ACCESS_KEY_ID}", - "AWS_SECRET_ACCESS_KEY" = "${credentials.AWS_SECRET_ACCESS_KEY}", - "AWS_DEFAULT_REGION" = "${credentials.AWS_DEFAULT_REGION}", - "AWS_SESSION_TOKEN" = "${credentials.AWS_SESSION_TOKEN}", - "AWS_S3_ENDPOINT"= "${credentials.AWS_S3_ENDPOINT}") - -library("aws.s3") -bucketlist(region="") -`; - case "R (paws)": - return ` -install.packages("paws", repos = "https://cloud.R-project.org") - -Sys.setenv("AWS_ACCESS_KEY_ID" = "${credentials.AWS_ACCESS_KEY_ID}", - "AWS_SECRET_ACCESS_KEY" = "${credentials.AWS_SECRET_ACCESS_KEY}", - "AWS_DEFAULT_REGION" = "${credentials.AWS_DEFAULT_REGION}", - "AWS_SESSION_TOKEN" = "${credentials.AWS_SESSION_TOKEN}", - "AWS_S3_ENDPOINT"= "${credentials.AWS_S3_ENDPOINT}") - -library("paws") -minio <- paws::s3(config = list( - credentials = list( - creds = list( - access_key_id = Sys.getenv("AWS_ACCESS_KEY_ID"), - secret_access_key = Sys.getenv("AWS_SECRET_ACCESS_KEY"), - session_token = Sys.getenv("AWS_SESSION_TOKEN") - )), - endpoint = paste0("https://", Sys.getenv("AWS_S3_ENDPOINT")), - region = Sys.getenv("AWS_DEFAULT_REGION"))) - -minio$list_buckets() - `; - case "Python (s3fs)": - return ` -import os -import s3fs -os.environ["AWS_ACCESS_KEY_ID"] = '${credentials.AWS_ACCESS_KEY_ID}' -os.environ["AWS_SECRET_ACCESS_KEY"] = '${credentials.AWS_SECRET_ACCESS_KEY}' -os.environ["AWS_SESSION_TOKEN"] = '${credentials.AWS_SESSION_TOKEN}' -os.environ["AWS_DEFAULT_REGION"] = '${credentials.AWS_DEFAULT_REGION}' -fs = s3fs.S3FileSystem( - client_kwargs={'endpoint_url': 'https://'+'${credentials.AWS_S3_ENDPOINT}'}, - key = os.environ["AWS_ACCESS_KEY_ID"], - secret = os.environ["AWS_SECRET_ACCESS_KEY"], - token = os.environ["AWS_SESSION_TOKEN"]) - `; - case "Python (boto3)": - return ` -import boto3 -s3 = boto3.client("s3",endpoint_url = 'https://'+'${credentials.AWS_S3_ENDPOINT}', - aws_access_key_id= '${credentials.AWS_ACCESS_KEY_ID}', - aws_secret_access_key= '${credentials.AWS_SECRET_ACCESS_KEY}', - aws_session_token = '${credentials.AWS_SESSION_TOKEN}') - `; - case "Python (polars)": - return ` -import polars as pl -storage_options = { - "aws_endpoint": 'https://'+'${credentials.AWS_S3_ENDPOINT}', - "aws_access_key_id": os.environ["AWS_ACCESS_KEY_ID"], - "aws_secret_access_key": os.environ["AWS_SECRET_ACCESS_KEY"], - "aws_region": os.environ["AWS_DEFAULT_REGION"], - "aws_token": os.environ["AWS_SESSION_TOKEN"] - } - # or hard-coded : - storage_options = { - "aws_endpoint": 'https://'+'${credentials.AWS_S3_ENDPOINT}', - "aws_access_key_id": '${credentials.AWS_ACCESS_KEY_ID}', - "aws_secret_access_key": '${credentials.AWS_SECRET_ACCESS_KEY}', - "aws_region": '${credentials.AWS_DEFAULT_REGION}' - "aws_token": '${credentials.AWS_SESSION_TOKEN}' - } - df = pl.scan_parquet(source = "s3://bucket/*.parquet", storage_options=storage_options) - print(df) - - `; - case "shell environment variables": - return ` -export AWS_ACCESS_KEY_ID=${credentials.AWS_ACCESS_KEY_ID} -export AWS_SECRET_ACCESS_KEY=${credentials.AWS_SECRET_ACCESS_KEY} -export AWS_DEFAULT_REGION=${credentials.AWS_DEFAULT_REGION} -export AWS_SESSION_TOKEN=${credentials.AWS_SESSION_TOKEN} -export AWS_S3_ENDPOINT=${credentials.AWS_S3_ENDPOINT} - `; - case "MC client": - return ` -export MC_HOST_s3=https://${credentials.AWS_ACCESS_KEY_ID}:${credentials.AWS_SECRET_ACCESS_KEY}:${credentials.AWS_SESSION_TOKEN}@${credentials.AWS_S3_ENDPOINT} - `; - case "s3cmd": - return ` -[default] -access_key = ${credentials.AWS_ACCESS_KEY_ID} -access_token = ${credentials.AWS_SESSION_TOKEN} -add_encoding_exts = -add_headers = -bucket_location = us-east-1 -ca_certs_file = -cache_file = -check_ssl_certificate = False -check_ssl_hostname = False -cloudfront_host = cloudfront.amazonaws.com -default_mime_type = binary/octet-stream -delay_updates = False -delete_after = False -delete_after_fetch = False -delete_removed = False -dry_run = False -enable_multipart = True -encoding = UTF-8 -encrypt = False -expiry_date = -expiry_days = -expiry_prefix = -follow_symlinks = False -force = False -get_continue = False -gpg_command = /usr/bin/gpg -gpg_decrypt = %(gpg_command)s -d --verbose --no-use-agent --batch --yes --passphrase-fd %(passphrase_fd)s -o %(output_file)s %(input_file)s -gpg_encrypt = %(gpg_command)s -c --verbose --no-use-agent --batch --yes --passphrase-fd %(passphrase_fd)s -o %(output_file)s %(input_file)s -gpg_passphrase = -guess_mime_type = True -host_base = ${credentials.AWS_S3_ENDPOINT} -host_bucket = ${credentials.AWS_S3_ENDPOINT} -human_readable_sizes = False -invalidate_default_index_on_cf = False -invalidate_default_index_root_on_cf = True -invalidate_on_cf = False -kms_key = -limitrate = 0 -list_md5 = False -log_target_prefix = -long_listing = False -max_delete = -1 -mime_type = -multipart_chunk_size_mb = 15 -multipart_max_chunks = 10000 -preserve_attrs = True -progress_meter = True -proxy_host = -proxy_port = 0 -put_continue = False -recursive = False -recv_chunk = 65536 -reduced_redundancy = False -requester_pays = False -restore_days = 1 -secret_key = ${credentials.AWS_SECRET_ACCESS_KEY} -send_chunk = 65536 -server_side_encryption = False -signature_v2 = False -simpledb_host = sdb.amazonaws.com -skip_existing = False -socket_timeout = 300 -stats = False -stop_on_error = False -storage_class = -urlencoding_mode = normal -use_https = True -use_mime_magic = True -verbosity = WARNING -website_endpoint = http://%(bucket)s.s3-website-%(location)s.amazonaws.com/ -website_error = -website_index = index.html - `; - case "rclone": - return ` -[minio] -type = s3 -provider = Minio -env_auth = false -upload_concurrency = 5 -acl = private -bucket_acl = private -endpoint = ${credentials.AWS_S3_ENDPOINT} -access_key_id = ${credentials.AWS_ACCESS_KEY_ID} -secret_access_key = ${credentials.AWS_SECRET_ACCESS_KEY} -session_token = ${credentials.AWS_SESSION_TOKEN} - `; - } - })() - .replace(/^\n/, "") - .replace(/[\t\n]+$/, ""), - programmingLanguage: ((): string => { - switch (selectedTechnology) { - case "R (aws.S3)": - case "R (paws)": - return "r"; - case "Python (s3fs)": - case "Python (boto3)": - case "Python (polars)": - return "python"; - case "shell environment variables": - case "MC client": - return "bash"; - case "s3cmd": - case "rclone": - //NOTE: Not supported by the react-code-block - return "init"; - } - })() - }; - } -); - -const isRefreshing = createSelector(state, state => state.isRefreshing); - -const main = createSelector( - isReady, - isRefreshing, - credentials, - selectedTechnology, - initScript, - createSelector(readyState, state => { - if (state === null) { - return null; - } - - return state.expirationTime; - }), - ( - isReady, - isRefreshing, - credentials, - selectedTechnology, - initScript, - expirationTime - ) => { - if (!isReady) { - return { - isReady: false as const, - isRefreshing - }; - } - - assert(credentials !== null); - assert(selectedTechnology !== null); - assert(initScript !== null); - assert(expirationTime !== null); - - return { - isReady: true as const, - isRefreshing, - credentials, - selectedTechnology, - initScript, - expirationTime - }; - } -); - -export const selectors = { main }; - -const s3Profile = createSelector( - s3ProfilesManagement.selectors.ambientS3Profile, - s3ProfilesManagement.selectors.s3Profiles, - (ambientS3Profile, s3Profiles) => { - if ( - ambientS3Profile !== undefined && - ambientS3Profile.origin === "defined in region" - ) { - return ambientS3Profile; - } - - return ( - s3Profiles.find(s3Profile => s3Profile.profileName === "default") ?? - s3Profiles.find(s3Profile => s3Profile.origin === "defined in region") - ); - } -); - -export const privateSelectors = { - s3Profile, - isRefreshing -}; diff --git a/web/src/core/usecases/s3CodeSnippets/state.ts b/web/src/core/usecases/s3CodeSnippets/state.ts deleted file mode 100644 index 2520518ab..000000000 --- a/web/src/core/usecases/s3CodeSnippets/state.ts +++ /dev/null @@ -1,92 +0,0 @@ -import "minimal-polyfills/Object.fromEntries"; -import { id } from "tsafe/id"; -import { assert } from "tsafe/assert"; -import { createUsecaseActions } from "clean-architecture"; - -export type Technology = - | "R (aws.S3)" - | "R (aws.S3)" - | "R (paws)" - | "Python (s3fs)" - | "Python (boto3)" - | "Python (polars)" - | "shell environment variables" - | "MC client" - | "s3cmd" - | "rclone"; - -type State = State.NotRefreshed | State.Ready; - -namespace State { - type Common = { - isRefreshing: boolean; - }; - - export type NotRefreshed = Common & { - stateDescription: "not refreshed"; - }; - - export type Ready = Common & { - stateDescription: "ready"; - expirationTime: number; - credentials: { - AWS_ACCESS_KEY_ID: string; - AWS_SECRET_ACCESS_KEY: string; - AWS_DEFAULT_REGION: string; - AWS_SESSION_TOKEN: string; - AWS_S3_ENDPOINT: string; - }; - selectedTechnology: Technology; - }; -} - -export const name = "s3CodeSnippets"; - -export const { reducer, actions } = createUsecaseActions({ - name, - initialState: id( - id({ - stateDescription: "not refreshed", - isRefreshing: false - }) - ), - reducers: { - refreshStarted: state => { - state.isRefreshing = true; - }, - refreshed: ( - state, - { - payload - }: { - payload: { - credentials: State.Ready["credentials"]; - expirationTime: number; - }; - } - ) => { - const { credentials, expirationTime } = payload; - - const selectedTechnology: Technology = - state.stateDescription === "ready" - ? state.selectedTechnology - : "R (paws)"; - - return id({ - isRefreshing: false, - stateDescription: "ready", - selectedTechnology, - credentials, - expirationTime - }); - }, - technologyChanged: ( - state, - { payload }: { payload: { technology: Technology } } - ) => { - const { technology } = payload; - assert(state.stateDescription === "ready"); - state.selectedTechnology = technology; - } - } -}); diff --git a/web/src/core/usecases/s3CodeSnippets/thunks.ts b/web/src/core/usecases/s3CodeSnippets/thunks.ts deleted file mode 100644 index ececf3128..000000000 --- a/web/src/core/usecases/s3CodeSnippets/thunks.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Thunks } from "core/bootstrap"; -import { actions } from "./state"; -import { assert } from "tsafe/assert"; -import * as s3ProfilesManagement from "core/usecases/s3ProfilesManagement"; -import type { Technology } from "./state"; -import { parseUrl } from "core/tools/parseUrl"; -import { privateSelectors } from "./selectors"; - -export const thunks = { - /** Can, and must be called before the slice is refreshed, - * tels if the feature is available. - */ - isAvailable: - () => - (...args): boolean => { - const [, getState] = args; - return privateSelectors.s3Profile(getState()) !== undefined; - }, - /** Refresh is expected to be called whenever the component that use this slice mounts */ - refresh: - (params: { doForceRenewToken: boolean }) => - async (...args) => { - const { doForceRenewToken } = params; - - const [dispatch, getState] = args; - - if (privateSelectors.isRefreshing(getState())) { - return; - } - - dispatch(actions.refreshStarted()); - - const s3Profile = privateSelectors.s3Profile(getState()); - - assert(s3Profile !== undefined); - - const { region, host, port } = (() => { - const { host, port = 443 } = parseUrl( - s3Profile.paramsOfCreateS3Client.url - ); - - const region = s3Profile.paramsOfCreateS3Client.region; - - return { region, host, port }; - })(); - - const { tokens } = await (async () => { - const s3Client = await dispatch( - s3ProfilesManagement.protectedThunks.getS3Client({ - profileName: s3Profile.profileName - }) - ); - - const tokens = await s3Client.getToken({ - doForceRenew: doForceRenewToken - }); - - assert(tokens !== undefined); - - return { tokens }; - })(); - - assert(tokens.sessionToken !== undefined); - assert(tokens.expirationTime !== undefined); - - dispatch( - actions.refreshed({ - credentials: { - AWS_ACCESS_KEY_ID: tokens.accessKeyId, - AWS_SECRET_ACCESS_KEY: tokens.secretAccessKey, - AWS_DEFAULT_REGION: region ?? "", - AWS_SESSION_TOKEN: tokens.sessionToken, - AWS_S3_ENDPOINT: `${ - host === "s3.amazonaws.com" - ? `s3.${region}.amazonaws.com` - : host - }${port === 443 ? "" : `:${port}`}` - }, - expirationTime: tokens.expirationTime - }) - ); - }, - changeTechnology: - (params: { technology: Technology }) => - (...args) => { - const { technology } = params; - const [dispatch] = args; - dispatch(actions.technologyChanged({ technology })); - } -} satisfies Thunks; diff --git a/web/src/core/usecases/s3ExplorerUiController/evt.ts b/web/src/core/usecases/s3ExplorerUiController/evt.ts index 8edafe465..da5e14f0a 100644 --- a/web/src/core/usecases/s3ExplorerUiController/evt.ts +++ b/web/src/core/usecases/s3ExplorerUiController/evt.ts @@ -118,6 +118,22 @@ export const createEvt = (({ evtAction, dispatch, getState }) => { } ); + evtAction + .pipe(action => action.usecaseName === "s3ProfilesManagement") + .pipe(() => [ + s3ProfilesManagement.selectors.ambientS3Profile(getState())?.profileName + ]) + .pipe(profileName => profileName !== undefined) + .pipe(onlyIfChanged()) + .attach(() => { + dispatch( + thunks.listPrefix({ + s3Uri: undefined, + debounce: false + }) + ); + }); + evtAction.$attach( action => { if (action.usecaseName !== name) { diff --git a/web/src/core/usecases/s3ExplorerUiController/thunks.ts b/web/src/core/usecases/s3ExplorerUiController/thunks.ts index 4f163f3a8..455449d68 100644 --- a/web/src/core/usecases/s3ExplorerUiController/thunks.ts +++ b/web/src/core/usecases/s3ExplorerUiController/thunks.ts @@ -147,13 +147,6 @@ export const thunks = { ); assert(doesProfileExist); - - dispatch( - thunks.listPrefix({ - s3Uri: undefined, - debounce: false - }) - ); }, deleteBookmark: (() => { let isRunning = false; diff --git a/web/src/core/usecases/s3ProfilesCreationUiController/selectors.ts b/web/src/core/usecases/s3ProfilesCreationUiController/selectors.ts index e5fae77c9..3b4a0ce56 100644 --- a/web/src/core/usecases/s3ProfilesCreationUiController/selectors.ts +++ b/web/src/core/usecases/s3ProfilesCreationUiController/selectors.ts @@ -8,42 +8,14 @@ import type { ProjectConfigs } from "core/usecases/projectManagement"; import * as s3ProfilesManagement from "core/usecases/s3ProfilesManagement"; import * as projectManagement from "core/usecases/projectManagement"; -const readyState = (rootState: RootState) => { - const state = rootState[name]; +const state = (rootState: RootState) => 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 formValues = createSelector(state, state => state.formValues); const existingProfileNames = createSelector( - isReady, - createSelector(readyState, state => { - if (state === null) { - return null; - } - return state.creationTimeOfProfileToEdit; - }), + createSelector(state, state => state.creationTimeOfProfileToEdit), s3ProfilesManagement.selectors.s3Profiles, - (isReady, creationTimeOfProfileToEdit, s3Profiles) => { - if (!isReady) { - return null; - } - - assert(creationTimeOfProfileToEdit !== null); - + (creationTimeOfProfileToEdit, s3Profiles) => { return s3Profiles .filter(s3Profile => { if (creationTimeOfProfileToEdit === undefined) { @@ -65,17 +37,9 @@ const existingProfileNames = createSelector( ); const formValuesErrors = createSelector( - isReady, formValues, existingProfileNames, - (isReady, formValues, existingProfileNames) => { - if (!isReady) { - return null; - } - - assert(formValues !== null); - assert(existingProfileNames !== null); - + (formValues, existingProfileNames) => { const out: Record< keyof typeof formValues, | "must be an url" @@ -134,33 +98,14 @@ const formValuesErrors = createSelector( } ); -const isFormSubmittable = createSelector( - isReady, - formValuesErrors, - (isReady, formValuesErrors) => { - if (!isReady) { - return null; - } - - assert(formValuesErrors !== null); - - return objectKeys(formValuesErrors).every( - key => formValuesErrors[key] === undefined - ); - } -); +const isFormSubmittable = createSelector(formValuesErrors, formValuesErrors => { + 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); - + (formValues, formValuesErrors) => { if (formValuesErrors.url !== undefined) { return undefined; } @@ -172,33 +117,18 @@ const formattedFormValuesUrl = createSelector( ); const submittableFormValuesAsS3Profile_vault = createSelector( - isReady, formValues, formattedFormValuesUrl, isFormSubmittable, - createSelector(readyState, state => { - if (state === null) { - return null; - } - return state.creationTimeOfProfileToEdit; - }), + createSelector(state, state => state.creationTimeOfProfileToEdit), projectManagement.protectedSelectors.projectConfig, ( - isReady, formValues, formattedFormValuesUrl, isFormSubmittable, creationTimeOfProfileToEdit, projectConfig ) => { - if (!isReady) { - return null; - } - assert(formValues !== null); - assert(formattedFormValuesUrl !== null); - assert(isFormSubmittable !== null); - assert(creationTimeOfProfileToEdit !== null); - if (!isFormSubmittable) { return undefined; } @@ -255,15 +185,8 @@ const submittableFormValuesAsS3Profile_vault = createSelector( ); const urlStylesExamples = createSelector( - isReady, formattedFormValuesUrl, - (isReady, formattedFormValuesUrl) => { - if (!isReady) { - return null; - } - - assert(formattedFormValuesUrl !== null); - + formattedFormValuesUrl => { if (formattedFormValuesUrl === undefined) { return undefined; } @@ -286,46 +209,24 @@ const urlStylesExamples = createSelector( ); const main = createSelector( - isReady, formValues, formValuesErrors, isFormSubmittable, urlStylesExamples, - createSelector(readyState, state => { - if (state === null) { - return null; - } - return state.creationTimeOfProfileToEdit !== undefined; - }), + createSelector(state, state => state.creationTimeOfProfileToEdit !== undefined), ( - isReady, formValues, formValuesErrors, isFormSubmittable, urlStylesExamples, isEditionOfAnExistingConfig - ) => { - if (!isReady) { - return { - isReady: false as const - }; - } - - assert(formValues !== null); - assert(formValuesErrors !== null); - assert(isFormSubmittable !== null); - assert(urlStylesExamples !== null); - assert(isEditionOfAnExistingConfig !== null); - - return { - isReady: true, - formValues, - formValuesErrors, - isFormSubmittable, - urlStylesExamples, - isEditionOfAnExistingConfig - }; - } + ) => ({ + formValues, + formValuesErrors, + isFormSubmittable, + urlStylesExamples, + isEditionOfAnExistingConfig + }) ); export const privateSelectors = { diff --git a/web/src/core/usecases/s3ProfilesCreationUiController/state.ts b/web/src/core/usecases/s3ProfilesCreationUiController/state.ts index 98aaed094..fb54b7903 100644 --- a/web/src/core/usecases/s3ProfilesCreationUiController/state.ts +++ b/web/src/core/usecases/s3ProfilesCreationUiController/state.ts @@ -1,66 +1,51 @@ import { createUsecaseActions } from "clean-architecture"; import { id } from "tsafe/id"; -import { assert } from "tsafe/assert"; +import { createObjectThatThrowsIfAccessed } from "clean-architecture"; -export type State = State.NotInitialized | State.Ready; +export type State = { + formValues: State.FormValues; + creationTimeOfProfileToEdit: number | undefined; +}; export namespace State { - export type NotInitialized = { - stateDescription: "not initialized"; - }; - - export type Ready = { - stateDescription: "ready"; - formValues: Ready.FormValues; - creationTimeOfProfileToEdit: number | undefined; + export type FormValues = { + profileName: string; + url: string; + region: string | undefined; + pathStyleAccess: boolean; + isAnonymous: boolean; + accessKeyId: string | undefined; + secretAccessKey: string | undefined; + sessionToken: string | undefined; }; - - export namespace Ready { - export type FormValues = { - profileName: 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 type ChangeValueParams = + { + key: K; + value: State.FormValues[K]; + }; export const name = "s3ProfilesCreationUiController"; export const { reducer, actions } = createUsecaseActions({ name, - initialState: id( - id({ - stateDescription: "not initialized" - }) - ), + initialState: createObjectThatThrowsIfAccessed(), reducers: { - initialized: ( + loaded: ( _state, { payload }: { payload: { creationTimeOfProfileToEdit: number | undefined; - initialFormValues: State.Ready["formValues"]; + initialFormValues: State.FormValues; }; } ) => { const { creationTimeOfProfileToEdit, initialFormValues } = payload; - return id({ - stateDescription: "ready", + return id({ formValues: initialFormValues, creationTimeOfProfileToEdit }); @@ -73,17 +58,11 @@ export const { reducer, actions } = createUsecaseActions({ 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/s3ProfilesCreationUiController/thunks.ts b/web/src/core/usecases/s3ProfilesCreationUiController/thunks.ts index 97b84d2c5..7090b58a2 100644 --- a/web/src/core/usecases/s3ProfilesCreationUiController/thunks.ts +++ b/web/src/core/usecases/s3ProfilesCreationUiController/thunks.ts @@ -6,32 +6,26 @@ import * as s3ProfilesManagement from "core/usecases/s3ProfilesManagement"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; export const thunks = { - initialize: - (params: { - // NOTE: Undefined for creation - profileName_toUpdate: string | undefined; - }) => - async (...args) => { - const { profileName_toUpdate } = params; + load: + (params: { isEdit: boolean }) => + (...args) => { + const { isEdit } = params; const [dispatch, getState] = args; - const s3Profiles = s3ProfilesManagement.selectors.s3Profiles(getState()); - - update_existing_config: { - if (profileName_toUpdate === undefined) { - break update_existing_config; + update_existing_profile: { + if (!isEdit) { + break update_existing_profile; } - const s3Profile = s3Profiles.find( - s3Profile => s3Profile.profileName === profileName_toUpdate - ); + const s3Profile = + s3ProfilesManagement.selectors.ambientS3Profile(getState()); assert(s3Profile !== undefined); assert(s3Profile.origin === "created by user (or group project member)"); dispatch( - actions.initialized({ + actions.loaded({ creationTimeOfProfileToEdit: s3Profile.creationTime, initialFormValues: { profileName: s3Profile.profileName, @@ -77,7 +71,7 @@ export const thunks = { if (s3Profiles_defaultValuesOfCreationForm === undefined) { dispatch( - actions.initialized({ + actions.loaded({ creationTimeOfProfileToEdit: undefined, initialFormValues: { profileName: "", @@ -95,7 +89,7 @@ export const thunks = { } dispatch( - actions.initialized({ + actions.loaded({ creationTimeOfProfileToEdit: undefined, initialFormValues: { profileName: "", @@ -112,13 +106,6 @@ export const thunks = { }) ); }, - reset: - () => - (...args) => { - const [dispatch] = args; - - dispatch(actions.stateResetToNotInitialized()); - }, submit: () => async (...args) => { @@ -127,7 +114,6 @@ export const thunks = { const s3Profile_vault = privateSelectors.submittableFormValuesAsS3Profile_vault(getState()); - assert(s3Profile_vault !== null); assert(s3Profile_vault !== undefined); await dispatch( @@ -139,10 +125,14 @@ export const thunks = { }) ); - dispatch(actions.stateResetToNotInitialized()); + dispatch( + s3ProfilesManagement.protectedThunks.changeAmbientProfile({ + profileName: s3Profile_vault.profileName + }) + ); }, changeValue: - (params: ChangeValueParams) => + (params: ChangeValueParams) => async (...args) => { const { key, value } = params; diff --git a/web/src/core/usecases/s3ProfilesDetailsUiController/decoupledLogic/codeSnippets.ts b/web/src/core/usecases/s3ProfilesDetailsUiController/decoupledLogic/codeSnippets.ts new file mode 100644 index 000000000..e53b759b5 --- /dev/null +++ b/web/src/core/usecases/s3ProfilesDetailsUiController/decoupledLogic/codeSnippets.ts @@ -0,0 +1,504 @@ +export const technologies = [ + "AWS CLI / shared profile", + "Python (boto3)", + "Python (s3fs)", + "Python (polars)", + "Python (pyarrow)", + "DuckDB", + "R (arrow)", + "R (paws)", + "rclone" +] as const; + +export type Technology = (typeof technologies)[number]; + +export type CodeSnippet = { + fileBasename: string; + codeSrc: string; +}; + +type AccessCredentials = + | { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string | undefined; + } + | undefined; + +type SnippetContext = { + profileName: string; + profileNameForShell: string; + region: string; + endpointOrigin: string; + endpointAuthority: string; + endpointScheme: "http" | "https"; + awsConfigSectionName: string; + awsServiceSectionName: string; + rcloneRemoteName: string; + accessCredentials: AccessCredentials; +}; + +const defaultRegionFallback = "us-east-1"; + +export function getCodeSnippet(params: { + technology: Technology; + profileName: string; + endpointUrl: string; + defaultRegion: string | undefined; + accessCredentials: AccessCredentials; +}): CodeSnippet { + const { technology } = params; + + const context = getSnippetContext(params); + + const snippetFactories = { + "AWS CLI / shared profile": getAwsCliSnippet, + "Python (boto3)": getBoto3Snippet, + "Python (s3fs)": getS3fsSnippet, + "Python (polars)": getPolarsSnippet, + "Python (pyarrow)": getPyArrowSnippet, + DuckDB: getDuckDbSnippet, + "R (arrow)": getRArrowSnippet, + "R (paws)": getRPawsSnippet, + rclone: getRcloneSnippet + } satisfies Record CodeSnippet>; + + return snippetFactories[technology](context); +} + +function getSnippetContext(params: { + profileName: string; + endpointUrl: string; + defaultRegion: string | undefined; + accessCredentials: AccessCredentials; +}): SnippetContext { + const { profileName, endpointUrl, defaultRegion, accessCredentials } = params; + + const normalizedEndpointUrl = normalizeEndpointUrl(endpointUrl); + const parsedEndpointUrl = new URL(normalizedEndpointUrl); + + return { + profileName, + profileNameForShell: shellQuote(profileName), + region: defaultRegion ?? defaultRegionFallback, + endpointOrigin: parsedEndpointUrl.origin, + endpointAuthority: parsedEndpointUrl.host, + endpointScheme: parsedEndpointUrl.protocol === "http:" ? "http" : "https", + awsConfigSectionName: + profileName === "default" ? "default" : `profile ${profileName}`, + awsServiceSectionName: `${toSafeIdentifier(profileName, "-")}-s3`, + rcloneRemoteName: toSafeIdentifier(profileName, "-"), + accessCredentials + }; +} + +function getAwsCliSnippet(context: SnippetContext): CodeSnippet { + const { accessCredentials } = context; + + if (accessCredentials === undefined) { + return { + fileBasename: "public-bucket-aws-cli.sh", + codeSrc: trimCode(` + # Public buckets do not need a shared AWS credentials profile. + # Replace "your-bucket" with the bucket you want to inspect. + aws s3 ls \\ + --endpoint-url ${shellQuote(context.endpointOrigin)} \\ + --region ${shellQuote(context.region)} \\ + --no-sign-request \\ + s3://your-bucket/ + `) + }; + } + + return { + fileBasename: "setup-aws-profile.sh", + codeSrc: trimCode(` + mkdir -p ~/.aws + + cat > ~/.aws/credentials <<'EOF' + [${context.profileName}] + aws_access_key_id = ${accessCredentials.accessKeyId} + aws_secret_access_key = ${accessCredentials.secretAccessKey}${ + accessCredentials.sessionToken === undefined + ? "" + : ` + aws_session_token = ${accessCredentials.sessionToken}` + } + EOF + + cat > ~/.aws/config <<'EOF' + [${context.awsConfigSectionName}] + region = ${context.region} + services = ${context.awsServiceSectionName} + + [services ${context.awsServiceSectionName}] + s3 = + endpoint_url = ${context.endpointOrigin} + EOF + + export AWS_PROFILE=${context.profileNameForShell} + + aws s3 ls --profile ${context.profileNameForShell} + `) + }; +} + +function getBoto3Snippet(context: SnippetContext): CodeSnippet { + return { + fileBasename: "example.py", + codeSrc: + context.accessCredentials === undefined + ? trimCode(` + import boto3 + from botocore import UNSIGNED + from botocore.config import Config + + s3 = boto3.client( + "s3", + endpoint_url=${JSON.stringify(context.endpointOrigin)}, + region_name=${JSON.stringify(context.region)}, + config=Config(signature_version=UNSIGNED), + ) + + bucket = "your-bucket" + response = s3.list_objects_v2(Bucket=bucket, MaxKeys=10) + print([item["Key"] for item in response.get("Contents", [])]) + `) + : trimCode(` + import boto3 + + # Preferred: create the shared AWS profile once, then reuse it here. + session = boto3.Session(profile_name=${JSON.stringify(context.profileName)}) + + s3 = session.client( + "s3", + endpoint_url=${JSON.stringify(context.endpointOrigin)}, + region_name=${JSON.stringify(context.region)}, + ) + + bucket = "your-bucket" + response = s3.list_objects_v2(Bucket=bucket, MaxKeys=10) + print([item["Key"] for item in response.get("Contents", [])]) + `) + }; +} + +function getS3fsSnippet(context: SnippetContext): CodeSnippet { + return { + fileBasename: "example.py", + codeSrc: + context.accessCredentials === undefined + ? trimCode(` + import s3fs + + fs = s3fs.S3FileSystem( + anon=True, + endpoint_url=${JSON.stringify(context.endpointOrigin)}, + client_kwargs={"region_name": ${JSON.stringify(context.region)}}, + ) + + print(fs.ls("your-bucket")) + `) + : trimCode(` + import s3fs + + # Preferred: create the shared AWS profile once, then reuse it here. + fs = s3fs.S3FileSystem( + profile=${JSON.stringify(context.profileName)}, + endpoint_url=${JSON.stringify(context.endpointOrigin)}, + client_kwargs={"region_name": ${JSON.stringify(context.region)}}, + ) + + print(fs.ls("your-bucket")) + `) + }; +} + +function getPolarsSnippet(context: SnippetContext): CodeSnippet { + return { + fileBasename: "example.py", + codeSrc: + context.accessCredentials === undefined + ? trimCode(` + import polars as pl + + source = "s3://your-bucket/path/to/dataset/*.parquet" + + lf = pl.scan_parquet( + source, + storage_options={ + "aws_endpoint_url": ${JSON.stringify(context.endpointOrigin)}, + "aws_region": ${JSON.stringify(context.region)}, + "aws_skip_signature": True, + }, + ) + + print(lf.limit(5).collect()) + `) + : trimCode(` + import polars as pl + + source = "s3://your-bucket/path/to/dataset/*.parquet" + + lf = pl.scan_parquet( + source, + storage_options={ + "aws_endpoint_url": ${JSON.stringify(context.endpointOrigin)}, + "aws_region": ${JSON.stringify(context.region)}, + }, + credential_provider=pl.CredentialProviderAWS( + profile_name=${JSON.stringify(context.profileName)}, + region_name=${JSON.stringify(context.region)}, + ), + ) + + print(lf.limit(5).collect()) + `) + }; +} + +function getPyArrowSnippet(context: SnippetContext): CodeSnippet { + return { + fileBasename: "example.py", + codeSrc: + context.accessCredentials === undefined + ? trimCode(` + import pyarrow.dataset as ds + from pyarrow import fs + + s3 = fs.S3FileSystem( + anonymous=True, + region=${JSON.stringify(context.region)}, + endpoint_override=${JSON.stringify(context.endpointAuthority)}, + scheme=${JSON.stringify(context.endpointScheme)}, + ) + + dataset = ds.dataset( + "your-bucket/path/to/dataset/", + filesystem=s3, + format="parquet", + ) + print(dataset.head(5).to_pandas()) + `) + : trimCode(` + import os + import pyarrow.dataset as ds + from pyarrow import fs + + # Preferred: create the shared AWS profile once, then reuse it here. + os.environ["AWS_PROFILE"] = ${JSON.stringify(context.profileName)} + + s3 = fs.S3FileSystem( + region=${JSON.stringify(context.region)}, + endpoint_override=${JSON.stringify(context.endpointAuthority)}, + scheme=${JSON.stringify(context.endpointScheme)}, + ) + + dataset = ds.dataset( + "your-bucket/path/to/dataset/", + filesystem=s3, + format="parquet", + ) + print(dataset.head(5).to_pandas()) + `) + }; +} + +function getDuckDbSnippet(context: SnippetContext): CodeSnippet { + const secretName = `${toSafeIdentifier(context.profileName, "_")}_s3`; + + return { + fileBasename: "example.sql", + codeSrc: + context.accessCredentials === undefined + ? trimCode(` + INSTALL aws; + LOAD aws; + + -- DuckDB's documented S3 flow is credential-based. + -- For public buckets, prefer the Polars, pyarrow, or s3fs snippets, + -- which expose an explicit anonymous / unsigned mode. + `) + : trimCode(` + INSTALL aws; + LOAD aws; + + CREATE OR REPLACE SECRET ${secretName} ( + TYPE s3, + PROVIDER credential_chain, + CHAIN config, + PROFILE ${toSqlString(context.profileName)}, + REGION ${toSqlString(context.region)}, + ENDPOINT ${toSqlString(context.endpointAuthority)}, + USE_SSL ${context.endpointScheme === "https"} + ); + + SELECT * + FROM read_parquet('s3://your-bucket/path/to/dataset/*.parquet') + LIMIT 10; + `) + }; +} + +function getRArrowSnippet(context: SnippetContext): CodeSnippet { + return { + fileBasename: "example.R", + codeSrc: + context.accessCredentials === undefined + ? trimCode(` + install.packages("arrow", repos = "https://cloud.r-project.org") + + library(arrow) + + fs <- S3FileSystem$create( + anonymous = TRUE, + region = ${JSON.stringify(context.region)}, + endpoint_override = ${JSON.stringify(context.endpointAuthority)}, + scheme = ${JSON.stringify(context.endpointScheme)} + ) + + dataset <- open_dataset( + "your-bucket/path/to/dataset/", + filesystem = fs, + format = "parquet" + ) + + print(head(as.data.frame(dataset))) + `) + : trimCode(` + install.packages("arrow", repos = "https://cloud.r-project.org") + + Sys.setenv(AWS_PROFILE = ${JSON.stringify(context.profileName)}) + library(arrow) + + fs <- S3FileSystem$create( + region = ${JSON.stringify(context.region)}, + endpoint_override = ${JSON.stringify(context.endpointAuthority)}, + scheme = ${JSON.stringify(context.endpointScheme)} + ) + + dataset <- open_dataset( + "your-bucket/path/to/dataset/", + filesystem = fs, + format = "parquet" + ) + + print(head(as.data.frame(dataset))) + `) + }; +} + +function getRPawsSnippet(context: SnippetContext): CodeSnippet { + return { + fileBasename: "example.R", + codeSrc: + context.accessCredentials === undefined + ? trimCode(` + install.packages("paws", repos = "https://cloud.r-project.org") + + library(paws) + + s3 <- paws::s3( + credentials = list(anonymous = TRUE), + endpoint = ${JSON.stringify(context.endpointOrigin)}, + region = ${JSON.stringify(context.region)} + ) + + bucket <- "your-bucket" + response <- s3$list_objects_v2(Bucket = bucket, MaxKeys = 10) + keys <- if (is.null(response$Contents)) character() else { + vapply(response$Contents, function(item) item$Key, character(1)) + } + + print(keys) + `) + : trimCode(` + install.packages("paws", repos = "https://cloud.r-project.org") + + library(paws) + + # Preferred: create the shared AWS profile once, then reuse it here. + s3 <- paws::s3( + credentials = list(profile = ${JSON.stringify(context.profileName)}), + endpoint = ${JSON.stringify(context.endpointOrigin)}, + region = ${JSON.stringify(context.region)} + ) + + bucket <- "your-bucket" + response <- s3$list_objects_v2(Bucket = bucket, MaxKeys = 10) + keys <- if (is.null(response$Contents)) character() else { + vapply(response$Contents, function(item) item$Key, character(1)) + } + + print(keys) + `) + }; +} + +function getRcloneSnippet(context: SnippetContext): CodeSnippet { + return { + fileBasename: "rclone.conf", + codeSrc: + context.accessCredentials === undefined + ? trimCode(` + [${context.rcloneRemoteName}] + type = s3 + provider = Other + env_auth = false + region = ${context.region} + endpoint = ${context.endpointOrigin} + access_key_id = + secret_access_key = + + # Blank keys enable anonymous access for public buckets. + # Example: rclone ls ${context.rcloneRemoteName}:your-bucket + `) + : trimCode(` + [${context.rcloneRemoteName}] + type = s3 + provider = Other + env_auth = true + profile = ${context.profileName} + region = ${context.region} + endpoint = ${context.endpointOrigin} + + # Preferred: reuse the shared AWS profile created once in ~/.aws. + # Example: rclone ls ${context.rcloneRemoteName}:your-bucket + `) + }; +} + +function normalizeEndpointUrl(endpointUrl: string): string { + const trimmed = endpointUrl.trim(); + + return /^https?:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} + +function toSafeIdentifier(value: string, separator: "-" | "_"): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, separator) + .replace(new RegExp(`^${separator}+|${separator}+$`, "g"), ""); + + return normalized === "" ? "onyxia" : normalized; +} + +function toSqlString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function trimCode(code: string): string { + const lines = code.replace(/^\n+|\n+$/g, "").split("\n"); + const indents = lines + .filter(line => line.trim() !== "") + .map(line => line.match(/^ */)?.[0].length ?? 0); + + const minIndent = indents.length === 0 ? 0 : Math.min(...indents); + + return lines.map(line => line.slice(minIndent)).join("\n"); +} diff --git a/web/src/core/usecases/s3CodeSnippets/index.ts b/web/src/core/usecases/s3ProfilesDetailsUiController/index.ts similarity index 100% rename from web/src/core/usecases/s3CodeSnippets/index.ts rename to web/src/core/usecases/s3ProfilesDetailsUiController/index.ts diff --git a/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts b/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts new file mode 100644 index 000000000..dd8cf4cec --- /dev/null +++ b/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts @@ -0,0 +1,101 @@ +import type { State as RootState } from "core/bootstrap"; +import { name } from "./state"; +import { createSelector } from "clean-architecture"; +import { + getCodeSnippet, + technologies, + type CodeSnippet, + type Technology +} from "./decoupledLogic/codeSnippets"; +import * as s3ProfilesManagement from "core/usecases/s3ProfilesManagement"; +import { assert } from "tsafe"; +import { parseUrl } from "core/tools/parseUrl"; + +const state = (rootState: RootState) => rootState[name]; + +export type MainView = { + availableProfileNames: string[]; + profileName: string; + endpointUrl: string; + defaultRegion: string | undefined; + isReadonly: boolean; + availableTechnologies: readonly Technology[]; + technology: Technology; + codeSnippet: CodeSnippet; + accessCredentials: + | { + areTokensBeingRenewed: boolean; + areTokensRenewable: boolean; + accessKeyId: string; + secretAccessKey: string; + sessionToken: string | undefined; + expirationTime: number | undefined; + } + | undefined; +}; + +const mainView = createSelector( + state, + s3ProfilesManagement.selectors.ambientS3Profile, + createSelector(s3ProfilesManagement.selectors.s3Profiles, s3Profiles => + s3Profiles.map(s3Profile => s3Profile.profileName) + ), + (state, s3Profile, availableProfileNames): MainView => { + assert(s3Profile !== undefined); + + const { region, host, port } = (() => { + const { host, port = 443 } = parseUrl(s3Profile.paramsOfCreateS3Client.url); + + const region = s3Profile.paramsOfCreateS3Client.region; + + return { region, host, port }; + })(); + + const endpointUrl = `${ + host === "s3.amazonaws.com" ? `s3.${region}.amazonaws.com` : host + }${port === 443 ? "" : `:${port}`}`; + + return { + availableProfileNames, + profileName: s3Profile.profileName, + endpointUrl, + defaultRegion: s3Profile.paramsOfCreateS3Client.region, + isReadonly: (() => { + switch (s3Profile.origin) { + case "created by user (or group project member)": + return true; + case "defined in region": + return false; + } + })(), + availableTechnologies: technologies, + accessCredentials: + state.accessCredentials === undefined + ? undefined + : { + ...state.accessCredentials, + areTokensRenewable: + s3Profile.paramsOfCreateS3Client.isStsEnabled + }, + technology: state.technology, + codeSnippet: getCodeSnippet({ + accessCredentials: state.accessCredentials, + defaultRegion: s3Profile.paramsOfCreateS3Client.region, + endpointUrl: s3Profile.paramsOfCreateS3Client.url, + profileName: s3Profile.profileName, + technology: state.technology + }) + }; + } +); + +export const selectors = { mainView }; + +export const privateSelectors = { + areTokensBeingRenewed: createSelector( + state, + state => + state.accessCredentials !== undefined && + state.accessCredentials.areTokensBeingRenewed + ) +}; diff --git a/web/src/core/usecases/s3ProfilesDetailsUiController/state.ts b/web/src/core/usecases/s3ProfilesDetailsUiController/state.ts new file mode 100644 index 000000000..f436e800d --- /dev/null +++ b/web/src/core/usecases/s3ProfilesDetailsUiController/state.ts @@ -0,0 +1,89 @@ +import "minimal-polyfills/Object.fromEntries"; +import { + createUsecaseActions, + createObjectThatThrowsIfAccessed +} from "clean-architecture"; +import type { Technology } from "./decoupledLogic/codeSnippets"; +import { assert, id } from "tsafe"; +import { technologies } from "./decoupledLogic/codeSnippets"; + +type State = { + accessCredentials: + | { + areTokensBeingRenewed: boolean; + accessKeyId: string; + secretAccessKey: string; + sessionToken: string | undefined; + expirationTime: number | undefined; + } + | undefined; + technology: Technology; +}; + +export const name = "s3ProfilesDetailsUiController"; + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: createObjectThatThrowsIfAccessed(), + reducers: { + loaded: ( + _state, + { + payload + }: { + payload: { + accessCredentials: + | { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string | undefined; + expirationTime: number | undefined; + } + | undefined; + }; + } + ) => { + const { accessCredentials } = payload; + + return id({ + accessCredentials: + accessCredentials === undefined + ? undefined + : { + ...accessCredentials, + areTokensBeingRenewed: false + }, + technology: technologies[0] + }); + }, + tokenRenewalStarted: state => { + assert(state.accessCredentials !== undefined); + state.accessCredentials.areTokensBeingRenewed = true; + }, + tokenRenewed: ( + state, + { + payload + }: { + payload: { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string | undefined; + expirationTime: number; + }; + } + ) => { + state.accessCredentials = { + ...payload, + areTokensBeingRenewed: false + }; + }, + technologyChanged: ( + state, + { payload }: { payload: { technology: Technology } } + ) => { + const { technology } = payload; + state.technology = technology; + } + } +}); diff --git a/web/src/core/usecases/s3ProfilesDetailsUiController/thunks.ts b/web/src/core/usecases/s3ProfilesDetailsUiController/thunks.ts new file mode 100644 index 000000000..cfd4a6f10 --- /dev/null +++ b/web/src/core/usecases/s3ProfilesDetailsUiController/thunks.ts @@ -0,0 +1,93 @@ +import type { Thunks } from "core/bootstrap"; +import { actions } from "./state"; +import { assert } from "tsafe/assert"; +import * as s3ProfilesManagement from "core/usecases/s3ProfilesManagement"; +import { privateSelectors } from "./selectors"; +import type { Technology } from "./decoupledLogic/codeSnippets"; + +export const thunks = { + load: + () => + async (...args) => { + const [dispatch] = args; + + const s3ClientWrap = await dispatch( + s3ProfilesManagement.protectedThunks.getAmbientS3ProfileAndClient() + ); + + assert(s3ClientWrap !== undefined); + + const { s3Client } = s3ClientWrap; + + const tokens = await s3Client.getToken({ doForceRenew: false }); + + dispatch( + actions.loaded({ + accessCredentials: + tokens === undefined + ? undefined + : { + accessKeyId: tokens.accessKeyId, + expirationTime: tokens.expirationTime, + secretAccessKey: tokens.secretAccessKey, + sessionToken: tokens.sessionToken + } + }) + ); + }, + renewTokens: + () => + async (...args) => { + const [dispatch, getState] = args; + + if (privateSelectors.areTokensBeingRenewed(getState())) { + return; + } + + dispatch(actions.tokenRenewalStarted()); + + const s3ClientWrap = await dispatch( + s3ProfilesManagement.protectedThunks.getAmbientS3ProfileAndClient() + ); + + assert(s3ClientWrap !== undefined); + + const { s3Client } = s3ClientWrap; + + const tokens = await s3Client.getToken({ doForceRenew: true }); + + assert(tokens !== undefined); + assert(tokens.expirationTime !== undefined); + + dispatch( + actions.tokenRenewed({ + accessKeyId: tokens.accessKeyId, + expirationTime: tokens.expirationTime, + secretAccessKey: tokens.secretAccessKey, + sessionToken: tokens.sessionToken + }) + ); + }, + changeTechnology: + (params: { technology: Technology }) => + (...args) => { + const { technology } = params; + const [dispatch] = args; + dispatch(actions.technologyChanged({ technology })); + }, + updateSelectedS3Profile: + (params: { profileName: string }) => + async (...args) => { + const [dispatch] = args; + + const { profileName } = params; + + const { doesProfileExist } = dispatch( + s3ProfilesManagement.protectedThunks.changeAmbientProfile({ + profileName + }) + ); + + assert(doesProfileExist); + } +} satisfies Thunks; diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index d09505431..97ab3390e 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -11,7 +11,6 @@ export const translations: Translations<"de"> = { Account: { profile: "Profil", git: undefined, - storage: "Verbindung zum Speicher", k8sCodeSnippets: "Verbindung zu Kubernetes", "user-interface": "Konfiguration der Benutzeroberfläche", text1: "Mein Konto", @@ -74,16 +73,6 @@ export const translations: Translations<"de"> = { ) }, - AccountStorageTab: { - "credentials section title": "Verbinden Sie Ihre Daten mit Ihren Diensten", - "credentials section helper": - "MinIO-objektbasierter Speicher, kompatibel mit Amazon (AWS S3). Diese Informationen sind bereits automatisch eingetragen.", - "accessible as env": "In Ihren Diensten als Umgebungsvariable verfügbar", - "init script section title": - "Zugriff auf den Speicher außerhalb der Datalab-Dienste", - "init script section helper": `Laden Sie das Initialisierungsskript in der Programmiersprache Ihrer Wahl herunter.`, - "expires in": ({ howMuchTime }) => `Läuft in ${howMuchTime} ab` - }, AccountKubernetesTab: { "credentials section title": "Verbindung zum Kubernetes-Cluster herstellen", "credentials section helper": diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 8cc4a07fe..c792a8ba6 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -10,7 +10,6 @@ export const translations: Translations<"en"> = { Account: { profile: "Profile", git: "Git", - storage: "Connect to storage", k8sCodeSnippets: "Kubernetes", "user-interface": "Interface preferences", text1: "My account", @@ -74,17 +73,6 @@ export const translations: Translations<"en"> = { ) }, - AccountStorageTab: { - "credentials section title": "Connect your data to your services", - "credentials section helper": - "Amazon-compatible MinIO object storage (AWS S3). This information is already filled in automatically.", - "accessible as env": - "Accessible withing your services as the environnement variable:", - "init script section title": "To access your storage outside of datalab services", - "init script section helper": - "Download or copy the init script in the programming language of your choice.", - "expires in": ({ howMuchTime }) => `Expires ${howMuchTime}` - }, AccountKubernetesTab: { "credentials section title": "Connect to the Kubernetes cluster", "credentials section helper": diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index ada449c65..7623e5572 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -11,7 +11,6 @@ export const translations: Translations<"en"> = { Account: { profile: "Perfil", git: "Git", - storage: "Conectar al almacenamiento", k8sCodeSnippets: "Kubernetes", "user-interface": "Preferencias de interfaz", text1: "Mi cuenta", @@ -75,18 +74,6 @@ export const translations: Translations<"en"> = { ) }, - AccountStorageTab: { - "credentials section title": "Conecta tus datos a tus servicios", - "credentials section helper": - "Almacenamiento de objetos MinIO compatible con Amazon (AWS S3). Esta información ya está rellenada automáticamente.", - "accessible as env": - "Accesible dentro de tus servicios como la variable de entorno:", - "init script section title": - "Para acceder a tu almacenamiento fuera de los servicios de datalab", - "init script section helper": - "Descarga o copia el script de inicialización en el lenguaje de programación de tu elección.", - "expires in": ({ howMuchTime }) => `Expira ${howMuchTime}` - }, AccountKubernetesTab: { "credentials section title": "Conéctate al clúster de Kubernetes", "credentials section helper": diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index ef31e787e..0a7c28139 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -11,7 +11,6 @@ export const translations: Translations<"fi"> = { Account: { profile: "Profiili", git: undefined, - storage: "Yhdistä tallennustilaan", k8sCodeSnippets: "Kubernetes", "user-interface": "Käyttöliittymän asetukset", text1: "Oma tili", @@ -75,17 +74,6 @@ export const translations: Translations<"fi"> = { ) }, - AccountStorageTab: { - "credentials section title": "Yhdistä datat palveluihisi", - "credentials section helper": - "Amazon-yhteensopiva MinIO-objektivarasto (AWS S3). Tämä tieto täytetään automaattisesti.", - "accessible as env": "Käytettävissä palveluissasi ympäristömuuttujana:", - "init script section title": - "Pääsy tallennustilaan datalab-palveluiden ulkopuolelta", - "init script section helper": - "Lataa tai kopioi alustan tukemat aloituskomenskriptit valitsemallasi ohjelmointikielellä.", - "expires in": ({ howMuchTime }) => `Vanhenee ${howMuchTime} kuluttua` - }, AccountKubernetesTab: { "credentials section title": "Yhdistä Kubernetes-klusteriin", "credentials section helper": diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 1d6afa942..14381da89 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -11,7 +11,6 @@ export const translations: Translations<"fr"> = { Account: { profile: "Profil", git: undefined, - storage: "Connexion au stockage", k8sCodeSnippets: "Connexion à Kubernetes", "user-interface": "Modes d'interface", text1: "Mon compte", @@ -75,18 +74,6 @@ export const translations: Translations<"fr"> = { ) }, - AccountStorageTab: { - "credentials section title": "Connecter vos données à vos services", - "credentials section helper": - "Stockage object MinIO compatible Amazon (AWS S3). Ces informations sont déjà renseignées automatiquement.", - "accessible as env": - "Accessible au sein de vos services en tant que la variable d'environnement", - "init script section title": - "Pour accéder au stockage en dehors des services du datalab", - "init script section helper": - "Téléchargez ou copiez le script d'initialisation dans le langage de programmation de votre choix.", - "expires in": ({ howMuchTime }) => `Expire ${howMuchTime}` - }, AccountKubernetesTab: { "credentials section title": "Connection au cluster Kubernetes", "credentials section helper": diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index 7f2c9c2f3..a058a1e18 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -11,7 +11,6 @@ export const translations: Translations<"it"> = { Account: { profile: "Profilo", git: undefined, - storage: "Connessione allo storage", k8sCodeSnippets: "Connessione a Kubernetes", "user-interface": "Modalità d'interfaccia", text1: "Il mio account", @@ -74,17 +73,6 @@ export const translations: Translations<"it"> = { ) }, - AccountStorageTab: { - "credentials section title": "Collega i tuoi dati ai tuoi servizi", - "credentials section helper": - "Archiviazione oggetti MinIO compatibile con Amazon (AWS S3). Queste informazioni sono già precompilate automaticamente.", - "accessible as env": - "Accessibile all'interno dei tuoi servizi come variabile d'ambiente", - "init script section title": - "Per accedere allo storage al di fuori dei servizi del datalab", - "init script section helper": `Scarica o copia lo script di inizializzazione nel linguaggio di programmazione di tua scelta.`, - "expires in": ({ howMuchTime }) => `Scade in ${howMuchTime}` - }, AccountKubernetesTab: { "credentials section title": "Connetti al cluster Kubernetes", "credentials section helper": @@ -230,8 +218,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 6bee65356..dfec4cd7f 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -11,7 +11,6 @@ export const translations: Translations<"nl"> = { Account: { profile: "Profiel", git: undefined, - storage: "Verbinding met opslag", k8sCodeSnippets: "Verbinding met Kubernetes", "user-interface": "Interfacemodi", text1: "Mijn account", @@ -74,16 +73,6 @@ export const translations: Translations<"nl"> = { ) }, - AccountStorageTab: { - "credentials section title": "Uw gegevens verbinden met uw diensten", - "credentials section helper": - "Opslag object MinIO compatible Amazon (AWS S3). Deze informatie is al automatisch ingevuld.", - "accessible as env": "Toegankelijk binnen uw diensten als omgevingsvariabele", - "init script section title": - "Om toegang te krijgen tot opslag buiten de diensten van het datalab", - "init script section helper": `Download of kopieer het initialisatiescript in de programmeertaal van uw keuze.`, - "expires in": ({ howMuchTime }) => `Vervalt binnen ${howMuchTime}` - }, AccountKubernetesTab: { "credentials section title": "Verbind met de Kubernetes-cluster", "credentials section helper": diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index bf05fdacc..ecf71d587 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -11,7 +11,6 @@ export const translations: Translations<"no"> = { Account: { profile: "Profil", git: undefined, - storage: "Koble til lagring", k8sCodeSnippets: "Kubernetes", "user-interface": "Grensesnittspreferanser", text1: "Min konto", @@ -74,17 +73,6 @@ export const translations: Translations<"no"> = { ) }, - AccountStorageTab: { - "credentials section title": "Koble dataene dine til tjenestene dine", - "credentials section helper": - "Amazon-kompatibel MinIO-objektlagring (AWS S3). Denne informasjonen fylles allerede automatisk ut.", - "accessible as env": "Tilgjengelig i tjenestene dine som en miljøvariabel:", - "init script section title": - "For å få tilgang til lagringen din utenfor datalabtjenestene", - "init script section helper": - "Last ned eller kopier initialiseringskriptet i programingsspråket du foretrekker.", - "expires in": ({ howMuchTime }) => `Utløper om ${howMuchTime}` - }, AccountKubernetesTab: { "credentials section title": "Koble til Kubernetes-klusteret", "credentials section helper": diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index 916e0ff63..a0feddc8e 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -11,7 +11,6 @@ export const translations: Translations<"zh-CN"> = { Account: { profile: "个人资料", git: undefined, - storage: "链接到储存器", "user-interface": "变换显示模式", k8sCodeSnippets: "Kubernetes", text1: "我的账号", @@ -69,15 +68,6 @@ export const translations: Translations<"zh-CN"> = { ) }, - AccountStorageTab: { - "credentials section title": "将您的数据连接到您的服务", - "credentials section helper": - "与 Amazon (AWS S3) 兼容的对象存储 MinIO. 此信息已自动填写.", - "accessible as env": "可在您的服务中作为环境变量被访问", - "init script section title": "访问datalab服务之外的存储器", - "init script section helper": `下载或复制用您选择的编程语言编写的初始化脚本.`, - "expires in": ({ howMuchTime }) => `有效期至 ${howMuchTime}` - }, AccountKubernetesTab: { "credentials section title": "连接到 Kubernetes 集群", "credentials section helper": "用于直接与 Kubernetes API 服务器交互的凭证。", diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index 7f628e1e8..1537967ca 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -21,20 +21,6 @@ export type ComponentKey = | import("ui/pages/s3Explorer/dialogs/CreateOrRenameBookmarkDialog").I18n | import("ui/pages/s3Explorer/dialogs/DirectoryCreationDialog").I18n | import("ui/pages/s3Explorer/dialogs/CreateOrUpdateProfileDialog").I18n - /* - | import("ui/pages/s3Explorer/Explorer").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 @@ -48,7 +34,6 @@ export type ComponentKey = | import("ui/pages/account/AccountProfileTab/UserProfileForm").I18n | import("ui/pages/account/AccountProfileTab/ConfirmNavigationDialog").I18n | import("ui/pages/account/AccountGitTab").I18n - | import("ui/pages/account/AccountStorageTab").I18n | import("ui/pages/account/AccountKubernetesTab").I18n | import("ui/pages/account/AccountUserInterfaceTab").I18n | import("ui/pages/account/AccountVaultTab").I18n diff --git a/web/src/ui/pages/account/AccountStorageTab.tsx b/web/src/ui/pages/account/AccountStorageTab.tsx deleted file mode 100644 index 9dc935a4b..000000000 --- a/web/src/ui/pages/account/AccountStorageTab.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { useEffect, memo, lazy, Suspense } from "react"; -import { useTranslation } from "ui/i18n"; -import { SettingSectionHeader } from "ui/shared/SettingSectionHeader"; -import { SettingField } from "ui/shared/SettingField"; -import { useCallbackFactory } from "powerhooks/useCallbackFactory"; -import { copyToClipboard } from "ui/tools/copyToClipboard"; -import Divider from "@mui/material/Divider"; -import { tss } from "tss"; -import { assert } from "tsafe/assert"; -import { saveAs } from "file-saver"; -import { smartTrim } from "ui/tools/smartTrim"; -import { useFromNow } from "ui/shared/formattedDate"; -import { useCoreState, getCoreSync } from "core"; -import { declareComponentKeys } from "i18nifty"; -import { useConstCallback } from "powerhooks/useConstCallback"; -import Select from "@mui/material/Select"; -import MenuItem from "@mui/material/MenuItem"; -import FormControl from "@mui/material/FormControl"; -import type { Technology } from "core/usecases/s3CodeSnippets"; -import type { SelectChangeEvent } from "@mui/material/Select"; -import type { Equals } from "tsafe"; -import { IconButton } from "onyxia-ui/IconButton"; -import { CircularProgress } from "onyxia-ui/CircularProgress"; -import { capitalize } from "tsafe/capitalize"; -import { getIconUrlByName } from "lazy-icons"; - -const CodeBlock = lazy(() => import("ui/shared/CodeBlock")); - -const technologies = [ - "R (aws.S3)", - "R (paws)", - "Python (s3fs)", - "Python (boto3)", - "Python (polars)", - "shell environment variables", - "MC client", - "s3cmd", - "rclone" -] as const; - -assert>(); - -export type Props = { - className?: string; -}; - -const AccountStorageTab = memo((props: Props) => { - const { className } = props; - - const { classes, theme } = useStyles(); - - const { t } = useTranslation({ AccountStorageTab }); - - const { - functions: { s3CodeSnippets } - } = getCoreSync(); - - useEffect(() => { - s3CodeSnippets.refresh({ doForceRenewToken: false }); - }, []); - - const { - isReady, - isRefreshing, - credentials, - expirationTime, - initScript, - selectedTechnology - } = useCoreState("s3CodeSnippets", "main"); - - const { fromNowText } = useFromNow({ dateTime: expirationTime ?? 0 }); - const onSelectChangeTechnology = useConstCallback((e: SelectChangeEvent) => - s3CodeSnippets.changeTechnology({ - technology: e.target.value as Technology - }) - ); - - const onFieldRequestCopyFactory = useCallbackFactory(([textToCopy]: [string]) => - copyToClipboard(textToCopy) - ); - - const onGetAppIconButtonClick = useConstCallback(() => { - assert(isReady); - saveAs( - new Blob([initScript.scriptCode], { - type: "text/plain;charset=utf-8" - }), - initScript.fileBasename - ); - }); - - const onRefreshIconButtonClick = useConstCallback(() => - s3CodeSnippets.refresh({ doForceRenewToken: true }) - ); - - if (!isReady) { - return ; - } - - return ( -
- - {t("credentials section helper")} -   - {t("expires in", { howMuchTime: fromNowText })} - - - } - /> - {( - [ - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - "AWS_SESSION_TOKEN", - "AWS_S3_ENDPOINT", - "AWS_DEFAULT_REGION" - ] as const - ).map(key => ( - - {t("accessible as env")} -   - {`$${key}`} - - } - onRequestCopy={onFieldRequestCopyFactory(credentials[key])} - isSensitiveInformation={ - key === "AWS_SECRET_ACCESS_KEY" || key === "AWS_SESSION_TOKEN" - } - /> - ))} - - -
- - - -
- -
- - {/* This component depends on a heavy third party library, we don't want to include it in the main bundle */} - - -
- ); -}); - -export default AccountStorageTab; - -const { i18n } = declareComponentKeys< - | "credentials section title" - | "credentials section helper" - | "accessible as env" - | "init script section title" - | "init script section helper" - | { K: "expires in"; P: { howMuchTime: string } } ->()({ AccountStorageTab }); -export type I18n = typeof i18n; - -const useStyles = tss.withName({ AccountStorageTab }).create(({ theme }) => ({ - divider: { - ...theme.spacing.topBottom("margin", 4) - }, - envVar: { - color: theme.colors.useCases.typography.textFocus - }, - codeBlockHeaderWrapper: { - display: "flex", - marginBottom: theme.spacing(3) - } -})); diff --git a/web/src/ui/pages/account/Page.tsx b/web/src/ui/pages/account/Page.tsx index a8192084b..3be3dce6b 100644 --- a/web/src/ui/pages/account/Page.tsx +++ b/web/src/ui/pages/account/Page.tsx @@ -24,7 +24,6 @@ export default Page; const AccountGitTab = lazy(() => import("./AccountGitTab")); const AccountKubernetesTab = lazy(() => import("./AccountKubernetesTab")); const AccountProfileTab = lazy(() => import("./AccountProfileTab")); -const AccountStorageTab = lazy(() => import("./AccountStorageTab")); const AccountUserInterfaceTab = lazy(() => import("./AccountUserInterfaceTab")); const AccountVaultTab = lazy(() => import("./AccountVaultTab")); @@ -35,15 +34,12 @@ function Account() { const { t } = useTranslation({ Account }); const { - functions: { s3CodeSnippets, k8sCodeSnippets, vaultCredentials } + functions: { k8sCodeSnippets, vaultCredentials } } = getCoreSync(); const tabs = useMemo( () => accountTabIds - .filter(accountTabId => - accountTabId !== "storage" ? true : s3CodeSnippets.isAvailable() - ) .filter(accountTabId => accountTabId !== "k8sCodeSnippets" ? true @@ -86,8 +82,6 @@ function Account() { return ; case "git": return ; - case "storage": - return ; case "user-interface": return ; case "k8sCodeSnippets": diff --git a/web/src/ui/pages/account/accountTabIds.ts b/web/src/ui/pages/account/accountTabIds.ts index 093e9b56f..266eef08c 100644 --- a/web/src/ui/pages/account/accountTabIds.ts +++ b/web/src/ui/pages/account/accountTabIds.ts @@ -1,7 +1,6 @@ export const accountTabIds = [ "profile", "git", - "storage", "k8sCodeSnippets", "vault", "user-interface" diff --git a/web/src/ui/pages/s3Explorer/ProfileModal.tsx b/web/src/ui/pages/s3Explorer/ProfileModal.tsx new file mode 100644 index 000000000..9bcc26ab1 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/ProfileModal.tsx @@ -0,0 +1,267 @@ +import { S3ProfileDetails as S3ProfileDetails_headless } from "ui/shared/codex/s3ProfileModal/S3ProfileDetails"; +import { S3ProfileForm as S3ProfileForm_headless } from "ui/shared/codex/s3ProfileModal/S3ProfileForm"; +import { type NonPostableEvt } from "evt"; +import { useEvt } from "evt/hooks/useEvt"; +import { useState, type ReactNode } from "react"; +import { withLoader } from "ui/tools/withLoader"; +import { getCore, useCoreState, getCoreSync } from "core"; +import { tss } from "tss"; +import { Text } from "onyxia-ui/Text"; +import { IconButton } from "onyxia-ui/IconButton"; +import { getIconUrlByName } from "lazy-icons"; + +type Props = { + evtOpen: NonPostableEvt<"detail" | "create">; +}; + +export function S3ProfileModal(props: Props) { + const { evtOpen } = props; + + const [activeView, setActiveView] = useState< + "detail" | "create" | "edit" | undefined + >(undefined); + + useEvt( + ctx => { + evtOpen.attach(ctx, setActiveView); + }, + [evtOpen] + ); + + if (activeView === undefined) { + return null; + } + + return ( + { + switch (activeView) { + case "detail": + return "S3 Profile Detail"; + case "create": + return "New Custom S3 Profile"; + case "edit": + return "Edit Custom S3 Profile"; + } + })()} + onClose={() => setActiveView(undefined)} + > + {(() => { + switch (activeView) { + case "detail": + return ( + setActiveView("create")} + onEdit={() => setActiveView("edit")} + /> + ); + case "create": + case "edit": + return ( + setActiveView(undefined)} + /> + ); + } + })()} + + ); +} + +function SideModal(props: { + title: ReactNode; + onClose: () => void; + children: ReactNode; +}) { + const { children, title, onClose } = props; + + const { classes } = useStyles_SideModal(); + + return ( +
+
+ {title} + +
+ +
{children}
+
+ ); +} + +const useStyles_SideModal = tss.withName({ SideModal }).create(({ theme }) => ({ + root: { + position: "fixed", + with: 600, + top: theme.spacing(5), + bottom: theme.spacing(5), + right: 0, + display: "flex", + flexDirection: "column" + }, + headingWrapper: { + display: "flex", + justifyContent: "space-between", + borderBottom: `1px solid ${theme.colors.useCases.typography.textPrimary}` + }, + childrenWrapper: { + flex: 1, + overflow: "auto" + } +})); + +const S3ProfileDetails = withLoader<{ + onCreateNewProfile: () => void; + onEdit: () => void; +}>({ + loader: async () => { + const core = await getCore(); + await core.functions.s3ProfilesDetailsUiController.load(); + }, + FallbackComponent: () => null, + Component: ({ onCreateNewProfile, onEdit }) => { + const mainView = useCoreState("s3ProfilesDetailsUiController", "mainView"); + + const { + functions: { s3ProfilesDetailsUiController } + } = getCoreSync(); + + return ( + s3ProfilesDetailsUiController.renewTokens() + : undefined + } + } + availableTechnologies={mainView.availableTechnologies} + technology={mainView.technology} + codeSippet={mainView.codeSnippet} + /> + ); + } +}); + +const S3ProfileForm = withLoader<{ + isEdit: boolean; + onClose: () => void; +}>({ + loader: async ({ isEdit }) => { + const core = await getCore(); + core.functions.s3ProfilesCreationUiController.load({ isEdit }); + }, + FallbackComponent: () => null, + Component: ({ onClose }) => { + const mainView = useCoreState("s3ProfilesCreationUiController", "main"); + const { + functions: { s3ProfilesCreationUiController } + } = getCoreSync(); + + return ( + + s3ProfilesCreationUiController.changeValue({ + key: "profileName", + value: newValue + }), + errorMessage: mainView.formValuesErrors.profileName + }} + endpointUrl={{ + value: mainView.formValues.url, + onChange: newValue => + s3ProfilesCreationUiController.changeValue({ + key: "url", + value: newValue + }), + errorMessage: mainView.formValuesErrors.url + }} + defaultRegion={{ + value: mainView.formValues.region, + onChange: newValue => + s3ProfilesCreationUiController.changeValue({ + key: "region", + value: newValue + }), + errorMessage: mainView.formValuesErrors.region + }} + urlStyle={{ + value: mainView.formValues.pathStyleAccess + ? "path" + : "virtual-hosted", + onChange: newValue => + s3ProfilesCreationUiController.changeValue({ + key: "pathStyleAccess", + value: newValue === "path" + }) + }} + isAnonymous={{ + value: mainView.formValues.isAnonymous, + onChange: newValue => + s3ProfilesCreationUiController.changeValue({ + key: "isAnonymous", + value: newValue + }) + }} + accessKeyId={{ + value: mainView.formValues.accessKeyId, + onChange: newValue => + s3ProfilesCreationUiController.changeValue({ + key: "accessKeyId", + value: newValue + }), + errorMessage: mainView.formValuesErrors.accessKeyId + }} + secretAccessKey={{ + value: mainView.formValues.secretAccessKey, + onChange: newValue => + s3ProfilesCreationUiController.changeValue({ + key: "secretAccessKey", + value: newValue + }), + errorMessage: mainView.formValuesErrors.secretAccessKey + }} + sessionToken={{ + value: mainView.formValues.sessionToken, + onChange: newValue => + s3ProfilesCreationUiController.changeValue({ + key: "sessionToken", + value: newValue + }), + errorMessage: mainView.formValuesErrors.sessionToken + }} + onSubmit={(() => { + if (!mainView.isFormSubmittable) { + return undefined; + } + + return async () => { + await s3ProfilesCreationUiController.submit(); + onClose(); + }; + })()} + onCancel={onClose} + /> + ); + } +}); diff --git a/web/src/ui/shared/codex/s3ProfileModal/S3ProfileDetails/S3ProfileDetails.tsx b/web/src/ui/shared/codex/s3ProfileModal/S3ProfileDetails/S3ProfileDetails.tsx new file mode 100644 index 000000000..90c6610f5 --- /dev/null +++ b/web/src/ui/shared/codex/s3ProfileModal/S3ProfileDetails/S3ProfileDetails.tsx @@ -0,0 +1,40 @@ +import type { + Technology, + CodeSnippet +} from "core/usecases/s3ProfilesDetailsUiController/decoupledLogic/codeSnippets"; + +export type Props = { + className?: string; + /** Assert at least one profile */ + availableProfileNames: string[]; + + profileName: string; + + onSelectedProfileChange: (params: { profileName: string }) => void; + + onCreateNewProfile: () => void; + + onEdit: (() => void) | undefined; + + endpointUrl: string; + defaultRegion: string | undefined; + + accessCredentials: + | { + expirationTime: number | undefined; + accessKeyId: string; + secretAccessKey: string; + sessionToken: string | undefined; + areTokensBeingRenewed: boolean; + onRenewToken: (() => void) | undefined; + } + | undefined; + + availableTechnologies: readonly Technology[]; + technology: Technology; + codeSippet: CodeSnippet; +}; + +export function S3ProfileDetails(props: Props) { + return null; +} diff --git a/web/src/ui/shared/codex/s3ProfileModal/S3ProfileDetails/index.ts b/web/src/ui/shared/codex/s3ProfileModal/S3ProfileDetails/index.ts new file mode 100644 index 000000000..9537861b4 --- /dev/null +++ b/web/src/ui/shared/codex/s3ProfileModal/S3ProfileDetails/index.ts @@ -0,0 +1 @@ +export * from "./S3ProfileDetails"; diff --git a/web/src/ui/shared/codex/s3ProfileModal/S3ProfileForm/S3ProfileForm.tsx b/web/src/ui/shared/codex/s3ProfileModal/S3ProfileForm/S3ProfileForm.tsx new file mode 100644 index 000000000..aab60230d --- /dev/null +++ b/web/src/ui/shared/codex/s3ProfileModal/S3ProfileForm/S3ProfileForm.tsx @@ -0,0 +1,63 @@ +type ErrorId = + | "must be an url" + | "is required" + | "not a valid access key id" + | "profile name already used"; + +export type Props = { + className?: string; + + profileName: { + value: string; + onChange: (newValue: string) => void; + errorMessage: ErrorId | undefined; + }; + + endpointUrl: { + value: string; + onChange: (newValue: string) => void; + errorMessage: ErrorId | undefined; + }; + + defaultRegion: { + value: string | undefined; + onChange: (newValue: string | undefined) => void; + errorMessage: ErrorId | undefined; + }; + + urlStyle: { + value: "path" | "virtual-hosted"; + onChange: (newValue: "path" | "virtual-hosted") => void; + }; + + isAnonymous: { + value: boolean; + onChange: (newValue: boolean) => void; + }; + + accessKeyId: { + value: string | undefined; + onChange: (newValue: string | undefined) => void; + errorMessage: ErrorId | undefined; + }; + + secretAccessKey: { + value: string | undefined; + onChange: (newValue: string | undefined) => void; + errorMessage: ErrorId | undefined; + }; + + sessionToken: { + value: string | undefined; + onChange: (newValue: string | undefined) => void; + errorMessage: ErrorId | undefined; + }; + + onSubmit: (() => void) | undefined; + + onCancel: () => void; +}; + +export function S3ProfileForm(props: Props) { + return null; +} diff --git a/web/src/ui/shared/codex/s3ProfileModal/S3ProfileForm/index.ts b/web/src/ui/shared/codex/s3ProfileModal/S3ProfileForm/index.ts new file mode 100644 index 000000000..7ead60561 --- /dev/null +++ b/web/src/ui/shared/codex/s3ProfileModal/S3ProfileForm/index.ts @@ -0,0 +1 @@ +export * from "./S3ProfileForm"; From 7b9836535335ee9dbb37652eb04bc27d82c7845c Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 29 Apr 2026 11:04:51 +0200 Subject: [PATCH 02/14] Move S3 profile modal into explorer dialogs Rename profile modal/dialog naming for consistency Replace old create/update profile dialog usage with S3ProfileDialog Remove obsolete create/update profile dialog i18n resources Rename shared S3 profile UI folder from modal to dialog Fix custom profile edit availability in profile details --- .../selectors.ts | 4 +- 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 | 61 +-- 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 - web/src/ui/pages/s3Explorer/Page.tsx | 25 +- .../dialogs/CreateOrUpdateProfileDialog.tsx | 415 ------------------ .../s3Explorer/dialogs/S3ExplorerDialogs.tsx | 11 +- .../S3ProfileDialog.tsx} | 32 +- .../S3ProfileDetails/S3ProfileDetails.tsx | 2 +- .../S3ProfileDetails/index.ts | 0 .../S3ProfileForm/S3ProfileForm.tsx | 2 +- .../S3ProfileForm/index.ts | 0 19 files changed, 34 insertions(+), 940 deletions(-) delete mode 100644 web/src/ui/pages/s3Explorer/dialogs/CreateOrUpdateProfileDialog.tsx rename web/src/ui/pages/s3Explorer/{ProfileModal.tsx => dialogs/S3ProfileDialog.tsx} (93%) rename web/src/ui/shared/codex/{s3ProfileModal => s3ProfileDialog}/S3ProfileDetails/S3ProfileDetails.tsx (95%) rename web/src/ui/shared/codex/{s3ProfileModal => s3ProfileDialog}/S3ProfileDetails/index.ts (100%) rename web/src/ui/shared/codex/{s3ProfileModal => s3ProfileDialog}/S3ProfileForm/S3ProfileForm.tsx (96%) rename web/src/ui/shared/codex/{s3ProfileModal => s3ProfileDialog}/S3ProfileForm/index.ts (100%) diff --git a/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts b/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts index dd8cf4cec..129d61959 100644 --- a/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts +++ b/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts @@ -63,9 +63,9 @@ const mainView = createSelector( isReadonly: (() => { switch (s3Profile.origin) { case "created by user (or group project member)": - return true; - case "defined in region": return false; + case "defined in region": + return true; } })(), availableTechnologies: technologies, diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index 97ab3390e..35e37b8e3 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -268,61 +268,6 @@ 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 c792a8ba6..1c00c0b76 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -257,56 +257,6 @@ 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 7623e5572..7c0030acb 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -261,59 +261,6 @@ export const translations: Translations<"en"> = { "can't be empty": "No puede estar vacío", "new directory": "Nueva carpeta" }, - 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 0a7c28139..60b66da09 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -254,60 +254,6 @@ 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 14381da89..67e211f79 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -262,61 +262,6 @@ 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 a058a1e18..840f905eb 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -217,10 +217,9 @@ export const translations: Translations<"it"> = { la nostra documentazione - .   - - Configurare il tuo Vault CLI locale - . + {". \u00a0"} + Configurare il tuo Vault CLI locale + {"."} ) }, @@ -259,60 +258,6 @@ 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 dfec4cd7f..b603a7c7f 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -259,58 +259,6 @@ 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 ecf71d587..19ebe4d95 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -257,59 +257,6 @@ export const translations: Translations<"no"> = { "can't be empty": "Kan ikke være tom", "new directory": "Ny katalog" }, - 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 a0feddc8e..1b130a764 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -234,55 +234,6 @@ 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 1537967ca..a7c8305ed 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -20,7 +20,6 @@ export type ComponentKey = | import("ui/pages/s3Explorer/dialogs/ConfirmOverwriteDialog").I18n | import("ui/pages/s3Explorer/dialogs/CreateOrRenameBookmarkDialog").I18n | import("ui/pages/s3Explorer/dialogs/DirectoryCreationDialog").I18n - | import("ui/pages/s3Explorer/dialogs/CreateOrUpdateProfileDialog").I18n | import("ui/App/Header/Header").I18n | import("ui/App/LeftBar").I18n | import("ui/App/AutoLogoutCountdown").I18n diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 77bbdf564..9500a65bd 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -85,7 +85,7 @@ function PageComponent() { evtConfirmCustomS3ConfigDeletionDialogOpen: new Evt(), evtCreateOrRenameBookmarkDialogOpen: new Evt(), evtDirectoryCreationDialogOpen: new Evt(), - evtCreateOrUpdateProfileDialogOpen: new Evt(), + evtS3ProfileDialogOpen: new Evt(), evtMaybeAcknowledgeConfigVolatilityDialogOpen: new Evt() }) ); @@ -248,9 +248,7 @@ function PageComponent() { return ( - - - ); -}); - -const useButtonsStyles = tss - .withName(`${symToStr({ CreateOrUpdateProfileDialog })}${symToStr({ Buttons })}`) - .create({}); - -const Body = memo(() => { - const { isReady, formValues, formValuesErrors, urlStylesExamples } = useCoreState( - "s3ProfilesCreationUiController", - "main" - ); - - const { - functions: { s3ProfilesCreationUiController } - } = getCoreSync(); - - const { classes, css, theme } = useBodyStyles(); - - const { t } = useTranslation({ CreateOrUpdateProfileDialog }); - - if (!isReady) { - return null; - } - - return ( - <> - - - s3ProfilesCreationUiController.changeValue({ - key: "profileName", - value - }) - } - /> - - s3ProfilesCreationUiController.changeValue({ - key: "url", - value - }) - } - /> - - s3ProfilesCreationUiController.changeValue({ - key: "region", - value - }) - } - /> - - {t("url style")} - - {t("url style helper text")} - - - s3ProfilesCreationUiController.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")} - - - - - s3ProfilesCreationUiController.changeValue({ - key: "isAnonymous", - value: isChecked - }) - } - /> - } - label={t("isAnonymous switch label")} - /> - - {t("isAnonymous switch helper text")} - - {!formValues.isAnonymous && ( - <> - - s3ProfilesCreationUiController.changeValue({ - key: "accessKeyId", - value: value || undefined - }) - } - /> - - s3ProfilesCreationUiController.changeValue({ - key: "secretAccessKey", - value: value || undefined - }) - } - /> - - s3ProfilesCreationUiController.changeValue({ - key: "sessionToken", - value: value || undefined - }) - } - /> - - )} - - - ); -}); - -const useBodyStyles = tss - .withName(`${symToStr({ CreateOrUpdateProfileDialog })}${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" - | "profile name already used" - | "not a valid access key id" - | "url textField label" - | "url textField helper text" - | "region textField label" - | "region textField helper text" - | "account credentials" - | "profileName textField label" - | "profileName 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; - } ->()({ CreateOrUpdateProfileDialog }); -export type I18n = typeof i18n; diff --git a/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx index 9eefee82a..06fd5bf99 100644 --- a/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx +++ b/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx @@ -2,10 +2,7 @@ import { ConfirmCustomS3ConfigDeletionDialog, type Props as ConfirmCustomS3ConfigDeletionDialogProps } from "./ConfirmCustomS3ConfigDeletionDialog"; -import { - CreateOrUpdateProfileDialog, - type CreateOrUpdateProfileDialogProps -} from "./CreateOrUpdateProfileDialog"; +import { S3ProfileDialog, type S3ProfileDialogProps } from "./S3ProfileDialog"; import { ConfirmBucketCreationAttemptDialog, type ConfirmBucketCreationAttemptDialogProps @@ -29,7 +26,7 @@ import { export type S3ExplorerDialogsProps = { evtConfirmCustomS3ConfigDeletionDialogOpen: ConfirmCustomS3ConfigDeletionDialogProps["evtOpen"]; - evtCreateOrUpdateProfileDialogOpen: CreateOrUpdateProfileDialogProps["evtOpen"]; + evtS3ProfileDialogOpen: S3ProfileDialogProps["evtOpen"]; evtConfirmBucketCreationAttemptDialogOpen: ConfirmBucketCreationAttemptDialogProps["evtOpen"]; evtConfirmOverwriteDialogOpen: ConfirmOverwriteDialogProps["evtOpen"]; evtCreateOrRenameBookmarkDialogOpen: CreateOrRenameBookmarkDialogProps["evtOpen"]; @@ -40,7 +37,7 @@ export type S3ExplorerDialogsProps = { export function S3ExplorerDialogs(props: S3ExplorerDialogsProps) { const { evtConfirmCustomS3ConfigDeletionDialogOpen, - evtCreateOrUpdateProfileDialogOpen, + evtS3ProfileDialogOpen, evtConfirmBucketCreationAttemptDialogOpen, evtConfirmOverwriteDialogOpen, evtCreateOrRenameBookmarkDialogOpen, @@ -53,7 +50,7 @@ export function S3ExplorerDialogs(props: S3ExplorerDialogsProps) { - + diff --git a/web/src/ui/pages/s3Explorer/ProfileModal.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx similarity index 93% rename from web/src/ui/pages/s3Explorer/ProfileModal.tsx rename to web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx index 9bcc26ab1..e2b1bb201 100644 --- a/web/src/ui/pages/s3Explorer/ProfileModal.tsx +++ b/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx @@ -1,6 +1,6 @@ -import { S3ProfileDetails as S3ProfileDetails_headless } from "ui/shared/codex/s3ProfileModal/S3ProfileDetails"; -import { S3ProfileForm as S3ProfileForm_headless } from "ui/shared/codex/s3ProfileModal/S3ProfileForm"; -import { type NonPostableEvt } from "evt"; +import { S3ProfileDetails as S3ProfileDetails_headless } from "ui/shared/codex/s3ProfileDialog/S3ProfileDetails"; +import { S3ProfileForm as S3ProfileForm_headless } from "ui/shared/codex/s3ProfileDialog/S3ProfileForm"; +import { type Evt } from "evt"; import { useEvt } from "evt/hooks/useEvt"; import { useState, type ReactNode } from "react"; import { withLoader } from "ui/tools/withLoader"; @@ -10,16 +10,18 @@ import { Text } from "onyxia-ui/Text"; import { IconButton } from "onyxia-ui/IconButton"; import { getIconUrlByName } from "lazy-icons"; -type Props = { - evtOpen: NonPostableEvt<"detail" | "create">; +export type S3ProfileDialogOpenView = "detail" | "create"; + +export type S3ProfileDialogProps = { + evtOpen: Evt; }; -export function S3ProfileModal(props: Props) { +type ActiveView = S3ProfileDialogOpenView | "edit"; + +export function S3ProfileDialog(props: S3ProfileDialogProps) { const { evtOpen } = props; - const [activeView, setActiveView] = useState< - "detail" | "create" | "edit" | undefined - >(undefined); + const [activeView, setActiveView] = useState(undefined); useEvt( ctx => { @@ -33,7 +35,7 @@ export function S3ProfileModal(props: Props) { } return ( - { switch (activeView) { case "detail": @@ -65,18 +67,18 @@ export function S3ProfileModal(props: Props) { ); } })()} - + ); } -function SideModal(props: { +function SideDialog(props: { title: ReactNode; onClose: () => void; children: ReactNode; }) { const { children, title, onClose } = props; - const { classes } = useStyles_SideModal(); + const { classes } = useStyles_SideDialog(); return (
@@ -90,10 +92,10 @@ function SideModal(props: { ); } -const useStyles_SideModal = tss.withName({ SideModal }).create(({ theme }) => ({ +const useStyles_SideDialog = tss.withName({ SideDialog }).create(({ theme }) => ({ root: { position: "fixed", - with: 600, + width: 600, top: theme.spacing(5), bottom: theme.spacing(5), right: 0, diff --git a/web/src/ui/shared/codex/s3ProfileModal/S3ProfileDetails/S3ProfileDetails.tsx b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.tsx similarity index 95% rename from web/src/ui/shared/codex/s3ProfileModal/S3ProfileDetails/S3ProfileDetails.tsx rename to web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.tsx index 90c6610f5..f5142434d 100644 --- a/web/src/ui/shared/codex/s3ProfileModal/S3ProfileDetails/S3ProfileDetails.tsx +++ b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.tsx @@ -35,6 +35,6 @@ export type Props = { codeSippet: CodeSnippet; }; -export function S3ProfileDetails(props: Props) { +export function S3ProfileDetails(_props: Props) { return null; } diff --git a/web/src/ui/shared/codex/s3ProfileModal/S3ProfileDetails/index.ts b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/index.ts similarity index 100% rename from web/src/ui/shared/codex/s3ProfileModal/S3ProfileDetails/index.ts rename to web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/index.ts diff --git a/web/src/ui/shared/codex/s3ProfileModal/S3ProfileForm/S3ProfileForm.tsx b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileForm/S3ProfileForm.tsx similarity index 96% rename from web/src/ui/shared/codex/s3ProfileModal/S3ProfileForm/S3ProfileForm.tsx rename to web/src/ui/shared/codex/s3ProfileDialog/S3ProfileForm/S3ProfileForm.tsx index aab60230d..7fef6ef18 100644 --- a/web/src/ui/shared/codex/s3ProfileModal/S3ProfileForm/S3ProfileForm.tsx +++ b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileForm/S3ProfileForm.tsx @@ -58,6 +58,6 @@ export type Props = { onCancel: () => void; }; -export function S3ProfileForm(props: Props) { +export function S3ProfileForm(_props: Props) { return null; } diff --git a/web/src/ui/shared/codex/s3ProfileModal/S3ProfileForm/index.ts b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileForm/index.ts similarity index 100% rename from web/src/ui/shared/codex/s3ProfileModal/S3ProfileForm/index.ts rename to web/src/ui/shared/codex/s3ProfileDialog/S3ProfileForm/index.ts From 9f420b97d7f3a93b169359e5fe5c4565e36e25f0 Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 29 Apr 2026 11:13:20 +0200 Subject: [PATCH 03/14] Refactor S3ProfileDialog types for active view management --- .../ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx index e2b1bb201..facc955da 100644 --- a/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx +++ b/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx @@ -10,18 +10,16 @@ import { Text } from "onyxia-ui/Text"; import { IconButton } from "onyxia-ui/IconButton"; import { getIconUrlByName } from "lazy-icons"; -export type S3ProfileDialogOpenView = "detail" | "create"; - export type S3ProfileDialogProps = { - evtOpen: Evt; + evtOpen: Evt<"detail" | "create">; }; -type ActiveView = S3ProfileDialogOpenView | "edit"; - export function S3ProfileDialog(props: S3ProfileDialogProps) { const { evtOpen } = props; - const [activeView, setActiveView] = useState(undefined); + const [activeView, setActiveView] = useState< + "detail" | "create" | "edit" | undefined + >(undefined); useEvt( ctx => { From 08011929418a11b5165a76d88bd71b948c898de6 Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 29 Apr 2026 11:14:33 +0200 Subject: [PATCH 04/14] Add isEditionOfAnExistingConfig prop to S3ProfileForm for edit mode handling --- web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx | 1 + .../codex/s3ProfileDialog/S3ProfileForm/S3ProfileForm.tsx | 2 ++ 2 files changed, 3 insertions(+) diff --git a/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx index facc955da..2d4fec90b 100644 --- a/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx +++ b/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx @@ -178,6 +178,7 @@ const S3ProfileForm = withLoader<{ return ( diff --git a/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileForm/S3ProfileForm.tsx b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileForm/S3ProfileForm.tsx index 7fef6ef18..e5735ae61 100644 --- a/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileForm/S3ProfileForm.tsx +++ b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileForm/S3ProfileForm.tsx @@ -7,6 +7,8 @@ type ErrorId = export type Props = { className?: string; + isEditionOfAnExistingConfig: boolean; + profileName: { value: string; onChange: (newValue: string) => void; From 8346a177eb47434c27a05eace611301ea74ae4ac Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 29 Apr 2026 11:52:01 +0200 Subject: [PATCH 05/14] Add comment to clarify isEditionOfAnExistingConfig prop usage in S3ProfileForm --- .../shared/codex/s3ProfileDialog/S3ProfileForm/S3ProfileForm.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileForm/S3ProfileForm.tsx b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileForm/S3ProfileForm.tsx index e5735ae61..9086a163a 100644 --- a/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileForm/S3ProfileForm.tsx +++ b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileForm/S3ProfileForm.tsx @@ -7,6 +7,7 @@ type ErrorId = export type Props = { className?: string; + // If true, the submit button should be labeled "Save Change" else "Create Profile" isEditionOfAnExistingConfig: boolean; profileName: { From 31452a4ff125a2dd9752422b8e9df97c6c29fa94 Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 29 Apr 2026 11:59:36 +0200 Subject: [PATCH 06/14] Workable draft of the profile edit side modal --- .../s3Explorer/dialogs/S3ProfileDialog.tsx | 65 +++++++++++++++---- 1 file changed, 54 insertions(+), 11 deletions(-) diff --git a/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx index 2d4fec90b..ac422b7ed 100644 --- a/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx +++ b/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx @@ -9,6 +9,7 @@ import { tss } from "tss"; import { Text } from "onyxia-ui/Text"; import { IconButton } from "onyxia-ui/IconButton"; import { getIconUrlByName } from "lazy-icons"; +import { alpha } from "@mui/material/styles"; export type S3ProfileDialogProps = { evtOpen: Evt<"detail" | "create">; @@ -80,12 +81,26 @@ function SideDialog(props: { return (
-
- {title} - -
+
+
+ + {title} + + +
-
{children}
+
{children}
+
); } @@ -93,21 +108,49 @@ function SideDialog(props: { const useStyles_SideDialog = tss.withName({ SideDialog }).create(({ theme }) => ({ root: { position: "fixed", - width: 600, - top: theme.spacing(5), - bottom: theme.spacing(5), - right: 0, + inset: 0, + zIndex: theme.muiTheme.zIndex.modal, + display: "flex", + justifyContent: "flex-end", + alignItems: "stretch", + padding: `${theme.spacing(5)}px ${theme.spacing(2)}px ${theme.spacing(2)}px`, + boxSizing: "border-box", + backgroundColor: alpha(theme.colors.useCases.surfaces.background, 0.72), + backdropFilter: "blur(1px)" + }, + panel: { + width: 430, + maxWidth: "100%", + minHeight: 0, display: "flex", - flexDirection: "column" + flexDirection: "column", + overflow: "hidden", + borderRadius: 8, + border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + backgroundColor: theme.colors.useCases.surfaces.surface1, + boxShadow: theme.shadows[4] }, headingWrapper: { display: "flex", + alignItems: "center", justifyContent: "space-between", + gap: theme.spacing(2), + padding: `${theme.spacing(3)}px ${theme.spacing(3)}px ${theme.spacing(2)}px`, borderBottom: `1px solid ${theme.colors.useCases.typography.textPrimary}` }, + title: { + minWidth: 0, + color: theme.colors.useCases.typography.textPrimary + }, + closeButton: { + flex: "none" + }, childrenWrapper: { flex: 1, - overflow: "auto" + minHeight: 0, + overflow: "auto", + padding: `${theme.spacing(2.5)}px ${theme.spacing(3)}px ${theme.spacing(3)}px`, + boxSizing: "border-box" } })); From dc0c2eee1c7bb2cf3c88679f5b95ab879601b082 Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 29 Apr 2026 14:10:23 +0200 Subject: [PATCH 07/14] Minimal S3ProfileDetail component --- .../s3Explorer/dialogs/S3ProfileDialog.tsx | 3 +- .../S3ProfileDetails/S3ProfileDetails.spec.md | 34 ++ .../S3ProfileDetails.stories.tsx | 201 +++++++ .../S3ProfileDetails/S3ProfileDetails.tsx | 527 +++++++++++++++++- .../CodeTextEditor/CodeTextEditor.tsx | 16 +- .../LegacyModeCodeTextEditor.tsx | 49 ++ 6 files changed, 825 insertions(+), 5 deletions(-) create mode 100644 web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.spec.md create mode 100644 web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.stories.tsx create mode 100644 web/src/ui/shared/textEditor/CodeTextEditor/LegacyModeCodeTextEditor.tsx diff --git a/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx index ac422b7ed..800e72adf 100644 --- a/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx +++ b/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx @@ -198,7 +198,8 @@ const S3ProfileDetails = withLoader<{ } availableTechnologies={mainView.availableTechnologies} technology={mainView.technology} - codeSippet={mainView.codeSnippet} + onTechnologyChange={s3ProfilesDetailsUiController.changeTechnology} + codeSnippet={mainView.codeSnippet} /> ); } diff --git a/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.spec.md b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.spec.md new file mode 100644 index 000000000..cd1782a90 --- /dev/null +++ b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.spec.md @@ -0,0 +1,34 @@ +# S3ProfileDetails + +`S3ProfileDetails` is a controlled, presentational component for inspecting one S3 profile and copying the values needed to use it outside the S3 explorer. + +## Responsibilities + +- Display the currently selected S3 profile name in a selector. +- Let the user switch to another available profile through `onSelectedProfileChange`. +- Let the user start profile creation through `onCreateNewProfile`. +- Expose the edit action when available, and render it disabled when the selected profile is read-only. +- Display connection fields for the endpoint URL and the default region when a default region exists. +- Display access credentials when provided: + - access key ID, + - secret access key, + - optional session token, + - optional expiration and renewal action. +- Keep sensitive credential values visually elided in the middle while copying the full value. +- Provide copy-to-clipboard buttons with explicit tooltips and copied feedback. +- Display a generated setup snippet for the selected technology. +- Let the user switch snippet technology through `onTechnologyChange`. +- Render snippets with `CodeTextEditor` using the language that matches the selected technology. + +## Non-Responsibilities + +- It does not fetch profiles, credentials, or snippets. +- It does not own the selected profile or selected technology state. +- It does not create, edit, renew, or persist profiles itself. +- It does not decide which snippets are available; that comes from the controller. + +## Props Contract + +The parent must provide at least one profile name and keep `profileName` synchronized with `availableProfileNames`. `technology` must be one of `availableTechnologies`, and `codeSnippet` must correspond to the selected profile and technology. + +Credential copy buttons always copy the raw credential value even though the rendered text is shortened. diff --git a/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.stories.tsx b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.stories.tsx new file mode 100644 index 000000000..af9a77110 --- /dev/null +++ b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.stories.tsx @@ -0,0 +1,201 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { action } from "@storybook/addon-actions"; +import { useEffect, useMemo, useState } from "react"; +import { + getCodeSnippet, + technologies, + type Technology +} from "core/usecases/s3ProfilesDetailsUiController/decoupledLogic/codeSnippets"; +import { S3ProfileDetails, type Props } from "./S3ProfileDetails"; + +type ProfileFixture = { + profileName: string; + endpointUrl: string; + defaultRegion: string | undefined; + isReadonly: boolean; + accessCredentials: Props["accessCredentials"]; +}; + +const profileFixtures: ProfileFixture[] = [ + { + profileName: "S3 Default Profile", + endpointUrl: "https://s3.eu-west-1.amazonaws.com", + defaultRegion: "eu-west-1", + isReadonly: true, + accessCredentials: { + expirationTime: Date.now() + 1000 * 60 * 60 * 4, + accessKeyId: "ASIAIOSFODNN7EXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + sessionToken: "IQoJb3JpZ2luX2VjEOr//////////wEaCXVzLWVhc3QtMSJHMEUCIDbN", + areTokensBeingRenewed: false, + onRenewToken: action("renewToken") + } + }, + { + profileName: "Personal Sandbox", + endpointUrl: "https://minio.lab.example.net", + defaultRegion: undefined, + isReadonly: false, + accessCredentials: { + expirationTime: undefined, + accessKeyId: "sandbox-access-key-01j6vh2k1c2mf18q5m8q9q", + secretAccessKey: "sandbox-secret-key-nhWps2sKgwX9Tn0bkWmju90Ax7g3sU2dbD", + sessionToken: undefined, + areTokensBeingRenewed: false, + onRenewToken: undefined + } + }, + { + profileName: "Public Open Data", + endpointUrl: "https://object.data.gouv.fr", + defaultRegion: "us-east-1", + isReadonly: true, + accessCredentials: undefined + } +]; + +const meta = { + title: "Shared/Codex/S3ProfileDialog/S3ProfileDetails", + component: S3ProfileDetails, + decorators: [ + Story => ( +
+ +
+ ) + ] +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +function StatefulS3ProfileDetails(props: Props & { profiles: ProfileFixture[] }) { + const { profiles, ...initialProps } = props; + const [profileName, setProfileName] = useState(initialProps.profileName); + const [technology, setTechnology] = useState(initialProps.technology); + + useEffect(() => { + setProfileName(initialProps.profileName); + }, [initialProps.profileName]); + + useEffect(() => { + setTechnology(initialProps.technology); + }, [initialProps.technology]); + + const selectedProfile = getSelectedProfile({ profiles, profileName }); + + const codeSnippet = useMemo( + () => + getStoryCodeSnippet({ + profile: selectedProfile, + technology + }), + [selectedProfile, technology] + ); + + return ( + profile.profileName)} + profileName={selectedProfile.profileName} + onSelectedProfileChange={params => { + initialProps.onSelectedProfileChange(params); + setProfileName(params.profileName); + }} + endpointUrl={selectedProfile.endpointUrl} + defaultRegion={selectedProfile.defaultRegion} + accessCredentials={selectedProfile.accessCredentials} + onEdit={selectedProfile.isReadonly ? undefined : initialProps.onEdit} + technology={technology} + onTechnologyChange={params => { + initialProps.onTechnologyChange(params); + setTechnology(params.technology); + }} + codeSnippet={codeSnippet} + /> + ); +} + +function getSelectedProfile(params: { + profiles: ProfileFixture[]; + profileName: string; +}): ProfileFixture { + const { profiles, profileName } = params; + const selectedProfile = + profiles.find(profile => profile.profileName === profileName) ?? profiles[0]; + + if (selectedProfile === undefined) { + throw new Error("S3ProfileDetails story fixtures must not be empty"); + } + + return selectedProfile; +} + +function getStoryCodeSnippet(params: { + profile: ProfileFixture; + technology: Technology; +}) { + const { profile, technology } = params; + + return getCodeSnippet({ + technology, + profileName: profile.profileName, + endpointUrl: profile.endpointUrl, + defaultRegion: profile.defaultRegion, + accessCredentials: + profile.accessCredentials === undefined + ? undefined + : { + accessKeyId: profile.accessCredentials.accessKeyId, + secretAccessKey: profile.accessCredentials.secretAccessKey, + sessionToken: profile.accessCredentials.sessionToken + } + }); +} + +const defaultProfile = profileFixtures[0]; +const defaultTechnology = technologies[0]; + +if (defaultProfile === undefined || defaultTechnology === undefined) { + throw new Error("S3ProfileDetails story fixtures must not be empty"); +} + +const baseArgs: Props = { + availableProfileNames: profileFixtures.map(profile => profile.profileName), + profileName: defaultProfile.profileName, + onSelectedProfileChange: action("selectedProfileChange"), + onCreateNewProfile: action("createNewProfile"), + onEdit: action("edit"), + endpointUrl: defaultProfile.endpointUrl, + defaultRegion: defaultProfile.defaultRegion, + accessCredentials: defaultProfile.accessCredentials, + availableTechnologies: technologies, + technology: defaultTechnology, + onTechnologyChange: action("technologyChange"), + codeSnippet: getStoryCodeSnippet({ + profile: defaultProfile, + technology: defaultTechnology + }) +}; + +export const Default: Story = { + args: baseArgs, + render: args => +}; + +export const EditableProfile: Story = { + args: { + ...baseArgs, + profileName: "Personal Sandbox" + }, + render: args => +}; + +export const AnonymousProfile: Story = { + args: { + ...baseArgs, + profileName: "Public Open Data" + }, + render: args => +}; diff --git a/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.tsx b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.tsx index f5142434d..3ebf1b93b 100644 --- a/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.tsx +++ b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.tsx @@ -2,6 +2,23 @@ import type { Technology, CodeSnippet } from "core/usecases/s3ProfilesDetailsUiController/decoupledLogic/codeSnippets"; +import { useId, type ReactNode } from "react"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import MenuItem from "@mui/material/MenuItem"; +import Select from "@mui/material/Select"; +import { alpha } from "@mui/material/styles"; +import { Button } from "onyxia-ui/Button"; +import { CopyToClipboardIconButton } from "onyxia-ui/CopyToClipboardIconButton"; +import { Icon } from "onyxia-ui/Icon"; +import { Text } from "onyxia-ui/Text"; +import { getIconUrlByName } from "lazy-icons"; +import { tss } from "tss"; +import { + CodeTextEditor, + type CodeTextEditorLanguage +} from "ui/shared/textEditor/CodeTextEditor"; +import { assert } from "tsafe/assert"; export type Props = { className?: string; @@ -32,9 +49,513 @@ export type Props = { availableTechnologies: readonly Technology[]; technology: Technology; - codeSippet: CodeSnippet; + onTechnologyChange: (params: { technology: Technology }) => void; + codeSnippet: CodeSnippet; }; -export function S3ProfileDetails(_props: Props) { - return null; +const createNewProfileSelectValue = "__onyxia_create_new_s3_profile__"; + +export function S3ProfileDetails(props: Props) { + const { + className, + availableProfileNames, + profileName, + onSelectedProfileChange, + onCreateNewProfile, + onEdit, + endpointUrl, + defaultRegion, + accessCredentials, + availableTechnologies, + technology, + onTechnologyChange, + codeSnippet + } = props; + + const profileSelectLabelId = useId(); + const technologySelectLabelId = useId(); + + const { classes, cx } = useStyles(); + + return ( +
+
+ + Profile + + + + +
+ +
+ +
+ + {defaultRegion !== undefined && defaultRegion !== "" && ( + + )} +
+
+ +
+ + + {accessCredentials !== undefined && ( + <> +
+ + Environment variable{" "} + + AWS_ACCESS_KEY_ID + + + } + /> + + Environment variable{" "} + + AWS_SECRET_ACCESS_KEY + + + } + /> + {accessCredentials.sessionToken !== undefined && ( + + Environment variable{" "} + + AWS_SESSION_TOKEN + + + } + /> + )} +
+ +
+ + {accessCredentials.expirationTime === undefined + ? "No expiration time is advertised for these credentials." + : `Expires ${formatExpirationTime( + accessCredentials.expirationTime + )}.`} + + {accessCredentials.onRenewToken !== undefined && ( + + )} +
+ + )} +
+ +
+ + +
+ + Technology + + + +
+ + {codeSnippet.fileBasename} + + +
+
+ + } + /> +
+
+ ); +} + +function SectionHeading(props: { title: string; subtitle: ReactNode }) { + const { title, subtitle } = props; + const { classes } = useStyles_SectionHeading(); + + return ( +
+ + {title} + + + {subtitle} + +
+ ); } + +function CopyableField(props: { + label: string; + copyLabel: string; + value: string; + helperText?: ReactNode; + isSensitive?: boolean; +}) { + const { label, copyLabel, value, helperText, isSensitive = false } = props; + + const { classes, cx } = useStyles_CopyableField(); + const displayedValue = isSensitive ? getMiddleEllipsis(value) : value; + + return ( +
+
+ + {label} + +
+ + {displayedValue} + + +
+
+ {helperText !== undefined && ( + + {helperText} + + )} +
+ ); +} + +function getMiddleEllipsis(value: string): string { + const length = 28; + + if (value.length <= length) { + return value; + } + + const prefixLength = 12; + const suffixLength = 8; + + return `${value.slice(0, prefixLength)}...${value.slice(-suffixLength)}`; +} + +function formatExpirationTime(expirationTime: number): string { + if (!Number.isFinite(expirationTime)) { + return "never"; + } + + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short" + }).format(new Date(expirationTime)); +} + +function isTechnology( + value: string, + availableTechnologies: readonly Technology[] +): value is Technology { + return (availableTechnologies as readonly string[]).includes(value); +} + +function getCodeTextEditorLanguage(technology: Technology): CodeTextEditorLanguage { + switch (technology) { + case "AWS CLI / shared profile": + return "shell"; + case "Python (boto3)": + case "Python (s3fs)": + case "Python (polars)": + case "Python (pyarrow)": + return "python"; + case "DuckDB": + return "SQL"; + case "R (arrow)": + case "R (paws)": + return "R"; + case "rclone": + return "properties"; + } +} + +const useStyles = tss.withName({ S3ProfileDetails }).create(({ theme }) => ({ + root: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(4), + minWidth: 0 + }, + profileBar: { + display: "flex", + alignItems: "flex-end", + gap: theme.spacing(2), + minWidth: 0 + }, + profileSelectControl: { + flex: 1, + minWidth: 0 + }, + editButton: { + flex: "none" + }, + createProfileMenuItem: { + display: "inline-flex", + alignItems: "center", + gap: theme.spacing(1) + }, + section: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(2), + minWidth: 0 + }, + fields: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(1.5), + minWidth: 0 + }, + envVarName: { + color: theme.colors.useCases.typography.textFocus, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + fontSize: "0.92em" + }, + credentialsFooter: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing(2), + flexWrap: "wrap", + minWidth: 0 + }, + credentialsExpiration: { + color: theme.colors.useCases.typography.textSecondary, + minWidth: 0 + }, + snippetToolbar: { + display: "flex", + alignItems: "flex-end", + justifyContent: "space-between", + gap: theme.spacing(2), + flexWrap: "wrap", + minWidth: 0 + }, + technologySelectControl: { + flex: "1 1 230px", + minWidth: 0 + }, + snippetActions: { + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: theme.spacing(1), + minWidth: 0, + flex: "1 1 120px" + }, + fileBasename: { + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + color: theme.colors.useCases.typography.textSecondary, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + fontSize: 13 + }, + codeEditor: { + border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + boxSizing: "border-box" + }, + editorFallback: { + height: 220, + borderRadius: theme.spacing(1), + backgroundColor: theme.colors.useCases.surfaces.surface2 + } +})); + +const useStyles_SectionHeading = tss.withName({ SectionHeading }).create(({ theme }) => ({ + root: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.75), + minWidth: 0 + }, + title: { + color: theme.colors.useCases.typography.textPrimary + }, + subtitle: { + color: theme.colors.useCases.typography.textSecondary + } +})); + +const useStyles_CopyableField = tss.withName({ CopyableField }).create(({ theme }) => ({ + root: { + padding: `${theme.spacing(1.5)}px ${theme.spacing(2)}px`, + borderRadius: 8, + backgroundColor: alpha(theme.colors.useCases.surfaces.surface2, 0.72), + minWidth: 0 + }, + header: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing(2), + flexWrap: "wrap", + minWidth: 0 + }, + label: { + color: theme.colors.useCases.typography.textPrimary, + minWidth: 0 + }, + valueAndCopy: { + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + gap: theme.spacing(1), + flex: "1 1 180px", + minWidth: 0 + }, + value: { + minWidth: 0, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + color: theme.colors.useCases.typography.textPrimary, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + fontSize: 13, + textAlign: "right" + }, + sensitiveValue: { + letterSpacing: 0 + }, + helperText: { + marginTop: theme.spacing(0.75), + color: theme.colors.useCases.typography.textSecondary + } +})); diff --git a/web/src/ui/shared/textEditor/CodeTextEditor/CodeTextEditor.tsx b/web/src/ui/shared/textEditor/CodeTextEditor/CodeTextEditor.tsx index 5d8daeb93..c3b5be643 100644 --- a/web/src/ui/shared/textEditor/CodeTextEditor/CodeTextEditor.tsx +++ b/web/src/ui/shared/textEditor/CodeTextEditor/CodeTextEditor.tsx @@ -3,6 +3,15 @@ import { assert, type Equals } from "tsafe/assert"; import { Suspense, lazy } from "react"; const ShellCodeTextEditor = lazy(() => import("./ShellCodeTextEditor")); const JsonCodeTextEditor = lazy(() => import("./JsonCodeTextEditor")); +const LegacyModeCodeTextEditor = lazy(() => import("./LegacyModeCodeTextEditor")); + +export type CodeTextEditorLanguage = + | "shell" + | "JSON" + | "python" + | "R" + | "SQL" + | "properties"; export type Props = { className?: string; @@ -12,7 +21,7 @@ export type Props = { onChange: ((newValue: string) => void) | undefined; fallback?: JSX.Element; children?: ReactNode; - language: "shell" | "JSON"; + language: CodeTextEditorLanguage; }; { @@ -33,6 +42,11 @@ export function CodeTextEditor(props: Props) { return ; case "JSON": return ; + case "python": + case "R": + case "SQL": + case "properties": + return ; } })()} diff --git a/web/src/ui/shared/textEditor/CodeTextEditor/LegacyModeCodeTextEditor.tsx b/web/src/ui/shared/textEditor/CodeTextEditor/LegacyModeCodeTextEditor.tsx new file mode 100644 index 000000000..9e8989f62 --- /dev/null +++ b/web/src/ui/shared/textEditor/CodeTextEditor/LegacyModeCodeTextEditor.tsx @@ -0,0 +1,49 @@ +import type { ReactNode } from "react"; +import { python } from "@codemirror/legacy-modes/mode/python"; +import { r } from "@codemirror/legacy-modes/mode/r"; +import { properties } from "@codemirror/legacy-modes/mode/properties"; +import { standardSQL } from "@codemirror/legacy-modes/mode/sql"; +import { StreamLanguage } from "@codemirror/language"; +import { assert, type Equals } from "tsafe/assert"; +import { TextEditor } from "../TextEditor"; +import type { CodeTextEditorLanguage } from "./CodeTextEditor"; + +type Language = "python" | "R" | "SQL" | "properties"; + +export type Props = { + className?: string; + id?: string; + maxHeight?: number; + value: string; + onChange: ((newValue: string) => void) | undefined; + fallback?: JSX.Element; + children?: ReactNode; + language: Language; +}; + +{ + type Props_Expected = Omit & + Pick; + + assert>; +} + +assert(); + +const streamLanguageByLanguage = { + python, + R: r, + SQL: standardSQL, + properties +} satisfies Record[0]>; + +export default function LegacyModeCodeTextEditor(props: Props) { + const { language, ...rest } = props; + + return ( + + ); +} From be2dd0ad80358027ee0f56f73a36e587496b4c15 Mon Sep 17 00:00:00 2001 From: garronej Date: Wed, 29 Apr 2026 14:41:50 +0200 Subject: [PATCH 08/14] Making sure the tokens are updated when changing profile --- web/src/core/usecases/s3ProfilesDetailsUiController/thunks.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/core/usecases/s3ProfilesDetailsUiController/thunks.ts b/web/src/core/usecases/s3ProfilesDetailsUiController/thunks.ts index cfd4a6f10..1400d04d3 100644 --- a/web/src/core/usecases/s3ProfilesDetailsUiController/thunks.ts +++ b/web/src/core/usecases/s3ProfilesDetailsUiController/thunks.ts @@ -89,5 +89,7 @@ export const thunks = { ); assert(doesProfileExist); + + await dispatch(thunks.load()); } } satisfies Thunks; From 9278b8461981d53f5964ab94f9e883ba1a79cc01 Mon Sep 17 00:00:00 2001 From: garronej Date: Thu, 30 Apr 2026 20:32:12 +0200 Subject: [PATCH 09/14] Implement read of public/private policy in the core --- web/src/core/adapters/s3Client/s3Client.ts | 56 ++++- web/src/core/ports/S3Client.ts | 6 + .../decoupledLogic/bucketPolicy.test.ts | 207 ++++++++++++++++ .../decoupledLogic/bucketPolicy.ts | 234 ++++++++++++++++++ .../computeUploadStatusAtPrefix.ts | 2 + .../usecases/s3ExplorerUiController/evt.ts | 36 ++- .../s3ExplorerUiController/selectors.ts | 26 +- .../usecases/s3ExplorerUiController/state.ts | 24 +- .../usecases/s3ExplorerUiController/thunks.ts | 50 +++- 9 files changed, 625 insertions(+), 16 deletions(-) create mode 100644 web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicy.test.ts create mode 100644 web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicy.ts diff --git a/web/src/core/adapters/s3Client/s3Client.ts b/web/src/core/adapters/s3Client/s3Client.ts index 420916414..6ef8980c4 100644 --- a/web/src/core/adapters/s3Client/s3Client.ts +++ b/web/src/core/adapters/s3Client/s3Client.ts @@ -3,7 +3,7 @@ import { getNewlyRequestedOrCachedTokenFactory, createSessionStorageTokenPersistence } from "core/tools/getNewlyRequestedOrCachedToken"; -import { assert, is, type Equals } from "tsafe/assert"; +import { assert, is, typeGuard, type Equals } from "tsafe"; import type { Oidc } from "core/ports/Oidc"; import { getS3UriKey, parseS3Uri } from "core/tools/S3Uri"; import { exclude, id } from "tsafe"; @@ -219,6 +219,60 @@ export function createS3Client( })(); const s3Client: S3Client = { + getBucketPolicies: async ({ bucket }) => { + const { getAwsS3Client } = await prApi; + + const { awsS3Client } = await getAwsS3Client(); + + const { GetBucketPolicyCommand, S3ServiceException } = await import( + "@aws-sdk/client-s3" + ); + + let policy: string | undefined; + + try { + ({ Policy: policy } = await awsS3Client.send( + new GetBucketPolicyCommand({ + Bucket: bucket + }) + )); + } catch (error) { + if (error instanceof S3ServiceException) { + const httpStatusCode = error.$metadata?.httpStatusCode; + + if ( + httpStatusCode === 403 || + httpStatusCode === 404 || + httpStatusCode === 405 || + httpStatusCode === 501 || + error.name === "NoSuchBucketPolicy" || + error.name === "NotImplemented" || + error.name === "NotSupported" + ) { + return undefined; + } + } + + throw error; + } + + if (policy === undefined) { + return undefined; + } + + const bucketPolicies: unknown = JSON.parse(policy); + + assert( + typeGuard( + bucketPolicies, + typeof bucketPolicies === "object" && + bucketPolicies !== null && + !Array.isArray(bucketPolicies) + ) + ); + + return bucketPolicies; + }, getToken: async ({ doForceRenew }) => { const { getNewlyRequestedOrCachedToken, clearCachedToken } = await prApi; diff --git a/web/src/core/ports/S3Client.ts b/web/src/core/ports/S3Client.ts index 6621b335d..e4183f43c 100644 --- a/web/src/core/ports/S3Client.ts +++ b/web/src/core/ports/S3Client.ts @@ -57,6 +57,10 @@ export type S3Client = { errorMessage: string; } >; + + getBucketPolicies: (params: { + bucket: string; + }) => Promise; }; export namespace S3Client { @@ -86,4 +90,6 @@ export namespace S3Client { errorCase: "access denied" | "no such bucket"; }; } + + export type BucketPolicies = Record; } diff --git a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicy.test.ts b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicy.test.ts new file mode 100644 index 000000000..50ed5354f --- /dev/null +++ b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicy.test.ts @@ -0,0 +1,207 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { symToStr } from "tsafe/symToStr"; +import type { S3Client } from "core/ports/S3Client"; +import { parseS3Uri } from "core/tools/S3Uri"; +import { getIsPublic } from "./bucketPolicy"; + +const bucketPolicies = { + Version: "2012-10-17", + Statement: [ + { + Sid: "PublicReadWholeBucket", + Effect: "Allow", + Principal: "*", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::my-bucket/*" + }, + { + Sid: "PrivateDenySecretPrefix", + Effect: "Deny", + Principal: "*", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::my-bucket/images/private/*" + }, + { + Sid: "PublicReadSingleFileInOtherBucket", + Effect: "Allow", + Principal: "*", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::other-bucket/public/logo.png" + } + ] +} satisfies S3Client.BucketPolicies; + +describe(symToStr({ getIsPublic }), () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns true when a public GetObject statement matches an object", () => { + expect( + getIsPublic({ + bucketPolicies, + s3Uri: parseS3Uri({ + value: "s3://my-bucket/images/logo.png", + delimiter: "/" + }) + }) + ).toBe(true); + }); + + it("returns true when a public wildcard statement covers a prefix", () => { + expect( + getIsPublic({ + bucketPolicies, + s3Uri: parseS3Uri({ + value: "s3://my-bucket/images/", + delimiter: "/" + }) + }) + ).toBe(true); + }); + + it("returns false when a public deny statement also matches", () => { + expect( + getIsPublic({ + bucketPolicies, + s3Uri: parseS3Uri({ + value: "s3://my-bucket/images/private/secret.txt", + delimiter: "/" + }) + }) + ).toBe(false); + }); + + it("does not match policies from another bucket", () => { + expect( + getIsPublic({ + bucketPolicies, + s3Uri: parseS3Uri({ + value: "s3://not-my-bucket/images/logo.png", + delimiter: "/" + }) + }) + ).toBe(false); + }); + + it("accepts a single Statement object and AWS principal shorthand", () => { + expect( + getIsPublic({ + bucketPolicies: { + Statement: { + Effect: "Allow", + Principal: { AWS: "*" }, + Action: ["s3:PutObject", "s3:Get*"], + Resource: "arn:aws:s3:::my-bucket/public/logo.png" + } + }, + s3Uri: parseS3Uri({ + value: "s3://my-bucket/public/logo.png", + delimiter: "/" + }) + }) + ).toBe(true); + }); + + it("does not grant public status for authenticated principals", () => { + expect( + getIsPublic({ + bucketPolicies: { + Statement: [ + { + Effect: "Allow", + Principal: { + AWS: "arn:aws:iam::123456789012:user/alice" + }, + Action: "s3:GetObject", + Resource: "arn:aws:s3:::my-bucket/uploads/*" + } + ] + }, + s3Uri: parseS3Uri({ + value: "s3://my-bucket/uploads/report.csv", + delimiter: "/" + }) + }) + ).toBe(false); + }); + + it("evaluates supported aws:CurrentTime date conditions", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-30T00:00:00Z")); + + expect( + getIsPublic({ + bucketPolicies: { + Statement: [ + { + Effect: "Allow", + Principal: "*", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::my-bucket/temporary-public/*", + Condition: { + DateLessThan: { + "aws:CurrentTime": "2026-12-31T23:59:59Z" + } + } + } + ] + }, + s3Uri: parseS3Uri({ + value: "s3://my-bucket/temporary-public/file.txt", + delimiter: "/" + }) + }) + ).toBe(true); + + expect( + getIsPublic({ + bucketPolicies: { + Statement: [ + { + Effect: "Allow", + Principal: "*", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::my-bucket/temporary-public/*", + Condition: { + DateLessThan: { + "aws:CurrentTime": "2026-01-01T00:00:00Z" + } + } + } + ] + }, + s3Uri: parseS3Uri({ + value: "s3://my-bucket/temporary-public/file.txt", + delimiter: "/" + }) + }) + ).toBe(false); + }); + + it("does not treat unsupported allow conditions as public", () => { + expect( + getIsPublic({ + bucketPolicies: { + Statement: [ + { + Effect: "Allow", + Principal: "*", + Action: "s3:GetObject", + Resource: "arn:aws:s3:::my-bucket/ip-restricted/*", + Condition: { + IpAddress: { + "aws:SourceIp": "203.0.113.0/24" + } + } + } + ] + }, + s3Uri: parseS3Uri({ + value: "s3://my-bucket/ip-restricted/file.txt", + delimiter: "/" + }) + }) + ).toBe(false); + }); +}); diff --git a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicy.ts b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicy.ts new file mode 100644 index 000000000..84a7b4ad3 --- /dev/null +++ b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicy.ts @@ -0,0 +1,234 @@ +import { type S3Uri } from "core/tools/S3Uri"; +import type { S3Client } from "core/ports/S3Client"; + +type BucketPolicyStatement = { + Effect?: unknown; + Principal?: unknown; + Action?: unknown; + Resource?: unknown; + Condition?: unknown; +}; + +export function getIsPublic(params: { + s3Uri: S3Uri; + bucketPolicies: S3Client.BucketPolicies; +}): boolean { + const { s3Uri, bucketPolicies } = params; + + const statements = getStatements(bucketPolicies); + + const matchingPublicStatements = statements.filter(statement => + getDoesStatementApplyToPublicGetObject({ statement, s3Uri }) + ); + + if ( + matchingPublicStatements.some(statement => + getDoesEffectMatch({ statement, effect: "Deny" }) + ) + ) { + return false; + } + + return matchingPublicStatements.some(statement => + getDoesEffectMatch({ statement, effect: "Allow" }) + ); +} + +function getStatements(bucketPolicies: S3Client.BucketPolicies): BucketPolicyStatement[] { + const { Statement } = bucketPolicies; + + if (isRecord(Statement)) { + return [Statement]; + } + + if (Array.isArray(Statement)) { + return Statement.filter(isRecord); + } + + return []; +} + +function getDoesStatementApplyToPublicGetObject(params: { + statement: BucketPolicyStatement; + s3Uri: S3Uri; +}): boolean { + const { statement, s3Uri } = params; + + if (!getIsPublicPrincipal(statement.Principal)) { + return false; + } + + if (!getDoesActionMatchGetObject(statement.Action)) { + return false; + } + + if (!getDoesResourceMatchS3Uri({ resource: statement.Resource, s3Uri })) { + return false; + } + + return getDoesConditionMatch({ + condition: statement.Condition, + effect: typeof statement.Effect === "string" ? statement.Effect : undefined + }); +} + +function getDoesEffectMatch(params: { + statement: BucketPolicyStatement; + effect: "Allow" | "Deny"; +}): boolean { + const { statement, effect } = params; + + return statement.Effect === effect; +} + +function getIsPublicPrincipal(principal: unknown): boolean { + if (principal === "*") { + return true; + } + + if (!isRecord(principal)) { + return false; + } + + return getValues(principal.AWS).includes("*"); +} + +function getDoesActionMatchGetObject(action: unknown): boolean { + return getValues(action).some(action => { + const pattern = action.toLowerCase(); + + return getWildcardRegExp(pattern).test("s3:getobject"); + }); +} + +function getDoesResourceMatchS3Uri(params: { resource: unknown; s3Uri: S3Uri }): boolean { + const { resource, s3Uri } = params; + + const targetArn = getObjectArn(s3Uri); + + return getValues(resource).some(resource => + getWildcardRegExp(resource).test(targetArn) + ); +} + +function getDoesConditionMatch(params: { + condition: unknown; + effect: string | undefined; +}): boolean { + const { condition, effect } = params; + + if (condition === undefined) { + return true; + } + + if (!isRecord(condition)) { + return effect === "Deny"; + } + + const conditionEntries = Object.entries(condition); + + if (conditionEntries.length === 0) { + return true; + } + + let hasOnlySupportedConditions = true; + + const doesAllSupportedConditionsMatch = conditionEntries.every( + ([operator, conditionValue]) => { + const currentTimeComparison = getCurrentTimeComparison({ + operator, + conditionValue + }); + + if (currentTimeComparison === undefined) { + hasOnlySupportedConditions = false; + return true; + } + + return currentTimeComparison; + } + ); + + if (!hasOnlySupportedConditions) { + return effect === "Deny"; + } + + return doesAllSupportedConditionsMatch; +} + +function getCurrentTimeComparison(params: { + operator: string; + conditionValue: unknown; +}): boolean | undefined { + const { operator, conditionValue } = params; + + if (!isRecord(conditionValue)) { + return undefined; + } + + const currentTimeValues = getValues(conditionValue["aws:CurrentTime"]); + + if (currentTimeValues.length === 0) { + return undefined; + } + + const now = Date.now(); + + const timestamps = currentTimeValues + .map(value => Date.parse(value)) + .filter(timestamp => !Number.isNaN(timestamp)); + + if (timestamps.length !== currentTimeValues.length) { + return undefined; + } + + switch (operator) { + case "DateLessThan": + return timestamps.some(timestamp => now < timestamp); + case "DateLessThanEquals": + return timestamps.some(timestamp => now <= timestamp); + case "DateGreaterThan": + return timestamps.some(timestamp => now > timestamp); + case "DateGreaterThanEquals": + return timestamps.some(timestamp => now >= timestamp); + default: + return undefined; + } +} + +function getObjectArn(s3Uri: S3Uri): string { + return `arn:aws:s3:::${s3Uri.bucket}/${s3Uri.keySegments.join(s3Uri.delimiter)}${ + s3Uri.isDelimiterTerminated ? s3Uri.delimiter : "" + }`; +} + +function getValues(valueOrValues: unknown): string[] { + if (typeof valueOrValues === "string") { + return [valueOrValues]; + } + + if (!Array.isArray(valueOrValues)) { + return []; + } + + return valueOrValues.filter((value): value is string => typeof value === "string"); +} + +function getWildcardRegExp(pattern: string): RegExp { + return new RegExp(`^${[...pattern].map(escapeWildcardChar).join("")}$`); +} + +function escapeWildcardChar(char: string): string { + switch (char) { + case "*": + return ".*"; + case "?": + return "."; + default: + return char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts index 0c1f42416..3c5b328bd 100644 --- a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts +++ b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts @@ -42,6 +42,7 @@ export function computeUploadStatusAtPrefix(params: { s3Uri: upload.s3Uri, uploadProgressPercent: upload.completionPercent, isDeleting: false, + isPublic: false, size: upload.size, lastModified: upload.uploadStartTime }); @@ -69,6 +70,7 @@ export function computeUploadStatusAtPrefix(params: { displayName, s3Uri: s3Uri_newItem, isDeleting: false, + isPublic: false, uploadProgressPercent: NaN }); } diff --git a/web/src/core/usecases/s3ExplorerUiController/evt.ts b/web/src/core/usecases/s3ExplorerUiController/evt.ts index da5e14f0a..d660c5fe3 100644 --- a/web/src/core/usecases/s3ExplorerUiController/evt.ts +++ b/web/src/core/usecases/s3ExplorerUiController/evt.ts @@ -8,7 +8,7 @@ import { AccessError } from "clean-architecture"; import * as s3ProfilesManagement from "core/usecases/s3ProfilesManagement"; import { assert } from "tsafe"; import { actions } from "./state"; -import { thunks, evtAskOverwriteConfirmation } from "./thunks"; +import { thunks, privateThunks, evtAskOverwriteConfirmation } from "./thunks"; import { getIsInside } from "core/tools/S3Uri"; import * as dataExplorer from "core/usecases/dataExplorer"; import { stringifyS3Uri } from "core/tools/S3Uri"; @@ -134,6 +134,40 @@ export const createEvt = (({ evtAction, dispatch, getState }) => { ); }); + evtAction + .pipe( + action => + action.usecaseName === name && + action.actionName === "listingCompletedSuccessfully" + ) + .attach(() => { + const bucket = (() => { + const s3Uri = privateSelectors.s3Uri(getState()); + + assert(s3Uri !== undefined); + + return s3Uri.bucket; + })(); + + const profileName = (() => { + const profile = + s3ProfilesManagement.selectors.ambientS3Profile(getState()); + assert(profile !== undefined); + + return profile.profileName; + })(); + + { + const wrap = privateSelectors.bucketPolicyByBucket(getState())[bucket]; + + if (wrap !== undefined && wrap.profileName === profileName) { + return; + } + } + + dispatch(privateThunks.updateBucketPolicy({ bucket, profileName })); + }); + evtAction.$attach( action => { if (action.usecaseName !== name) { diff --git a/web/src/core/usecases/s3ExplorerUiController/selectors.ts b/web/src/core/usecases/s3ExplorerUiController/selectors.ts index db11d3b80..b83ab12eb 100644 --- a/web/src/core/usecases/s3ExplorerUiController/selectors.ts +++ b/web/src/core/usecases/s3ExplorerUiController/selectors.ts @@ -8,6 +8,7 @@ import { id } from "tsafe/id"; import { same } from "evt/tools/inDepth/same"; import { computeUploadStatusAtPrefix } from "./decoupledLogic/computeUploadStatusAtPrefix"; import { name, type State } from "./state"; +import { getIsPublic } from "./decoupledLogic/bucketPolicy"; export type RouteParams = { profile?: string; @@ -102,6 +103,7 @@ export namespace MainView { uploadProgressPercent: number | undefined; isDeleting: boolean; displayName: string; + isPublic: boolean; }; export type PrefixSegment = Common & { @@ -268,10 +270,12 @@ const items = createSelector( listedPrefix_state, uploads_profile, deletions_profile, + createSelector(state, state => state.bucketPolicyByBucket), ( listedPrefix_state, uploads_profile, - deletions_profile + deletions_profile, + bucketPolicyByBucket ): MainView.Item[] | undefined => { if (listedPrefix_state === undefined) { return undefined; @@ -286,8 +290,19 @@ const items = createSelector( uploads: uploads_profile }); + const bucketPolicies = + bucketPolicyByBucket[listedPrefix_state.current.s3Uri.bucket]?.bucketPolicies; + const items_actual: MainView.Item[] = listedPrefix_state.current.items.map( item => { + const isPublic = + bucketPolicies === undefined + ? false + : getIsPublic({ + bucketPolicies, + s3Uri: item.s3Uri + }); + switch (item.type) { case "object": return id({ @@ -303,7 +318,8 @@ const items = createSelector( uploadProgressPercent: undefined, isDeleting: false, lastModified: item.lastModified, - size: item.size + size: item.size, + isPublic }); case "prefix": return id({ @@ -317,7 +333,8 @@ const items = createSelector( })(), s3Uri: item.s3Uri, uploadProgressPercent: undefined, - isDeleting: false + isDeleting: false, + isPublic }); } } @@ -695,5 +712,6 @@ export const privateSelectors = { doesListedPrefixHaveFinishedUpload, listedPrefix_state, isFullyQualifiedDataFileUri, - uploads: createSelector(state, state => state.uploads) + uploads: createSelector(state, state => state.uploads), + bucketPolicyByBucket: createSelector(state, state => state.bucketPolicyByBucket) }; diff --git a/web/src/core/usecases/s3ExplorerUiController/state.ts b/web/src/core/usecases/s3ExplorerUiController/state.ts index 949550216..9e2cad442 100644 --- a/web/src/core/usecases/s3ExplorerUiController/state.ts +++ b/web/src/core/usecases/s3ExplorerUiController/state.ts @@ -2,6 +2,7 @@ import { assert, id } from "tsafe"; import { createUsecaseActions } from "clean-architecture"; import { type S3Uri, stringifyS3Uri, getIsInside } from "core/tools/S3Uri"; import { same } from "evt/tools/inDepth/same"; +import type { S3Client } from "core/ports/S3Client"; //All explorer paths are expected to be absolute (start with /) @@ -10,6 +11,10 @@ export type State = { uploads: State.Upload[]; deletions: State.Deletion[]; listedPrefixByProfile: Record; + bucketPolicyByBucket: Record< + string, + { bucketPolicies: S3Client.BucketPolicies | undefined; profileName: string } + >; }; export namespace State { @@ -80,9 +85,26 @@ export const { reducer, actions } = createUsecaseActions({ commandLogsEntries: [], uploads: [], deletions: [], - listedPrefixByProfile: {} + listedPrefixByProfile: {}, + bucketPolicyByBucket: {} }), reducers: { + bucketPoliciesUpdated: ( + state, + { + payload + }: { + payload: { + bucket: string; + profileName: string; + bucketPolicies: S3Client.BucketPolicies | undefined; + }; + } + ) => { + const { bucket, profileName, bucketPolicies } = payload; + + state.bucketPolicyByBucket[bucket] = { profileName, bucketPolicies }; + }, putObjectStarted: ( state, { diff --git a/web/src/core/usecases/s3ExplorerUiController/thunks.ts b/web/src/core/usecases/s3ExplorerUiController/thunks.ts index 455449d68..76f454f79 100644 --- a/web/src/core/usecases/s3ExplorerUiController/thunks.ts +++ b/web/src/core/usecases/s3ExplorerUiController/thunks.ts @@ -4,7 +4,6 @@ import * as s3ProfilesManagement from "core/usecases/s3ProfilesManagement"; import type { RouteParams } from "./selectors"; import { assert, type Equals } from "tsafe/assert"; import { actions } from "./state"; -import * as s3ProfileManagement from "core/usecases/s3ProfilesManagement"; import { formatDuration } from "core/tools/timeFormat/formatDuration"; import { id } from "tsafe/id"; import { type S3Uri, parseS3Uri, stringifyS3Uri, getIsInside } from "core/tools/S3Uri"; @@ -93,7 +92,7 @@ export const thunks = { } const profileName_current = - s3ProfileManagement.selectors.ambientS3Profile( + s3ProfilesManagement.selectors.ambientS3Profile( getState() )?.profileName; @@ -164,7 +163,7 @@ export const thunks = { const [dispatch, getState] = args; const s3Profile = - s3ProfileManagement.selectors.ambientS3Profile(getState()); + s3ProfilesManagement.selectors.ambientS3Profile(getState()); assert(s3Profile !== undefined); @@ -188,7 +187,7 @@ export const thunks = { const [dispatch, getState] = args; - const s3Profile = s3ProfileManagement.selectors.ambientS3Profile(getState()); + const s3Profile = s3ProfilesManagement.selectors.ambientS3Profile(getState()); assert(s3Profile !== undefined); @@ -226,7 +225,7 @@ export const thunks = { const s3Uri = privateSelectors.s3Uri(getState()); const s3Profile = - s3ProfileManagement.selectors.ambientS3Profile(getState()); + s3ProfilesManagement.selectors.ambientS3Profile(getState()); assert(s3Profile !== undefined); assert(s3Uri !== undefined); @@ -582,7 +581,7 @@ export const thunks = { assert(profileName !== undefined); const s3Client = await dispatch( - s3ProfileManagement.protectedThunks.getS3Client({ profileName }) + s3ProfilesManagement.protectedThunks.getS3Client({ profileName }) ); const crawl = async (params: { @@ -657,7 +656,7 @@ export const thunks = { assert(profileName !== undefined); const s3Client = await dispatch( - s3ProfileManagement.protectedThunks.getS3Client({ profileName }) + s3ProfilesManagement.protectedThunks.getS3Client({ profileName }) ); const cmdId = Date.now(); @@ -706,7 +705,7 @@ export const privateThunks = { { const doesExist = await (async () => { const s3Client = await dispatch( - s3ProfileManagement.protectedThunks.getS3Client({ profileName }) + s3ProfilesManagement.protectedThunks.getS3Client({ profileName }) ); const resultOfListObject = await s3Client.listObjects({ s3Uri }); @@ -795,7 +794,7 @@ export const privateThunks = { }); const s3Client = await dispatch( - s3ProfileManagement.protectedThunks.getS3Client({ profileName }) + s3ProfilesManagement.protectedThunks.getS3Client({ profileName }) ); const resultOfPutObject = await s3Client.putObject({ @@ -858,5 +857,38 @@ export const privateThunks = { ); break; } + }, + updateBucketPolicy: + (params: { bucket: string; profileName: string }) => + async (...args) => { + const [dispatch] = args; + + const { bucket, profileName } = params; + + dispatch( + actions.bucketPoliciesUpdated({ + bucket, + profileName, + bucketPolicies: undefined + }) + ); + + const s3Client = await dispatch( + s3ProfilesManagement.protectedThunks.getS3Client({ profileName }) + ); + + const bucketPolicies = await s3Client.getBucketPolicies({ bucket }); + + if (bucketPolicies === undefined) { + return; + } + + dispatch( + actions.bucketPoliciesUpdated({ + bucket, + profileName, + bucketPolicies + }) + ); } } satisfies Thunks; From ac5548fcfbb18e43617edfe57db2c7c529b79445 Mon Sep 17 00:00:00 2001 From: garronej Date: Thu, 30 Apr 2026 21:23:12 +0200 Subject: [PATCH 10/14] Do not sign url for public files --- web/src/core/adapters/s3Client/s3Client.ts | 24 ++++ web/src/core/ports/S3Client.ts | 2 + web/src/core/usecases/k8sCodeSnippets.ts | 2 +- .../usecases/s3ExplorerUiController/thunks.ts | 108 ++++++++++++------ web/src/ui/pages/s3Explorer/Page.tsx | 2 +- .../S3ExplorerMainView.spec.md | 2 +- .../S3ExplorerMainView/S3ExplorerMainView.tsx | 15 ++- 7 files changed, 116 insertions(+), 39 deletions(-) diff --git a/web/src/core/adapters/s3Client/s3Client.ts b/web/src/core/adapters/s3Client/s3Client.ts index 6ef8980c4..82479a69c 100644 --- a/web/src/core/adapters/s3Client/s3Client.ts +++ b/web/src/core/adapters/s3Client/s3Client.ts @@ -219,6 +219,30 @@ export function createS3Client( })(); const s3Client: S3Client = { + getUnsignedDownloadUrl: ({ s3Uri }) => { + const url = new URL(params.url); + const pathname = url.pathname.endsWith("/") + ? url.pathname.slice(0, -1) + : url.pathname; + const encodedKey = getS3UriKey(s3Uri) + .split("/") + .map(encodeURIComponent) + .join("/"); + + if (params.pathStyleAccess) { + url.pathname = `${pathname}/${encodeURIComponent( + s3Uri.bucket + )}/${encodedKey}`; + } else { + url.hostname = `${s3Uri.bucket}.${url.hostname}`; + url.pathname = `${pathname}/${encodedKey}`; + } + + url.search = ""; + url.hash = ""; + + return url.href; + }, getBucketPolicies: async ({ bucket }) => { const { getAwsS3Client } = await prApi; diff --git a/web/src/core/ports/S3Client.ts b/web/src/core/ports/S3Client.ts index e4183f43c..faa39858e 100644 --- a/web/src/core/ports/S3Client.ts +++ b/web/src/core/ports/S3Client.ts @@ -36,6 +36,8 @@ export type S3Client = { validityDurationSecond: number; }) => Promise; + getUnsignedDownloadUrl: (params: { s3Uri: S3Uri.NonTerminatedByDelimiter }) => string; + getObjectContent: (params: { s3Uri: S3Uri.NonTerminatedByDelimiter; range: `bytes=0-${number}` | undefined; diff --git a/web/src/core/usecases/k8sCodeSnippets.ts b/web/src/core/usecases/k8sCodeSnippets.ts index 0b57282ea..5632dbe0d 100644 --- a/web/src/core/usecases/k8sCodeSnippets.ts +++ b/web/src/core/usecases/k8sCodeSnippets.ts @@ -106,7 +106,7 @@ export const thunks = { async (...args) => { const [dispatch, getState, rootContext] = args; - if (getState().s3CodeSnippets.isRefreshing) { + if (getState()[name].isRefreshing) { return; } diff --git a/web/src/core/usecases/s3ExplorerUiController/thunks.ts b/web/src/core/usecases/s3ExplorerUiController/thunks.ts index 76f454f79..eaa16b417 100644 --- a/web/src/core/usecases/s3ExplorerUiController/thunks.ts +++ b/web/src/core/usecases/s3ExplorerUiController/thunks.ts @@ -13,6 +13,7 @@ import type { State } from "./state"; import { Evt } from "evt"; import { onlyIfChanged } from "evt/operators"; import { Deferred } from "evt/tools/Deferred"; +import { getIsPublic } from "./decoupledLogic/bucketPolicy"; const { waitForDebounce: waitForDebounce_notifyRouteParamsExternallyUpdated } = createWaitForDebounce({ @@ -641,52 +642,46 @@ export const thunks = { }) ); }, - getPreSignedUrl: + getDirectDownloadUrl: (params: { s3Uri: S3Uri.NonTerminatedByDelimiter; - validityDurationSecond?: number; + validityDurationSecond_ifNotPublic: number; }) => - async (...args): Promise => { - const { s3Uri, validityDurationSecond = 3_600 } = params; + async (...args) => { + const { s3Uri, validityDurationSecond_ifNotPublic } = params; const [dispatch, getState] = args; - const profileName = privateSelectors.profileName(getState()); + const bucketPolicies = + privateSelectors.bucketPolicyByBucket(getState())[s3Uri.bucket] + ?.bucketPolicies; - assert(profileName !== undefined); + const isPublic = + bucketPolicies === undefined + ? false + : getIsPublic({ + s3Uri, + bucketPolicies + }); - const s3Client = await dispatch( - s3ProfilesManagement.protectedThunks.getS3Client({ profileName }) - ); + if (isPublic) { + const profileName = privateSelectors.profileName(getState()); - const cmdId = Date.now(); - dispatch( - actions.commandLogIssued({ - cmdId, - cmd: `aws s3 presign ${stringifyS3Uri(s3Uri)} --expires-in ${validityDurationSecond}` - }) - ); + assert(profileName !== undefined); - const downloadUrl = await s3Client.generateSignedDownloadUrl({ - s3Uri, - validityDurationSecond - }); + const s3Client = await dispatch( + s3ProfilesManagement.protectedThunks.getS3Client({ profileName }) + ); - dispatch( - actions.commandLogResponseReceived({ - cmdId, - resp: [ - `URL: ${downloadUrl.split("?")[0]}`, - `Expire: ${formatDuration({ - durationSeconds: validityDurationSecond, - t: undefined - })}`, - `Share: ${downloadUrl}` - ].join("\n") + return s3Client.getUnsignedDownloadUrl({ s3Uri }); + } + + return dispatch( + privateThunks.generateSignedDownloadUrl({ + s3Uri, + validityDurationSecond: validityDurationSecond_ifNotPublic }) ); - - return downloadUrl; } } satisfies Thunks; @@ -890,5 +885,52 @@ export const privateThunks = { bucketPolicies }) ); + }, + generateSignedDownloadUrl: + (params: { + s3Uri: S3Uri.NonTerminatedByDelimiter; + validityDurationSecond: number; + }) => + async (...args): Promise => { + const { s3Uri, validityDurationSecond } = params; + + const [dispatch, getState] = args; + + const profileName = privateSelectors.profileName(getState()); + + assert(profileName !== undefined); + + const s3Client = await dispatch( + s3ProfilesManagement.protectedThunks.getS3Client({ profileName }) + ); + + const cmdId = Date.now(); + dispatch( + actions.commandLogIssued({ + cmdId, + cmd: `aws s3 presign ${stringifyS3Uri(s3Uri)} --expires-in ${validityDurationSecond}` + }) + ); + + const downloadUrl = await s3Client.generateSignedDownloadUrl({ + s3Uri, + validityDurationSecond + }); + + dispatch( + actions.commandLogResponseReceived({ + cmdId, + resp: [ + `URL: ${downloadUrl.split("?")[0]}`, + `Expire: ${formatDuration({ + durationSeconds: validityDurationSecond, + t: undefined + })}`, + `Share: ${downloadUrl}` + ].join("\n") + }) + ); + + return downloadUrl; } } satisfies Thunks; diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 9500a65bd..af6a5eeef 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -448,7 +448,7 @@ function PageComponent() { } onDelete={s3ExplorerUiController.delete} getDirectDownloadUrl={ - s3ExplorerUiController.getPreSignedUrl + s3ExplorerUiController.getDirectDownloadUrl } evtAction={evtS3ExplorerMainViewAction} isUploadDisabled={mainView.isUploadButtonDisabled} diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md index 268a8b804..d4bfcd738 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md @@ -52,7 +52,7 @@ export type S3ExplorerMainViewProps = { getDirectDownloadUrl: (params: { s3Uri: S3Uri.NonTerminatedByDelimiter; - validityDurationSecond?: number; + validityDurationSecond_ifNotPublic: number; }) => Promise; }; ``` diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx index 9f30b14e7..66d75108a 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx @@ -62,7 +62,7 @@ export type S3ExplorerMainViewProps = { getDirectDownloadUrl: (params: { s3Uri: S3Uri.NonTerminatedByDelimiter; - validityDurationSecond?: number; + validityDurationSecond_ifNotPublic: number; }) => Promise; evtAction: NonPostableEvt<"CHOSE FILES TO UPLOAD">; @@ -127,6 +127,11 @@ type DataTransferItemWithWebkitGetAsEntry = DataTransferItem & { webkitGetAsEntry?: () => FileSystemEntryLike | null; }; +const directDownloadUrlValidityDurationSecond = { + download: 30, + shareableLink: 24 * 60 * 60 +} as const; + type FileSystemEntryLike = { readonly isFile: boolean; readonly isDirectory: boolean; @@ -1350,7 +1355,9 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { try { const url = await getDirectDownloadUrl({ - s3Uri: item.s3Uri + s3Uri: item.s3Uri, + validityDurationSecond_ifNotPublic: + directDownloadUrlValidityDurationSecond.shareableLink }); if (shareRequestIdRef.current !== requestId) { @@ -1405,7 +1412,9 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { downloadableObjects.map(async item => { try { const url = await getDirectDownloadUrl({ - s3Uri: item.s3Uri + s3Uri: item.s3Uri, + validityDurationSecond_ifNotPublic: + directDownloadUrlValidityDurationSecond.download }); window.open(url, "_blank", "noopener,noreferrer"); From 8a5f5fd5233ef6891bfedc92e6eeb49d0b0c062a Mon Sep 17 00:00:00 2001 From: garronej Date: Thu, 30 Apr 2026 22:59:39 +0200 Subject: [PATCH 11/14] Implement infra for changing s3Uri public/private policy --- web/src/core/adapters/s3Client/s3Client.ts | 172 +++++++++++++++++- web/src/core/ports/S3Client.ts | 5 + .../usecases/s3ExplorerUiController/evt.ts | 18 +- .../s3ExplorerUiController/selectors.ts | 23 ++- .../usecases/s3ExplorerUiController/state.ts | 3 +- .../usecases/s3ExplorerUiController/thunks.ts | 49 +++++ web/src/ui/pages/s3Explorer/Page.tsx | 8 + .../s3Explorer/dialogs/DisplayErrorDialog.tsx | 45 +++++ .../s3Explorer/dialogs/S3ExplorerDialogs.tsx | 4 + 9 files changed, 321 insertions(+), 6 deletions(-) create mode 100644 web/src/ui/pages/s3Explorer/dialogs/DisplayErrorDialog.tsx diff --git a/web/src/core/adapters/s3Client/s3Client.ts b/web/src/core/adapters/s3Client/s3Client.ts index 82479a69c..a54a81f80 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, typeGuard, type Equals } from "tsafe"; import type { Oidc } from "core/ports/Oidc"; -import { getS3UriKey, parseS3Uri } from "core/tools/S3Uri"; +import { getS3UriKey, parseS3Uri, type S3Uri } from "core/tools/S3Uri"; import { exclude, id } from "tsafe"; import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; import type { OidcParams_Partial } from "core/ports/OnyxiaApi"; @@ -614,7 +614,175 @@ export function createS3Client( } return { isSuccess: true }; - } + }, + setS3UriPublicPrivatePolicy: (() => { + type BucketPolicyStatement = Record; + + function isRecord(value: unknown): value is Record { + return ( + typeof value === "object" && value !== null && !Array.isArray(value) + ); + } + + function getPublicReadStatementSid(resourceArn: string): string { + return `OnyxiaPublicRead${fnv1aHashToHex(resourceArn)}`; + } + + function getPublicReadResourceArn(s3Uri: S3Uri): string { + const key = getS3UriKey(s3Uri); + + if (key === "") { + return `arn:aws:s3:::${s3Uri.bucket}/*`; + } + + return `arn:aws:s3:::${s3Uri.bucket}/${key}${s3Uri.isDelimiterTerminated ? "*" : ""}`; + } + + function createPublicReadStatement(params: { + resourceArn: string; + }): BucketPolicyStatement { + const { resourceArn } = params; + + return { + Sid: getPublicReadStatementSid(resourceArn), + Effect: "Allow", + Principal: "*", + Action: "s3:GetObject", + Resource: resourceArn + }; + } + + function getStatements(bucketPolicies: S3Client.BucketPolicies): unknown[] { + const { Statement } = bucketPolicies; + + if (Array.isArray(Statement)) { + return Statement; + } + + if (Statement === undefined) { + return []; + } + + return [Statement]; + } + + function removeManagedPublicReadStatement(params: { + statements: unknown[]; + resourceArn: string; + }): unknown[] { + const { statements, resourceArn } = params; + + const sid = getPublicReadStatementSid(resourceArn); + + return statements.filter(statement => { + if (!isRecord(statement)) { + return true; + } + + return statement.Sid !== sid || statement.Resource !== resourceArn; + }); + } + + return async ({ s3Uri, policy }) => { + const { getAwsS3Client } = await prApi; + + const { awsS3Client } = await getAwsS3Client(); + + const { + GetBucketPolicyCommand, + PutBucketPolicyCommand, + DeleteBucketPolicyCommand, + S3ServiceException + } = await import("@aws-sdk/client-s3"); + + const Bucket = s3Uri.bucket; + const resourceArn = getPublicReadResourceArn(s3Uri); + + let bucketPolicies: S3Client.BucketPolicies | undefined = undefined; + + try { + const { Policy } = await awsS3Client.send( + new GetBucketPolicyCommand({ + Bucket + }) + ); + + if (Policy !== undefined) { + const parsedPolicy: unknown = JSON.parse(Policy); + + assert( + typeGuard( + parsedPolicy, + isRecord(parsedPolicy) + ) + ); + + bucketPolicies = parsedPolicy; + } + } catch (error) { + if ( + error instanceof S3ServiceException && + (error.$metadata?.httpStatusCode === 404 || + error.name === "NoSuchBucketPolicy") + ) { + bucketPolicies = undefined; + } else { + assert(is(error)); + + return { + isSuccess: false, + errorMessage: error.message + }; + } + } + + const basePolicy = + bucketPolicies ?? + id({ + Version: "2012-10-17" + }); + + const statements = removeManagedPublicReadStatement({ + statements: getStatements(basePolicy), + resourceArn + }); + + if (policy === "public") { + statements.push(createPublicReadStatement({ resourceArn })); + } + + try { + if (statements.length === 0) { + if (bucketPolicies !== undefined) { + await awsS3Client.send( + new DeleteBucketPolicyCommand({ + Bucket + }) + ); + } + } else { + await awsS3Client.send( + new PutBucketPolicyCommand({ + Bucket, + Policy: JSON.stringify({ + ...basePolicy, + Statement: statements + }) + }) + ); + } + } catch (error) { + assert(is(error)); + + return { + isSuccess: false, + errorMessage: error.message + }; + } + + return { isSuccess: true }; + }; + })() }; return s3Client; diff --git a/web/src/core/ports/S3Client.ts b/web/src/core/ports/S3Client.ts index faa39858e..27702c0a7 100644 --- a/web/src/core/ports/S3Client.ts +++ b/web/src/core/ports/S3Client.ts @@ -63,6 +63,11 @@ export type S3Client = { getBucketPolicies: (params: { bucket: string; }) => Promise; + + setS3UriPublicPrivatePolicy: (params: { + s3Uri: S3Uri; + policy: "public" | "private"; + }) => Promise<{ isSuccess: true } | { isSuccess: false; errorMessage: string }>; }; export namespace S3Client { diff --git a/web/src/core/usecases/s3ExplorerUiController/evt.ts b/web/src/core/usecases/s3ExplorerUiController/evt.ts index d660c5fe3..bab338928 100644 --- a/web/src/core/usecases/s3ExplorerUiController/evt.ts +++ b/web/src/core/usecases/s3ExplorerUiController/evt.ts @@ -8,7 +8,12 @@ import { AccessError } from "clean-architecture"; import * as s3ProfilesManagement from "core/usecases/s3ProfilesManagement"; import { assert } from "tsafe"; import { actions } from "./state"; -import { thunks, privateThunks, evtAskOverwriteConfirmation } from "./thunks"; +import { + thunks, + privateThunks, + evtAskOverwriteConfirmation, + evtDisplayError +} from "./thunks"; import { getIsInside } from "core/tools/S3Uri"; import * as dataExplorer from "core/usecases/dataExplorer"; import { stringifyS3Uri } from "core/tools/S3Uri"; @@ -31,8 +36,19 @@ export const createEvt = (({ evtAction, dispatch, getState }) => { s3Uri: S3Uri.NonTerminatedByDelimiter; resolveResponse: (params: { doOverwrite: boolean }) => void; } + | { + action: "display error"; + errorMessage: string; + } >(); + evtDisplayError.attach(({ errorMessage }) => { + evt.post({ + action: "display error", + errorMessage + }); + }); + evtAskOverwriteConfirmation.attach(({ s3Uri, resolveResponse }) => evt.post({ action: "ask overwrite confirmation", diff --git a/web/src/core/usecases/s3ExplorerUiController/selectors.ts b/web/src/core/usecases/s3ExplorerUiController/selectors.ts index b83ab12eb..ad0f88469 100644 --- a/web/src/core/usecases/s3ExplorerUiController/selectors.ts +++ b/web/src/core/usecases/s3ExplorerUiController/selectors.ts @@ -93,6 +93,8 @@ export type MainView = { | undefined; commandLogsEntries: State.CommandLogsEntry[]; + + areBucketPoliciesFeaturesEnabled: boolean; }; export namespace MainView { @@ -631,6 +633,20 @@ const commandLogsEntries = createSelector( (state): MainView["commandLogsEntries"] => state.commandLogsEntries ); +const areBucketPoliciesFeaturesEnabled = createSelector( + createSelector(state, state => state.bucketPolicyByBucket), + s3Uri, + (bucketPolicyByBucket, s3Uri): MainView["areBucketPoliciesFeaturesEnabled"] => { + if (s3Uri === undefined) { + return false; + } + + const bucketPolicies = bucketPolicyByBucket[s3Uri.bucket]?.bucketPolicies; + + return bucketPolicies !== undefined; + } +); + const mainView = createSelector( profileSelect, bookmarks, @@ -643,6 +659,7 @@ const mainView = createSelector( isListing, listedPrefix, commandLogsEntries, + areBucketPoliciesFeaturesEnabled, ( profileSelect, bookmarks, @@ -654,7 +671,8 @@ const mainView = createSelector( fullyQualifiedUri, isListing, listedPrefix, - commandLogsEntries + commandLogsEntries, + areBucketPoliciesFeaturesEnabled ): MainView => ({ profileSelect, bookmarks, @@ -666,7 +684,8 @@ const mainView = createSelector( fullyQualifiedUri, isListing, listedPrefix, - commandLogsEntries + commandLogsEntries, + areBucketPoliciesFeaturesEnabled }) ); diff --git a/web/src/core/usecases/s3ExplorerUiController/state.ts b/web/src/core/usecases/s3ExplorerUiController/state.ts index 9e2cad442..4360327d1 100644 --- a/web/src/core/usecases/s3ExplorerUiController/state.ts +++ b/web/src/core/usecases/s3ExplorerUiController/state.ts @@ -13,7 +13,8 @@ export type State = { listedPrefixByProfile: Record; bucketPolicyByBucket: Record< string, - { bucketPolicies: S3Client.BucketPolicies | undefined; profileName: string } + | { bucketPolicies: S3Client.BucketPolicies | undefined; profileName: string } + | undefined >; }; diff --git a/web/src/core/usecases/s3ExplorerUiController/thunks.ts b/web/src/core/usecases/s3ExplorerUiController/thunks.ts index eaa16b417..04eb4076d 100644 --- a/web/src/core/usecases/s3ExplorerUiController/thunks.ts +++ b/web/src/core/usecases/s3ExplorerUiController/thunks.ts @@ -35,6 +35,10 @@ export const evtAskOverwriteConfirmation = Evt.create<{ resolveResponse: (params: { doOverwrite: boolean }) => void; }>(); +export const evtDisplayError = Evt.create<{ + errorMessage: string; +}>(); + export const thunks = { load: (params: { routeParams: RouteParams }) => @@ -682,6 +686,51 @@ export const thunks = { validityDurationSecond: validityDurationSecond_ifNotPublic }) ); + }, + toggleS3UriPublicPrivatePolicy: + (params: { s3Uri: S3Uri }) => + async (...args) => { + const { s3Uri } = params; + + const [dispatch, getState] = args; + + const profileName = privateSelectors.profileName(getState()); + + assert(profileName !== undefined); + + const bucketPolicies = + privateSelectors.bucketPolicyByBucket(getState())[s3Uri.bucket] + ?.bucketPolicies; + + assert(bucketPolicies !== undefined); + + const isPublic = getIsPublic({ + bucketPolicies, + s3Uri + }); + + const s3Client = await dispatch( + s3ProfilesManagement.protectedThunks.getS3Client({ profileName }) + ); + + const result = await s3Client.setS3UriPublicPrivatePolicy({ + s3Uri, + policy: isPublic ? "private" : "public" + }); + + if (!result.isSuccess) { + evtDisplayError.post({ + errorMessage: result.errorMessage + }); + return; + } + + await dispatch( + privateThunks.updateBucketPolicy({ + bucket: s3Uri.bucket, + profileName + }) + ); } } satisfies Thunks; diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index af6a5eeef..19e257025 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -85,6 +85,7 @@ function PageComponent() { evtConfirmCustomS3ConfigDeletionDialogOpen: new Evt(), evtCreateOrRenameBookmarkDialogOpen: new Evt(), evtDirectoryCreationDialogOpen: new Evt(), + evtDisplayErrorDialogOpen: new Evt(), evtS3ProfileDialogOpen: new Evt(), evtMaybeAcknowledgeConfigVolatilityDialogOpen: new Evt() }) @@ -117,6 +118,13 @@ function PageComponent() { s3Uri, resolveResponse }) + ) + .attach( + data => data.action === "display error", + ({ errorMessage }) => + dialogProps.evtDisplayErrorDialogOpen.post({ + errorMessage + }) ); }, []); diff --git a/web/src/ui/pages/s3Explorer/dialogs/DisplayErrorDialog.tsx b/web/src/ui/pages/s3Explorer/dialogs/DisplayErrorDialog.tsx new file mode 100644 index 000000000..e5eabd4cd --- /dev/null +++ b/web/src/ui/pages/s3Explorer/dialogs/DisplayErrorDialog.tsx @@ -0,0 +1,45 @@ +import { memo, useState } from "react"; +import { Dialog } from "onyxia-ui/Dialog"; +import { Button } from "onyxia-ui/Button"; +import { symToStr } from "tsafe/symToStr"; +import type { Evt, UnpackEvt } from "evt"; +import { useEvt } from "evt/hooks"; + +export type DisplayErrorDialogProps = { + evtOpen: Evt<{ + errorMessage: string; + }>; +}; + +export const DisplayErrorDialog = memo((props: DisplayErrorDialogProps) => { + const { evtOpen } = props; + + const [state, setState] = useState< + UnpackEvt | undefined + >(undefined); + + useEvt( + ctx => { + evtOpen.attach(ctx, eventData => setState(eventData)); + }, + [evtOpen] + ); + + return ( + setState(undefined)}> + Ok + + } + isOpen={state !== undefined} + onClose={() => setState(undefined)} + /> + ); +}); + +DisplayErrorDialog.displayName = symToStr({ + DisplayErrorDialog +}); diff --git a/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx index 06fd5bf99..e65e945d5 100644 --- a/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx +++ b/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx @@ -19,6 +19,7 @@ import { DirectoryCreationDialog, type DirectoryCreationDialogProps } from "./DirectoryCreationDialog"; +import { DisplayErrorDialog, type DisplayErrorDialogProps } from "./DisplayErrorDialog"; import { MaybeAcknowledgeConfigVolatilityDialog, type MaybeAcknowledgeConfigVolatilityDialogProps @@ -31,6 +32,7 @@ export type S3ExplorerDialogsProps = { evtConfirmOverwriteDialogOpen: ConfirmOverwriteDialogProps["evtOpen"]; evtCreateOrRenameBookmarkDialogOpen: CreateOrRenameBookmarkDialogProps["evtOpen"]; evtDirectoryCreationDialogOpen: DirectoryCreationDialogProps["evtOpen"]; + evtDisplayErrorDialogOpen: DisplayErrorDialogProps["evtOpen"]; evtMaybeAcknowledgeConfigVolatilityDialogOpen: MaybeAcknowledgeConfigVolatilityDialogProps["evtOpen"]; }; @@ -42,6 +44,7 @@ export function S3ExplorerDialogs(props: S3ExplorerDialogsProps) { evtConfirmOverwriteDialogOpen, evtCreateOrRenameBookmarkDialogOpen, evtDirectoryCreationDialogOpen, + evtDisplayErrorDialogOpen, evtMaybeAcknowledgeConfigVolatilityDialogOpen } = props; @@ -57,6 +60,7 @@ export function S3ExplorerDialogs(props: S3ExplorerDialogsProps) { + From c79f6197ee96144cd8eb1b42ece2daa7ed09ee73 Mon Sep 17 00:00:00 2001 From: garronej Date: Thu, 30 Apr 2026 23:11:55 +0200 Subject: [PATCH 12/14] Log aws CLI commands for toggleS3UriPublicPrivatePolicy --- .../decoupledLogic/bucketPolicy.ts | 103 +++++++++++++++++- .../usecases/s3ExplorerUiController/thunks.ts | 38 ++++++- 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicy.ts b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicy.ts index 84a7b4ad3..88483d0cb 100644 --- a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicy.ts +++ b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/bucketPolicy.ts @@ -1,14 +1,18 @@ -import { type S3Uri } from "core/tools/S3Uri"; +import { getS3UriKey, type S3Uri } from "core/tools/S3Uri"; import type { S3Client } from "core/ports/S3Client"; +import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; type BucketPolicyStatement = { Effect?: unknown; + Sid?: unknown; Principal?: unknown; Action?: unknown; Resource?: unknown; Condition?: unknown; }; +type ManagedBucketPolicyStatement = Record; + export function getIsPublic(params: { s3Uri: S3Uri; bucketPolicies: S3Client.BucketPolicies; @@ -34,6 +38,44 @@ export function getIsPublic(params: { ); } +export function getSetS3UriPublicPrivatePolicyCommandLog(params: { + s3Uri: S3Uri; + bucketPolicies: S3Client.BucketPolicies; + policy: "public" | "private"; +}): { cmd: string; successResp: string } { + const { s3Uri, bucketPolicies, policy } = params; + + const resourceArn = getPublicReadResourceArn(s3Uri); + + const statements = removeManagedPublicReadStatement({ + statements: getRawStatements(bucketPolicies), + resourceArn + }); + + if (policy === "public") { + statements.push(createPublicReadStatement({ resourceArn })); + } + + if (statements.length === 0) { + return { + cmd: `aws s3api delete-bucket-policy --bucket ${s3Uri.bucket}`, + successResp: "Bucket policy deleted" + }; + } + + const nextBucketPolicies = { + ...bucketPolicies, + Statement: statements + }; + + return { + cmd: `aws s3api put-bucket-policy --bucket ${ + s3Uri.bucket + } --policy '${JSON.stringify(nextBucketPolicies)}'`, + successResp: "Bucket policy updated" + }; +} + function getStatements(bucketPolicies: S3Client.BucketPolicies): BucketPolicyStatement[] { const { Statement } = bucketPolicies; @@ -48,6 +90,65 @@ function getStatements(bucketPolicies: S3Client.BucketPolicies): BucketPolicySta return []; } +function getRawStatements(bucketPolicies: S3Client.BucketPolicies): unknown[] { + const { Statement } = bucketPolicies; + + if (Array.isArray(Statement)) { + return Statement; + } + + if (Statement === undefined) { + return []; + } + + return [Statement]; +} + +function getPublicReadStatementSid(resourceArn: string): string { + return `OnyxiaPublicRead${fnv1aHashToHex(resourceArn)}`; +} + +function getPublicReadResourceArn(s3Uri: S3Uri): string { + const key = getS3UriKey(s3Uri); + + if (key === "") { + return `arn:aws:s3:::${s3Uri.bucket}/*`; + } + + return `arn:aws:s3:::${s3Uri.bucket}/${key}${s3Uri.isDelimiterTerminated ? "*" : ""}`; +} + +function createPublicReadStatement(params: { + resourceArn: string; +}): ManagedBucketPolicyStatement { + const { resourceArn } = params; + + return { + Sid: getPublicReadStatementSid(resourceArn), + Effect: "Allow", + Principal: "*", + Action: "s3:GetObject", + Resource: resourceArn + }; +} + +function removeManagedPublicReadStatement(params: { + statements: unknown[]; + resourceArn: string; +}): unknown[] { + const { statements, resourceArn } = params; + + const sid = getPublicReadStatementSid(resourceArn); + + return statements.filter(statement => { + if (!isRecord(statement)) { + return true; + } + + return statement.Sid !== sid || statement.Resource !== resourceArn; + }); +} + function getDoesStatementApplyToPublicGetObject(params: { statement: BucketPolicyStatement; s3Uri: S3Uri; diff --git a/web/src/core/usecases/s3ExplorerUiController/thunks.ts b/web/src/core/usecases/s3ExplorerUiController/thunks.ts index 04eb4076d..069a3b944 100644 --- a/web/src/core/usecases/s3ExplorerUiController/thunks.ts +++ b/web/src/core/usecases/s3ExplorerUiController/thunks.ts @@ -13,7 +13,10 @@ import type { State } from "./state"; import { Evt } from "evt"; import { onlyIfChanged } from "evt/operators"; import { Deferred } from "evt/tools/Deferred"; -import { getIsPublic } from "./decoupledLogic/bucketPolicy"; +import { + getIsPublic, + getSetS3UriPublicPrivatePolicyCommandLog +} from "./decoupledLogic/bucketPolicy"; const { waitForDebounce: waitForDebounce_notifyRouteParamsExternallyUpdated } = createWaitForDebounce({ @@ -709,22 +712,53 @@ export const thunks = { s3Uri }); + const policy = isPublic ? "private" : "public"; + + const commandLog = getSetS3UriPublicPrivatePolicyCommandLog({ + s3Uri, + bucketPolicies, + policy + }); + + const cmdId = Date.now(); + + dispatch( + actions.commandLogIssued({ + cmdId, + cmd: commandLog.cmd + }) + ); + const s3Client = await dispatch( s3ProfilesManagement.protectedThunks.getS3Client({ profileName }) ); const result = await s3Client.setS3UriPublicPrivatePolicy({ s3Uri, - policy: isPublic ? "private" : "public" + policy }); if (!result.isSuccess) { + dispatch( + actions.commandLogResponseReceived({ + cmdId, + resp: result.errorMessage + }) + ); + evtDisplayError.post({ errorMessage: result.errorMessage }); return; } + dispatch( + actions.commandLogResponseReceived({ + cmdId, + resp: commandLog.successResp + }) + ); + await dispatch( privateThunks.updateBucketPolicy({ bucket: s3Uri.bucket, From 88e2c6f650ec39590fc8052c5118aca508a62fba Mon Sep 17 00:00:00 2001 From: garronej Date: Thu, 30 Apr 2026 23:25:45 +0200 Subject: [PATCH 13/14] Remove S3SelectionActionBar buttons for unimplemented actions. --- .../S3ExplorerMainView.spec.md | 1 - .../S3ExplorerMainView/S3ExplorerMainView.tsx | 3 --- .../S3SelectionActionBar.spec.md | 18 +++++++-------- .../S3SelectionActionBar.stories.tsx | 3 +-- .../S3SelectionActionBar.tsx | 22 ++++++------------- 5 files changed, 16 insertions(+), 31 deletions(-) diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md index d4bfcd738..ad1f734c8 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md @@ -144,7 +144,6 @@ The component renders `S3SelectionActionBar` above the list. - `onCopyS3Uri` - `onDelete` - `onShare` -- `onRename` - `onClear` The visual and interaction behavior of this bar is defined in the dedicated `S3SelectionActionBar` spec and must not be redefined here. diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx index 66d75108a..4cc03633f 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx @@ -1543,9 +1543,6 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { requestShareLink(selectedObjectForShare); }} - onRename={() => { - return; - }} /> )}
diff --git a/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.spec.md b/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.spec.md index 318179a89..4a20643e6 100644 --- a/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.spec.md +++ b/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.spec.md @@ -20,8 +20,11 @@ type S3SelectionActionBarProps = { /** Function to clear the selection and hide the selection action bar */ onClear: () => void; - /** Always visible */ + /** Only visible when selectedS3Uris contains one element + * and this element is of type S3Uri.NonTerminatedByDelimiter */ onDownload: () => void; + + /** Always visible */ onDelete: () => void; /** Only visible when only one item is selected */ @@ -30,9 +33,6 @@ type S3SelectionActionBarProps = { /** Only visible when selectedS3Uris contains one element * and this element is of type S3Uri.NonTerminatedByDelimiter */ onShare: () => void; - - /** Only visible when one element is selected */ - onRename: () => void; }; ``` @@ -89,7 +89,6 @@ Each action is rendered as: These actions must always be rendered: -- Download → `onDownload` - Delete → `onDelete` ### Single selection actions @@ -101,17 +100,17 @@ selectedS3Uris.length === 1; ``` - Copy S3 path → `onCopyS3Uri` -- Rename → `onRename` -### Conditional single selection action +### Object-only single selection actions -The Share action is rendered only when: +The Download and Share actions are rendered only when: ```ts selectedS3Uris.length === 1 && selectedS3Uris[0] is S3Uri.NonTerminatedByDelimiter ``` +- Download → `onDownload` - Share → `onShare` ### Multi-selection @@ -119,14 +118,13 @@ selectedS3Uris.length === 1 When multiple items are selected: - Visible actions: -- Download - Delete Hidden actions: +- Download - Share - Copy S3 path -- Rename # Layout Rules diff --git a/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.stories.tsx b/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.stories.tsx index 3a4c77dd2..ecd46b4b0 100644 --- a/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.stories.tsx +++ b/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.stories.tsx @@ -35,8 +35,7 @@ const baseArgs: S3SelectionActionBarProps = { onDownload: action("download"), onDelete: action("delete"), onCopyS3Uri: action("copyS3Uri"), - onShare: action("share"), - onRename: action("rename") + onShare: action("share") }; export const SingleObject: Story = { diff --git a/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.tsx b/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.tsx index 53cd60bb9..bcd395fec 100644 --- a/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.tsx +++ b/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.tsx @@ -9,16 +9,16 @@ export type S3SelectionActionBarProps = { selectedS3Uris: S3Uri[]; /** Function to clear the selection and hide the selection action bar */ onClear: () => void; - /** Always visible */ + /** Only visible when selectedS3Uris contains one element + * and this element is of type S3Uri.NonTerminatedByDelimiter */ onDownload: () => void; + /** Always visible */ onDelete: () => void; /** Only visible when only one item is selected */ onCopyS3Uri: () => void; /** Only visible when selectedS3Uris contains one element * and this element is of type S3Uri.NonTerminatedByDelimiter */ onShare: () => void; - /** Only visible when one element is selected */ - onRename: () => void; }; type Action = { @@ -37,14 +37,13 @@ export function S3SelectionActionBar(props: S3SelectionActionBarProps) { onDownload, onDelete, onCopyS3Uri, - onShare, - onRename + onShare } = props; const { classes, cx } = useStyles(); const isSingleSelection = selectedS3Uris.length === 1; - const canShare = + const isSingleObjectSelection = isSingleSelection && selectedS3Uris[0].isDelimiterTerminated === false; const actions: Action[] = [ @@ -53,7 +52,7 @@ export function S3SelectionActionBar(props: S3SelectionActionBarProps) { label: "Download", iconName: "FileDownload", onClick: onDownload, - isVisible: true + isVisible: isSingleObjectSelection }, { key: "delete", @@ -74,14 +73,7 @@ export function S3SelectionActionBar(props: S3SelectionActionBarProps) { label: "Share", iconName: "Share", onClick: onShare, - isVisible: canShare - }, - { - key: "rename", - label: "Rename", - iconName: "Edit", - onClick: onRename, - isVisible: isSingleSelection + isVisible: isSingleObjectSelection } ]; From 683c957367ec78474dee5a0084e306d388dbe60b Mon Sep 17 00:00:00 2001 From: garronej Date: Fri, 1 May 2026 16:36:14 +0200 Subject: [PATCH 14/14] Add an overlay icon to show which object and prefixes are public. --- .../S3ExplorerMainView.stories.tsx | 38 ++++++++---- .../S3ExplorerMainView/S3ExplorerMainView.tsx | 62 ++++++++++++++++--- 2 files changed, 79 insertions(+), 21 deletions(-) diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx index d9380cf68..0b76165e1 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx @@ -20,12 +20,14 @@ type MockNode = s3Uri: S3Uri.TerminatedByDelimiter; uploadProgressPercent: number | undefined; isDeleting: boolean; + isPublic: boolean; } | { type: "object"; s3Uri: S3Uri.NonTerminatedByDelimiter; uploadProgressPercent: number | undefined; isDeleting: boolean; + isPublic: boolean; size: number; lastModified: number; }; @@ -72,19 +74,22 @@ const baseNodes: MockNode[] = [ type: "prefix segment", s3Uri: parsePrefixOrThrow("s3://analytics-data/exports/"), uploadProgressPercent: undefined, - isDeleting: false + isDeleting: false, + isPublic: true }, { type: "prefix segment", s3Uri: parsePrefixOrThrow("s3://analytics-data/raw/"), uploadProgressPercent: undefined, - isDeleting: false + isDeleting: false, + isPublic: false }, { type: "prefix segment", s3Uri: parsePrefixOrThrow("s3://analytics-data/tmp/"), uploadProgressPercent: 42, - isDeleting: false + isDeleting: false, + isPublic: false }, { type: "object", @@ -92,7 +97,8 @@ const baseNodes: MockNode[] = [ size: 19_481, lastModified: new Date("2026-03-17T08:45:00Z").getTime(), uploadProgressPercent: undefined, - isDeleting: false + isDeleting: false, + isPublic: true }, { type: "object", @@ -100,7 +106,8 @@ const baseNodes: MockNode[] = [ size: 6_294_321, lastModified: new Date("2026-03-18T15:10:00Z").getTime(), uploadProgressPercent: undefined, - isDeleting: false + isDeleting: false, + isPublic: false }, { type: "object", @@ -108,7 +115,8 @@ const baseNodes: MockNode[] = [ size: 140_000_000, lastModified: new Date("2026-03-19T07:55:00Z").getTime(), uploadProgressPercent: 67, - isDeleting: false + isDeleting: false, + isPublic: false } ]; @@ -117,13 +125,15 @@ const nestedNodes: MockNode[] = [ type: "prefix segment", s3Uri: parsePrefixOrThrow("s3://analytics-data/exports/2024/"), uploadProgressPercent: undefined, - isDeleting: false + isDeleting: false, + isPublic: false }, { type: "prefix segment", s3Uri: parsePrefixOrThrow("s3://analytics-data/exports/2025/"), uploadProgressPercent: undefined, - isDeleting: false + isDeleting: false, + isPublic: true }, { type: "object", @@ -131,7 +141,8 @@ const nestedNodes: MockNode[] = [ size: 11_704, lastModified: new Date("2026-03-19T10:00:00Z").getTime(), uploadProgressPercent: undefined, - isDeleting: false + isDeleting: false, + isPublic: true }, { type: "object", @@ -139,7 +150,8 @@ const nestedNodes: MockNode[] = [ size: 8_122, lastModified: new Date("2026-03-18T09:15:00Z").getTime(), uploadProgressPercent: undefined, - isDeleting: false + isDeleting: false, + isPublic: false } ]; @@ -256,7 +268,8 @@ function StatefulExplorer( isDelimiterTerminated: true }, uploadProgressPercent: undefined, - isDeleting: false + isDeleting: false, + isPublic: false } ]); }} @@ -302,7 +315,8 @@ function StatefulExplorer( : 15_000 + index, lastModified: now + index, uploadProgressPercent: undefined, - isDeleting: false + isDeleting: false, + isPublic: false }; }) ]); diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx index 4cc03633f..f738960e4 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx @@ -14,6 +14,7 @@ import LinearProgress from "@mui/material/LinearProgress"; import CircularProgress from "@mui/material/CircularProgress"; import Checkbox from "@mui/material/Checkbox"; import { alpha } from "@mui/material/styles"; +import PublicIcon from "@mui/icons-material/Public"; import { Evt } from "evt"; import { assert } from "tsafe/assert"; import { tss } from "tss"; @@ -78,6 +79,7 @@ export namespace S3ExplorerMainViewProps { uploadProgressPercent: number | undefined; isDeleting: boolean; displayName: string; + isPublic: boolean; }; export type PrefixSegment = Common & { @@ -806,6 +808,9 @@ function ItemRow(props: ItemRowProps) { !isUploadInProgress && (progressPercent === undefined || progressPercent === 100); const isCopyAvailable = !item.isDeleting; + const itemKindLabel = item.type === "prefix segment" ? "folder" : "object"; + const itemKindLabelCapitalized = item.type === "prefix segment" ? "Folder" : "Object"; + const itemPolicyLabel = item.isPublic ? "public" : "private"; const { classes, cx } = useStyles({ isDragActive: false @@ -845,16 +850,34 @@ function ItemRow(props: ItemRowProps) {
-
- +
+ + {item.isPublic && ( + + + )} - size="small" - /> -
+
+