diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml index fe8901915..7ca0ec818 100644 --- a/helm-chart/Chart.yaml +++ b/helm-chart/Chart.yaml @@ -14,4 +14,4 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. -version: 11.6.0 +version: 11.7.0-rc.3 diff --git a/helm-chart/README.md b/helm-chart/README.md index b46a0491d..6060727da 100644 --- a/helm-chart/README.md +++ b/helm-chart/README.md @@ -22,7 +22,7 @@ ingress: - host: datalab.my-domain.net EOF -helm install onyxia onyxia/onyxia --version "11.6.0" -f onyxia-values.yaml +helm install onyxia onyxia/onyxia --version "11.7.0-rc.3" -f onyxia-values.yaml ``` To expose Onyxia with the Kubernetes Gateway API instead of an Ingress, use an `HTTPRoute`. @@ -40,7 +40,7 @@ httpRoute: - datalab.my-domain.net EOF -helm install onyxia onyxia/onyxia --version "11.6.0" -f onyxia-values.yaml +helm install onyxia onyxia/onyxia --version "11.7.0-rc.3" -f onyxia-values.yaml ``` ### Using the Keycloak Theme (Optional) @@ -62,7 +62,7 @@ extraInitContainers: | args: - -c - | - curl -L -f -S -o /extensions/onyxia.jar https://github.com/InseeFrLab/onyxia/releases/download/v11.6.0/keycloak-theme.jar + curl -L -f -S -o /extensions/onyxia.jar https://github.com/InseeFrLab/onyxia/releases/download/v11.7.0-rc.3/keycloak-theme.jar volumeMounts: - name: extensions mountPath: /extensions @@ -97,7 +97,7 @@ api: ``` - [The REST API (`api`)](https://github.com/InseeFrLab/onyxia-api/blob/v4.12.0/README.md#configuration) -- [The Web Application (`web`)](https://github.com/InseeFrLab/onyxia/blob/web-v5.6.0/web/.env) +- [The Web Application (`web`)](https://github.com/InseeFrLab/onyxia/blob/web-v5.7.0-rc.3/web/.env) Below is a sample `onyxia-values.yaml` file that illustrates where to specify the `api` and `web` configuration parameters. @@ -150,4 +150,4 @@ httpRoute: If you are building your own service catalog for Onyxia ([learn how](https://docs.onyxia.sh/catalog-of-services)). Here are defined the onyxia reserved parameter and the structure of the dynamic context: -[`values.schema.json` `"x-onyxia"` specifications](https://github.com/InseeFrLab/onyxia/blob/web-v5.6.0/web/src/core/ports/OnyxiaApi/XOnyxia.ts) +[`values.schema.json` `"x-onyxia"` specifications](https://github.com/InseeFrLab/onyxia/blob/web-v5.7.0-rc.3/web/src/core/ports/OnyxiaApi/XOnyxia.ts) diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index daacbff24..c00a917af 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -43,7 +43,7 @@ web: replicaCount: 1 image: repository: inseefrlab/onyxia-web - tag: 5.6.0 + tag: 5.7.0-rc.3 pullPolicy: IfNotPresent imagePullSecrets: [] diff --git a/web/package.json b/web/package.json index cc5393882..2820c33c3 100644 --- a/web/package.json +++ b/web/package.json @@ -2,7 +2,7 @@ "name": "onyxia-web", "homepage": "https://onyxia.sh", "type": "module", - "version": "5.6.0", + "version": "5.7.0-rc.3", "license": "MIT", "scripts": { "postinstall": "yarn install-git-hooks && yarn postinstall:code-gen", diff --git a/web/scripts/unyamlify-env-local.ts b/web/scripts/unyamlify-env-local.ts index 959e76723..e0201ff9d 100644 --- a/web/scripts/unyamlify-env-local.ts +++ b/web/scripts/unyamlify-env-local.ts @@ -54,24 +54,28 @@ if (!fs.existsSync(envLocalYamlFilePath)) { ` roleSessionName: ""`, ` }`, ` },`, + ` anonymousProfileName: "anonymous",`, ` bookmarks: [`, ` {`, ` s3Uri: "s3://$1/",`, ` title: "Personal Bucket",`, - ` claimName: "preferred_username"`, + ` claimName: "preferred_username",`, + ` forProfileName: "default"`, ` },`, ` {`, ` s3Uri: "s3://projet-$1/",`, ` title: "Projet $1",`, ` claimName: "groups",`, - ` excludedClaimPattern: "^USER_ONYXIA.*"`, + ` excludedClaimPattern: "^USER_ONYXIA.*",`, + ` forProfileName: "default"`, ` },`, ` {`, ` s3Uri: "s3://donnees-insee/diffusion/",`, ` title: {`, ` fr: "Données de diffusion",`, ` en: "Dissemination Data"`, - ` }`, + ` },`, + ` forProfileName: "default"`, ` }`, ` ]`, ` }`, diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index de4d528fd..9b3b111d3 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -72,13 +72,15 @@ export async function bootstrapCore( const { createOnyxiaApi } = await import("core/adapters/onyxiaApi/mock"); const oidcParams = (() => { - const [entry] = s3Config.entries; + const sts = s3Config.entries + .map(entry => entry.sts) + .find(sts => sts !== undefined); - if (entry === undefined) { + if (sts === undefined) { return undefined; } - const { issuerUri, clientId, ...rest } = entry.sts.oidcParams; + const { issuerUri, clientId, ...rest } = sts.oidcParams; assert(issuerUri !== undefined, "Missing OIDC Issuer URI"); assert(clientId !== undefined, "Missing OIDC Client ID"); @@ -339,13 +341,7 @@ export async function bootstrapCore( await dispatch(usecases.userProfileForm.protectedThunks.initialize()); } - init_s3ProfilesManagement: { - if (!oidc.isUserLoggedIn) { - break init_s3ProfilesManagement; - } - - await dispatch(usecases.s3ProfilesManagement.protectedThunks.initialize()); - } + await dispatch(usecases.s3ProfilesManagement.protectedThunks.initialize()); pluginSystemInitCore({ core, context }); diff --git a/web/src/core/ports/OnyxiaApi/S3Config.ts b/web/src/core/ports/OnyxiaApi/S3Config.ts index 677501b0d..5901bc20a 100644 --- a/web/src/core/ports/OnyxiaApi/S3Config.ts +++ b/web/src/core/ports/OnyxiaApi/S3Config.ts @@ -30,6 +30,7 @@ type S3Config_S3_EnvValue_ExpectedShape = ArrayOrNot<{ >; oidcConfiguration?: Partial; }; + anonymousProfileName?: string; bookmarks?: ({ s3Uri: string; title: LocalizedString; @@ -98,6 +99,7 @@ const zS3Config_S3_EnvValue_ExpectedShape = (() => { oidcConfiguration: zOidcConfiguration.optional() }) .optional(), + anonymousProfileName: z.string().optional(), bookmarks: z.array(zBookmark).optional() }); @@ -122,49 +124,60 @@ export namespace S3Config { url: string; pathStyleAccess: boolean; region: string | undefined; - sts: { - url: string | undefined; - durationSeconds: number | undefined; - roles: Entry.StsRole[]; - oidcParams: OidcParams_Partial; - }; + // NOTE: sts and anonymousProfileName cannot be undefined at the same time. + sts: + | { + url: string | undefined; + durationSeconds: number | undefined; + roles: Entry.StsRole[]; + oidcParams: OidcParams_Partial; + } + | undefined; + anonymousProfileName: string | undefined; bookmarks: Entry.Bookmark[]; }; export namespace Entry { - export type StsRole = { - roleARN: string; - roleSessionName: string; - profileName: string; - } & ( - | { - claimName: undefined; - includedClaimPattern?: never; - excludedClaimPattern?: never; - } - | { - claimName: string; - includedClaimPattern: string | undefined; - excludedClaimPattern: string | undefined; - } - ); + export type StsRole = StsRole.NonTemplated | StsRole.Templated; - export type Bookmark = { - s3UriStr_templated: string; - title: LocalizedString; - forProfileNames: string[]; - } & ( - | { - claimName: undefined; - includedClaimPattern?: never; - excludedClaimPattern?: never; - } - | { - claimName: string; - includedClaimPattern: string | undefined; - excludedClaimPattern: string | undefined; - } - ); + export namespace StsRole { + type Common = { + roleARN: string; + roleSessionName: string; + profileName: string; + }; + + export type NonTemplated = Common & { + isTemplated: false; + }; + + export type Templated = Common & { + isTemplated: true; + claimName: string; + includedClaimPattern: string | undefined; + excludedClaimPattern: string | undefined; + }; + } + + export type Bookmark = Bookmark.NonTemplated | Bookmark.Templated; + export namespace Bookmark { + type Common = { + s3UriStr: string; + title: LocalizedString; + forProfileNames: string[]; + }; + + export type NonTemplated = Common & { + isTemplated: false; + }; + + export type Templated = Common & { + isTemplated: true; + claimName: string; + includedClaimPattern: string | undefined; + excludedClaimPattern: string | undefined; + }; + } } } @@ -198,60 +211,73 @@ export function parseS3ConfigFromEnvValue(params: { envValue: string }): S3Confi const parsedValue_arr = parsedValue instanceof Array ? parsedValue : [parsedValue]; const entries = parsedValue_arr - .filter(s3Config => s3Config.sts !== undefined) + .filter( + s3Config => + s3Config.sts !== undefined || s3Config.anonymousProfileName !== undefined + ) .map((s3Config): S3Config.Entry => { const { sts } = s3Config; - assert(sts !== undefined); - - const roles = Array.isArray(sts.role) ? sts.role : [sts.role]; - return { url: s3Config.URL, pathStyleAccess: s3Config.pathStyleAccess ?? true, region: s3Config.region, - sts: { - url: sts.URL, - durationSeconds: sts.durationSeconds, - roles: roles.map( - (role): S3Config.Entry.StsRole => ({ - roleARN: role.roleARN, - roleSessionName: role.roleSessionName, - profileName: role.profileName, - ...(role.claimName === undefined - ? { claimName: undefined } - : { - claimName: role.claimName, - includedClaimPattern: role.includedClaimPattern, - excludedClaimPattern: role.excludedClaimPattern + sts: + sts === undefined + ? undefined + : { + url: sts.URL, + durationSeconds: sts.durationSeconds, + roles: (Array.isArray(sts.role) + ? sts.role + : [sts.role] + ).map( + (role): S3Config.Entry.StsRole => ({ + roleARN: role.roleARN, + roleSessionName: role.roleSessionName, + profileName: role.profileName, + ...(role.claimName === undefined + ? { isTemplated: false } + : { + isTemplated: true, + claimName: role.claimName, + includedClaimPattern: + role.includedClaimPattern, + excludedClaimPattern: + role.excludedClaimPattern + }) }) - }) - ), - oidcParams: { - issuerUri: sts.oidcConfiguration?.issuerURI || undefined, - clientId: sts.oidcConfiguration?.clientID || undefined, - extraQueryParams_raw: - sts.oidcConfiguration?.extraQueryParams || undefined, - scope_spaceSeparated: sts.oidcConfiguration?.scope || undefined, - idleSessionLifetimeInSeconds: (() => { - const value = - sts.oidcConfiguration?.idleSessionLifetimeInSeconds; - - if (value === "" || value === undefined) { - return undefined; - } - - if (typeof value === "number") { - return value; - } - - return parseInt(value); - })() - } - }, + ), + oidcParams: { + issuerUri: + sts.oidcConfiguration?.issuerURI || undefined, + clientId: sts.oidcConfiguration?.clientID || undefined, + extraQueryParams_raw: + sts.oidcConfiguration?.extraQueryParams || + undefined, + scope_spaceSeparated: + sts.oidcConfiguration?.scope || undefined, + idleSessionLifetimeInSeconds: (() => { + const value = + sts.oidcConfiguration + ?.idleSessionLifetimeInSeconds; + + if (value === "" || value === undefined) { + return undefined; + } + + if (typeof value === "number") { + return value; + } + + return parseInt(value); + })() + } + }, + anonymousProfileName: s3Config.anonymousProfileName, bookmarks: (s3Config.bookmarks ?? []).map( (bookmark): S3Config.Entry.Bookmark => ({ - s3UriStr_templated: bookmark.s3Uri, + s3UriStr: bookmark.s3Uri, title: bookmark.title, forProfileNames: bookmark.forProfileName === undefined @@ -260,8 +286,9 @@ export function parseS3ConfigFromEnvValue(params: { envValue: string }): S3Confi ? [bookmark.forProfileName] : bookmark.forProfileName, ...(bookmark.claimName === undefined - ? { claimName: undefined } + ? { isTemplated: false } : { + isTemplated: true, claimName: bookmark.claimName, includedClaimPattern: bookmark.includedClaimPattern, excludedClaimPattern: bookmark.excludedClaimPattern @@ -272,8 +299,10 @@ export function parseS3ConfigFromEnvValue(params: { envValue: string }): S3Confi }); const s3ConfigForCreationForm = - parsedValue_arr.find(s3Config => s3Config.sts === undefined) ?? - parsedValue_arr[0]; + parsedValue_arr.find( + s3Config => + s3Config.sts === undefined && s3Config.anonymousProfileName === undefined + ) ?? parsedValue_arr[0]; return { entries, diff --git a/web/src/core/usecases/clusterEventsMonitor/selectors.ts b/web/src/core/usecases/clusterEventsMonitor/selectors.ts index a1b270627..02e4077fa 100644 --- a/web/src/core/usecases/clusterEventsMonitor/selectors.ts +++ b/web/src/core/usecases/clusterEventsMonitor/selectors.ts @@ -8,14 +8,14 @@ const state = (rootState: RootState) => rootState[name]; const clusterEvents = createSelector( state, projectManagement.protectedSelectors.currentProject, - projectManagement.protectedSelectors.projectConfig, - (state, currentProject, currentProjectConfig) => + projectManagement.protectedSelectors.projectConfigs, + (state, currentProject, currentProjectConfigs) => (state.clusterEventsByProjectId[currentProject.id] ?? []).map(clusterEvent => ({ ...clusterEvent, isHighlighted: clusterEvent.severity !== "info" && clusterEvent.timestamp > - currentProjectConfig.clusterNotificationCheckoutTime + currentProjectConfigs.clusterNotificationCheckoutTime })) ); diff --git a/web/src/core/usecases/launcher/selectors.ts b/web/src/core/usecases/launcher/selectors.ts index cc68a0956..e06bf79d3 100644 --- a/web/src/core/usecases/launcher/selectors.ts +++ b/web/src/core/usecases/launcher/selectors.ts @@ -177,7 +177,9 @@ const s3ProfileSelect = createSelector( } const availableConfigs = s3Configs.filter( - config => canInjectPersonalInfos || config.origin !== "defined in region" + config => + canInjectPersonalInfos || + config.origin !== "onyxia instance config (setup by admin)" ); // We don't display the s3 config selector if there is no config or only one diff --git a/web/src/core/usecases/launcher/thunks.ts b/web/src/core/usecases/launcher/thunks.ts index 1614e0142..b969a518b 100644 --- a/web/src/core/usecases/launcher/thunks.ts +++ b/web/src/core/usecases/launcher/thunks.ts @@ -185,7 +185,9 @@ export const thunks = { s3Profile => s3Profile.profileName === "default" ) ?? s3Profiles.find( - s3Profile => s3Profile.origin === "defined in region" + s3Profile => + s3Profile.origin === + "onyxia instance config (setup by admin)" ) ?? s3Profiles.find(() => true) )?.profileName; diff --git a/web/src/core/usecases/projectManagement/selectors.ts b/web/src/core/usecases/projectManagement/selectors.ts index 7ba2e01d3..aaac3013b 100644 --- a/web/src/core/usecases/projectManagement/selectors.ts +++ b/web/src/core/usecases/projectManagement/selectors.ts @@ -5,7 +5,7 @@ import { assert } from "tsafe/assert"; const state = (rootState: RootState) => rootState[name]; -const projectConfig = createSelector(state, state => state.currentProjectConfigs); +const projectConfigs = createSelector(state, state => state.currentProjectConfigs); export const protectedSelectors = { projects: createSelector(state, state => state.projects), @@ -18,7 +18,7 @@ export const protectedSelectors = { return project; }), - projectConfig + projectConfigs }; export const selectors = { @@ -31,8 +31,8 @@ export const selectors = { }) ), servicePassword: createSelector( - projectConfig, - projectConfig => projectConfig.servicePassword + projectConfigs, + projectConfigs => projectConfigs.servicePassword ), groupProjectName: createSelector( protectedSelectors.currentProject, diff --git a/web/src/core/usecases/projectManagement/thunks.ts b/web/src/core/usecases/projectManagement/thunks.ts index 94b96e2c2..cb32b3e37 100644 --- a/web/src/core/usecases/projectManagement/thunks.ts +++ b/web/src/core/usecases/projectManagement/thunks.ts @@ -237,7 +237,8 @@ export const protectedThunks = { await mutex.runExclusive(async () => { const { secretsManager } = rootContext; - const currentProjectConfig = protectedSelectors.projectConfig(getState()); + const currentProjectConfig = + protectedSelectors.projectConfigs(getState()); const currentLocalValue = currentProjectConfig[params.key]; diff --git a/web/src/core/usecases/restorableConfigManagement/selectors.ts b/web/src/core/usecases/restorableConfigManagement/selectors.ts index 0f66bd813..051cca728 100644 --- a/web/src/core/usecases/restorableConfigManagement/selectors.ts +++ b/web/src/core/usecases/restorableConfigManagement/selectors.ts @@ -6,7 +6,7 @@ import * as projectManagement from "core/usecases/projectManagement"; const state = (rootState: RootState) => rootState[name]; const restorableConfigs = createSelector( - projectManagement.protectedSelectors.projectConfig, + projectManagement.protectedSelectors.projectConfigs, createSelector(state, state => state.indexedChartsIcons), ({ restorableServiceConfigs }, indexedChartsIcons) => restorableServiceConfigs.map(restorableConfig => ({ diff --git a/web/src/core/usecases/restorableConfigManagement/thunks.ts b/web/src/core/usecases/restorableConfigManagement/thunks.ts index 345d0a663..4b88548ce 100644 --- a/web/src/core/usecases/restorableConfigManagement/thunks.ts +++ b/web/src/core/usecases/restorableConfigManagement/thunks.ts @@ -47,7 +47,7 @@ export const thunks = { const { restorableConfig } = params; const { restorableServiceConfigs } = - projectManagement.protectedSelectors.projectConfig(getState()); + projectManagement.protectedSelectors.projectConfigs(getState()); const restorableConfig_withSameRef = (() => { const results = restorableServiceConfigs.filter(restorableConfig_i => @@ -98,7 +98,7 @@ export const thunks = { const { restorableConfigRef: ref } = params; const { restorableServiceConfigs } = - projectManagement.protectedSelectors.projectConfig(getState()); + projectManagement.protectedSelectors.projectConfigs(getState()); const index_toDelete = restorableServiceConfigs.findIndex(c => getAreSameRestorableConfigRef(c, ref) @@ -131,7 +131,7 @@ export const thunks = { const { restorableConfigRef: ref, targetIndex } = params; const { restorableServiceConfigs } = - projectManagement.protectedSelectors.projectConfig(getState()); + projectManagement.protectedSelectors.projectConfigs(getState()); const index_current = restorableServiceConfigs.findIndex(c => getAreSameRestorableConfigRef(c, ref) @@ -168,7 +168,7 @@ export const thunks = { const { restorableConfigRef: ref, newFriendlyName } = params; const { restorableServiceConfigs } = - projectManagement.protectedSelectors.projectConfig(getState()); + projectManagement.protectedSelectors.projectConfigs(getState()); const restorableConfig_current = restorableServiceConfigs.find(c => getAreSameRestorableConfigRef(c, ref) diff --git a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts index a6e26a36c..b4f599aaa 100644 --- a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts +++ b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts @@ -73,6 +73,7 @@ export function computeUploadStatusAtPrefix(params: { s3Uri: s3Uri_newItem, isDeleting: false, policy: { isPublic: false, canBeMadePublic: false }, + profileNameForSharing: undefined, uploadProgressPercent: NaN }); } diff --git a/web/src/core/usecases/s3ExplorerUiController/selectors.ts b/web/src/core/usecases/s3ExplorerUiController/selectors.ts index 2860bb000..205cbe8d7 100644 --- a/web/src/core/usecases/s3ExplorerUiController/selectors.ts +++ b/web/src/core/usecases/s3ExplorerUiController/selectors.ts @@ -114,6 +114,7 @@ export namespace MainView { type: "prefix segment"; s3Uri: S3Uri.TerminatedByDelimiter; policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; + profileNameForSharing: string | undefined; }; export type Object = Common & { @@ -137,6 +138,42 @@ const profileName = createSelector( } ); +const profileName_anonymous = createSelector( + s3ProfilesManagement.selectors.ambientS3Profile, + s3ProfilesManagement.selectors.s3Profiles, + (ambientS3Profile, s3Profiles) => { + if (ambientS3Profile === undefined) { + return undefined; + } + if (ambientS3Profile.origin !== "onyxia instance config (setup by admin)") { + return undefined; + } + if ( + !ambientS3Profile.paramsOfCreateS3Client.isStsEnabled && + ambientS3Profile.paramsOfCreateS3Client.credentials === undefined + ) { + return ambientS3Profile.profileName; + } + + const s3Profile_anonymous = s3Profiles.find( + s3Profile => + s3Profile.origin === "onyxia instance config (setup by admin)" && + !s3Profile.paramsOfCreateS3Client.isStsEnabled && + s3Profile.paramsOfCreateS3Client.credentials === undefined && + s3Profile.paramsOfCreateS3Client.url === + ambientS3Profile.paramsOfCreateS3Client.url && + s3Profile.paramsOfCreateS3Client.region === + ambientS3Profile.paramsOfCreateS3Client.region + ); + + if (s3Profile_anonymous === undefined) { + return undefined; + } + + return s3Profile_anonymous.profileName; + } +); + const s3Uri = createSelector( createSelector(state, state => state.listedPrefixByProfile), profileName, @@ -185,7 +222,8 @@ const profileSelect = createSelector( selectedProfile: { name: ambientS3Profile.profileName, url: ambientS3Profile.paramsOfCreateS3Client.url, - isReadonly: ambientS3Profile.origin === "defined in region" + isReadonly: + ambientS3Profile.origin === "onyxia instance config (setup by admin)" }, availableProfileNames: s3Profiles.map(s3Profile => s3Profile.profileName) }; @@ -286,12 +324,14 @@ const items = createSelector( : paramsOfCreateS3Client.credentials === undefined; return isAnonymousS3Profile; }), + profileName_anonymous, ( listedPrefix_state, uploads_profile, deletions_profile, bucketPoliciesByBucket, - isAnonymousS3Profile + isAnonymousS3Profile, + profileName_anonymous ): MainView.Item[] | undefined => { if (listedPrefix_state === undefined) { return undefined; @@ -325,20 +365,9 @@ const items = createSelector( lastModified: item.lastModified, size: item.size }); - case "prefix": - return id({ - type: "prefix segment", - displayName: (() => { - const lastSegment = item.s3Uri.keySegments.at(-1); - - assert(lastSegment !== undefined); - - return lastSegment; - })(), - s3Uri: item.s3Uri, - uploadProgressPercent: undefined, - isDeleting: false, - policy: isAnonymousS3Profile + case "prefix": { + const policy: MainView.Item.PrefixSegment["policy"] = + isAnonymousS3Profile ? // NOTE: Semantically false but yield the intended result. { isPublic: false, canBeMadePublic: false } : getHasPrefixBeMadePublic({ @@ -355,8 +384,48 @@ const items = createSelector( s3Uri: item.s3Uri, bucketPoliciesByBucket }).isWithinPrefixThatHasBeenMadePublic - } + }; + + return id({ + type: "prefix segment", + displayName: (() => { + const lastSegment = item.s3Uri.keySegments.at(-1); + + assert(lastSegment !== undefined); + + return lastSegment; + })(), + s3Uri: item.s3Uri, + uploadProgressPercent: undefined, + isDeleting: false, + profileNameForSharing: (() => { + if (profileName_anonymous === undefined) { + return undefined; + } + + if (isAnonymousS3Profile) { + return profileName_anonymous; + } + + if (policy.isPublic) { + return profileName_anonymous; + } + + // NOTE: Semantically, this is wrong, it's sharable + // if it's within a prefix that has been made public + // but since we already compute that for canBeMadePublic + // we reuse the value here. + if (policy.canBeMadePublic) { + return undefined; + } + + return profileName_anonymous; + })(), + policy }); + } + default: + assert>(false); } } ); diff --git a/web/src/core/usecases/s3ExplorerUiController/thunks.ts b/web/src/core/usecases/s3ExplorerUiController/thunks.ts index 049efe8d8..b438c90a8 100644 --- a/web/src/core/usecases/s3ExplorerUiController/thunks.ts +++ b/web/src/core/usecases/s3ExplorerUiController/thunks.ts @@ -101,6 +101,37 @@ export const thunks = { routeParams_toSet: privateSelectors.routeParams(getState()) }; }, + getShouldEnforceLogin: + (params: { routeParams: Pick }) => + (...args) => { + const { routeParams } = params; + const [, getState] = args; + + const profileName = routeParams.profile; + + if (profileName === undefined) { + return true; + } + + const s3Profiles = s3ProfilesManagement.selectors.s3Profiles(getState()); + + const s3Profile = s3Profiles.find( + s3Profile => s3Profile.profileName === profileName + ); + + if (s3Profile === undefined) { + return true; + } + + const isAnonymousAdminProfile = + s3Profile.origin === "onyxia instance config (setup by admin)" && + !s3Profile.paramsOfCreateS3Client.isStsEnabled && + s3Profile.paramsOfCreateS3Client.credentials === undefined; + + const shouldEnforceLogin = !isAnonymousAdminProfile; + + return shouldEnforceLogin; + }, notifyRouteParamsExternallyUpdated: (params: { routeParams: RouteParams }) => async (...args) => { diff --git a/web/src/core/usecases/s3ProfilesCreationUiController/selectors.ts b/web/src/core/usecases/s3ProfilesCreationUiController/selectors.ts index 3b4a0ce56..57732ddd3 100644 --- a/web/src/core/usecases/s3ProfilesCreationUiController/selectors.ts +++ b/web/src/core/usecases/s3ProfilesCreationUiController/selectors.ts @@ -121,13 +121,13 @@ const submittableFormValuesAsS3Profile_vault = createSelector( formattedFormValuesUrl, isFormSubmittable, createSelector(state, state => state.creationTimeOfProfileToEdit), - projectManagement.protectedSelectors.projectConfig, + projectManagement.protectedSelectors.projectConfigs, ( formValues, formattedFormValuesUrl, isFormSubmittable, creationTimeOfProfileToEdit, - projectConfig + projectConfigs ) => { if (!isFormSubmittable) { return undefined; @@ -140,7 +140,7 @@ const submittableFormValuesAsS3Profile_vault = createSelector( return undefined; } - const s3Profile_vault_current = projectConfig.s3Profiles.find( + const s3Profile_vault_current = projectConfigs.s3Profiles.find( s3Config => s3Config.creationTime === creationTimeOfProfileToEdit ); diff --git a/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts b/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts index 129d61959..33ad685f6 100644 --- a/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts +++ b/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts @@ -64,7 +64,7 @@ const mainView = createSelector( switch (s3Profile.origin) { case "created by user (or group project member)": return false; - case "defined in region": + case "onyxia instance config (setup by admin)": return true; } })(), diff --git a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts index 117611801..62bf909ca 100644 --- a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts +++ b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts @@ -5,35 +5,20 @@ import { z } from "zod"; import { getValueAtPath } from "core/tools/Stringifyable"; import { type S3Uri, parseS3Uri } from "core/tools/S3Uri"; -export type ResolvedTemplateBookmark = { +export type ResolvedTemplatedBookmark = { title: LocalizedString; s3Uri: S3Uri; forProfileNames: string[]; }; -export async function resolveTemplatedBookmark(params: { - bookmark_fromConfig: S3Config.Entry.Bookmark; - getDecodedIdToken: () => Promise>; -}): Promise { - const { bookmark_fromConfig, getDecodedIdToken } = params; - - if (bookmark_fromConfig.claimName === undefined) { - return [ - id({ - s3Uri: parseS3Uri({ - value: bookmark_fromConfig.s3UriStr_templated, - delimiter: "/" - }), - title: bookmark_fromConfig.title, - forProfileNames: bookmark_fromConfig.forProfileNames - }) - ]; - } +export function resolveTemplatedBookmark(params: { + bookmark_fromConfig: S3Config.Entry.Bookmark.Templated; + decodedIdToken: Record; +}): ResolvedTemplatedBookmark[] { + const { bookmark_fromConfig, decodedIdToken } = params; const { claimName, excludedClaimPattern, includedClaimPattern } = bookmark_fromConfig; - const decodedIdToken = await getDecodedIdToken(); - const claimValue_arr: string[] = (() => { let claimValue_untrusted: unknown = (() => { const candidate = decodedIdToken[claimName]; @@ -115,11 +100,9 @@ export async function resolveTemplatedBookmark(params: { ); }; - return id({ + return id({ s3Uri: parseS3Uri({ - value: substituteTemplateString( - bookmark_fromConfig.s3UriStr_templated - ), + value: substituteTemplateString(bookmark_fromConfig.s3UriStr), delimiter: "/" }), title: substituteLocalizedString(bookmark_fromConfig.title), diff --git a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/resolveTemplatedStsRole.ts b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/resolveTemplatedStsRole.ts index 7d561fa2c..070b27dc7 100644 --- a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/resolveTemplatedStsRole.ts +++ b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/resolveTemplatedStsRole.ts @@ -3,32 +3,20 @@ import { id } from "tsafe/id"; import { z } from "zod"; import { getValueAtPath } from "core/tools/Stringifyable"; -export type ResolvedTemplateStsRole = { +export type StsRole = { roleARN: string; roleSessionName: string; profileName: string; }; -export async function resolveTemplatedStsRole(params: { - stsRole_fromConfig: S3Config.Entry.StsRole; - getDecodedIdToken: () => Promise>; -}): Promise { - const { stsRole_fromConfig, getDecodedIdToken } = params; - - if (stsRole_fromConfig.claimName === undefined) { - return [ - id({ - roleARN: stsRole_fromConfig.roleARN, - roleSessionName: stsRole_fromConfig.roleSessionName, - profileName: stsRole_fromConfig.profileName - }) - ]; - } +export function resolveTemplatedStsRole(params: { + stsRole_fromConfig: S3Config.Entry.StsRole.Templated; + decodedIdToken: Record; +}): StsRole[] { + const { stsRole_fromConfig, decodedIdToken } = params; const { claimName, excludedClaimPattern, includedClaimPattern } = stsRole_fromConfig; - const decodedIdToken = await getDecodedIdToken(); - const claimValue_arr: string[] = (() => { let claimValue_untrusted: unknown = (() => { const candidate = decodedIdToken[claimName]; @@ -97,7 +85,7 @@ export async function resolveTemplatedStsRole(params: { const substituteTemplateString = (str: string) => str.replace(/\$(\d+)/g, (_, i) => match[parseInt(i)] ?? ""); - return id({ + return id({ roleARN: substituteTemplateString(stsRole_fromConfig.roleARN), roleSessionName: substituteTemplateString( stsRole_fromConfig.roleSessionName diff --git a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts index 0cdeff1aa..757b841cd 100644 --- a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -1,14 +1,17 @@ import * as projectManagement from "core/usecases/projectManagement"; import type { S3Config } from "core/ports/OnyxiaApi/S3Config"; import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; -import { assert } from "tsafe"; +import { assert, id } from "tsafe"; import type { LocalizedString } from "core/ports/OnyxiaApi"; -import type { ResolvedTemplateBookmark } from "./resolveTemplatedBookmark"; -import type { ResolvedTemplateStsRole } from "./resolveTemplatedStsRole"; +import { resolveTemplatedBookmark } from "./resolveTemplatedBookmark"; +import { resolveTemplatedStsRole, type StsRole } from "./resolveTemplatedStsRole"; import type { S3Uri } from "core/tools/S3Uri"; import { parseUserConfigsS3BookmarksStr } from "./userConfigsS3Bookmarks"; +import type { OidcParams_Partial } from "core/ports/OnyxiaApi/OidcParams"; +import { parseS3Uri } from "core/tools/S3Uri"; +import { same } from "evt/tools/inDepth/same"; -export type S3Profile = S3Profile.DefinedInRegion | S3Profile.CreatedByUser; +export type S3Profile = S3Profile.SetupByAdmin | S3Profile.UserCreated; export namespace S3Profile { type Common = { @@ -16,12 +19,12 @@ export namespace S3Profile { bookmarks: Bookmark[]; }; - export type DefinedInRegion = Common & { - origin: "defined in region"; - paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts; + export type SetupByAdmin = Common & { + origin: "onyxia instance config (setup by admin)"; + paramsOfCreateS3Client: ParamsOfCreateS3Client; }; - export type CreatedByUser = Common & { + export type UserCreated = Common & { origin: "created by user (or group project member)"; creationTime: number; paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts; @@ -34,34 +37,49 @@ export namespace S3Profile { }; } -export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { - fromVault: { - s3Profiles: projectManagement.ProjectConfigs.S3Profile[]; - userConfigs_s3BookmarksStr: string | null; - }; - fromConfig: { - entries: S3Config.Entry[]; - // NOTE: The resolvedXXX can be undefined only when the function is used to - // the stablish the default profiles (for explorer and services) - resolvedTemplatedBookmarks: - | { - correspondingS3ConfigEntryIndex: number; - bookmarks: ResolvedTemplateBookmark[]; - }[] - | undefined; - resolvedTemplatedStsRoles: - | { - correspondingS3ConfigEntryIndex: number; - stsRoles: ResolvedTemplateStsRole[]; - }[] - | undefined; - }; +export function createS3Profiles(params: { + onyxiaInstanceS3ConfigEntries: S3Config.Entry[]; + userData: + | { + projectConfigs_s3Profiles: projectManagement.ProjectConfigs.S3Profile[]; + userConfigs_s3BookmarksStr: string | null; + decodedIdTokens: { + oidcParams: OidcParams_Partial; + decodedIdToken: Record; + }[]; + } + | undefined; }): S3Profile[] { - const { fromVault, fromConfig } = params; + const { onyxiaInstanceS3ConfigEntries, userData } = params; + + const bookmarks_user: { + displayName: string | undefined; + s3Uri: S3Uri; + profileName: string; + }[] = (() => { + if (userData === undefined) { + return []; + } + + const { userConfigs_s3BookmarksStr } = userData; + + if (userConfigs_s3BookmarksStr === null) { + return []; + } + return parseUserConfigsS3BookmarksStr({ + userConfigs_s3BookmarksStr + }); + })(); + + const s3Profiles_user: S3Profile.UserCreated[] = (() => { + if (userData === undefined) { + return []; + } - const s3Profiles: S3Profile[] = [ - ...fromVault.s3Profiles - .map((c): S3Profile.CreatedByUser => { + const { projectConfigs_s3Profiles } = userData; + + return projectConfigs_s3Profiles + .map((c): S3Profile.UserCreated => { const url = c.url; const pathStyleAccess = c.pathStyleAccess; const region = c.region; @@ -86,135 +104,198 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { })) }; }) - .sort((a, b) => b.creationTime - a.creationTime), - ...fromConfig.entries - .map((c, index): S3Profile.DefinedInRegion[] => { - const resolvedTemplatedBookmarks_forThisProfile = (() => { - if (fromConfig.resolvedTemplatedBookmarks === undefined) { - return []; - } + .sort((a, b) => b.creationTime - a.creationTime); + })(); - const entry = fromConfig.resolvedTemplatedBookmarks.find( - e => e.correspondingS3ConfigEntryIndex === index - ); - - assert(entry !== undefined); - - return entry.bookmarks; - })(); - - const buildFromRole = (params: { - resolvedTemplatedStsRole: ResolvedTemplateStsRole; - }): S3Profile.DefinedInRegion => { - const { resolvedTemplatedStsRole } = params; - - const paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts = { - url: c.url, - pathStyleAccess: c.pathStyleAccess, - isStsEnabled: true, - stsUrl: c.sts.url, - region: c.region, - oidcParams: c.sts.oidcParams, - durationSeconds: c.sts.durationSeconds, - role: resolvedTemplatedStsRole - }; - - return { - origin: "defined in region", - profileName: resolvedTemplatedStsRole.profileName, - bookmarks: [ - ...resolvedTemplatedBookmarks_forThisProfile - .filter(({ forProfileNames }) => { - if (forProfileNames.length === 0) { - return true; - } - - if (resolvedTemplatedStsRole === undefined) { - return false; - } - - const getDoMatch = (params: { - stringWithWildcards: string; - candidate: string; - }): boolean => { - const { stringWithWildcards, candidate } = params; - - if (!stringWithWildcards.includes("*")) { - return stringWithWildcards === candidate; - } - - const escapedRegex = stringWithWildcards - .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") - .replace(/\\\*/g, ".*"); - - return new RegExp(`^${escapedRegex}$`).test( - candidate - ); - }; - - return forProfileNames.some(profileName => - getDoMatch({ - stringWithWildcards: profileName, - candidate: - resolvedTemplatedStsRole.profileName - }) - ); - }) - .map(({ title, s3Uri }) => ({ - isReadonly: true, - displayName: title, - s3Uri - })), - ...parseUserConfigsS3BookmarksStr({ - userConfigs_s3BookmarksStr: - fromVault.userConfigs_s3BookmarksStr - }) - .filter( - entry => - entry.profileName === - resolvedTemplatedStsRole.profileName - ) - .map(entry => ({ - isReadonly: false, - displayName: entry.displayName ?? undefined, - s3Uri: entry.s3Uri - })) - ], - paramsOfCreateS3Client - }; - }; + const s3Profiles_admin: S3Profile.SetupByAdmin[] = onyxiaInstanceS3ConfigEntries + .map((c): S3Profile.SetupByAdmin[] => { + const decodedIdToken = (() => { + const { sts } = c; - const resolvedTemplatedStsRoles_forThisProfile = (() => { - if (fromConfig.resolvedTemplatedStsRoles === undefined) { - return []; - } + if (sts === undefined) { + return undefined; + } + + if (userData === undefined) { + return undefined; + } - const entry = fromConfig.resolvedTemplatedStsRoles.find( - e => e.correspondingS3ConfigEntryIndex === index - ); + const { decodedIdTokens } = userData; - assert(entry !== undefined); + const wrap = decodedIdTokens.find(wrap => + same(wrap.oidcParams, sts.oidcParams) + ); - return entry.stsRoles; - })(); + assert(wrap !== undefined); + + return wrap.decodedIdToken; + })(); + + const bookmarks_admin: { + title: LocalizedString; + s3Uri: S3Uri; + forProfileNames: string[]; + }[] = c.bookmarks + .map(bookmark_fromConfig => { + if (!bookmark_fromConfig.isTemplated) { + return [ + { + s3Uri: parseS3Uri({ + value: bookmark_fromConfig.s3UriStr, + delimiter: "/" + }), + title: bookmark_fromConfig.title, + forProfileNames: bookmark_fromConfig.forProfileNames + } + ]; + } - assert(resolvedTemplatedStsRoles_forThisProfile.length !== 0); + if (decodedIdToken === undefined) { + return []; + } - return resolvedTemplatedStsRoles_forThisProfile.map( - resolvedTemplatedStsRole => - buildFromRole({ resolvedTemplatedStsRole }) + const bookmarks = resolveTemplatedBookmark({ + bookmark_fromConfig, + decodedIdToken + }); + + return bookmarks; + }) + .flat(); + + const getBookmarksForProfileName = (params: { + profileName: string; + }): S3Profile.Bookmark[] => { + const { profileName } = params; + return [ + ...bookmarks_admin + .filter(({ forProfileNames }) => { + if (forProfileNames.length === 0) { + return true; + } + + const getDoMatch = (params: { + stringWithWildcards: string; + candidate: string; + }): boolean => { + const { stringWithWildcards, candidate } = params; + + if (!stringWithWildcards.includes("*")) { + return stringWithWildcards === candidate; + } + + const escapedRegex = stringWithWildcards + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + .replace(/\\\*/g, ".*"); + + return new RegExp(`^${escapedRegex}$`).test(candidate); + }; + + return forProfileNames.some(profileName_withWildcards => + getDoMatch({ + stringWithWildcards: profileName_withWildcards, + candidate: profileName + }) + ); + }) + .map(({ title, s3Uri }) => ({ + isReadonly: true, + displayName: title, + s3Uri + })), + ...bookmarks_user + .filter(bookmark => bookmark.profileName === profileName) + .map(bookmark => ({ + isReadonly: false, + displayName: bookmark.displayName || undefined, + s3Uri: bookmark.s3Uri + })) + ]; + }; + + const buildFromRole = (params: { + stsRole: StsRole; + }): S3Profile.SetupByAdmin => { + const { stsRole } = params; + + assert(c.sts !== undefined); + + const paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts = { + url: c.url, + pathStyleAccess: c.pathStyleAccess, + isStsEnabled: true, + stsUrl: c.sts.url, + region: c.region, + oidcParams: c.sts.oidcParams, + durationSeconds: c.sts.durationSeconds, + role: stsRole + }; + + const { profileName } = stsRole; + + return { + origin: "onyxia instance config (setup by admin)", + profileName, + bookmarks: getBookmarksForProfileName({ profileName }), + paramsOfCreateS3Client + }; + }; + + const stsRoles: StsRole[] = + decodedIdToken === undefined || c.sts === undefined + ? [] + : c.sts.roles + .map(stsRole_fromConfig => { + if (!stsRole_fromConfig.isTemplated) { + return [ + { + roleARN: stsRole_fromConfig.roleARN, + roleSessionName: + stsRole_fromConfig.roleSessionName, + profileName: stsRole_fromConfig.profileName + } + ]; + } + + const stsRoles = resolveTemplatedStsRole({ + stsRole_fromConfig: stsRole_fromConfig, + decodedIdToken + }); + + return stsRoles; + }) + .flat(); + + const s3Profiles_admin: S3Profile.SetupByAdmin[] = stsRoles.map(stsRole => + buildFromRole({ stsRole }) + ); + + if (c.anonymousProfileName !== undefined) { + const profileName = c.anonymousProfileName; + + s3Profiles_admin.push( + id({ + origin: "onyxia instance config (setup by admin)", + bookmarks: getBookmarksForProfileName({ profileName }), + profileName, + paramsOfCreateS3Client: id({ + url: c.url, + isStsEnabled: false, + credentials: undefined, + pathStyleAccess: c.pathStyleAccess, + region: c.region + }) + }) ); - }) - .flat() - ]; + } - for (const s3Profile of [...s3Profiles].sort((a, b) => { - if (a.origin === b.origin) { - return 0; - } + return s3Profiles_admin; + }) + .flat(); + + const s3Profiles: S3Profile[] = [...s3Profiles_admin, ...s3Profiles_user]; - return a.origin === "defined in region" ? -1 : 1; - })) { + for (const s3Profile of s3Profiles) { const s3Profiles_conflicting = s3Profiles.filter( s3Profile_i => s3Profile_i !== s3Profile && diff --git a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts index a7677cbfc..0ebdda3b7 100644 --- a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts +++ b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts @@ -27,14 +27,10 @@ const zUserProfileS3Bookmark = (() => { })(); export function parseUserConfigsS3BookmarksStr(params: { - userConfigs_s3BookmarksStr: string | null; + userConfigs_s3BookmarksStr: string; }): UserConfigs_S3Bookmark[] { const { userConfigs_s3BookmarksStr } = params; - if (userConfigs_s3BookmarksStr === null) { - return []; - } - const userProfileS3Bookmarks: unknown = JSON.parse(userConfigs_s3BookmarksStr); try { diff --git a/web/src/core/usecases/s3ProfilesManagement/selectors.ts b/web/src/core/usecases/s3ProfilesManagement/selectors.ts index 4ea00f0d1..3011993e3 100644 --- a/web/src/core/usecases/s3ProfilesManagement/selectors.ts +++ b/web/src/core/usecases/s3ProfilesManagement/selectors.ts @@ -1,55 +1,49 @@ import { createSelector } from "clean-architecture"; import * as projectManagement from "core/usecases/projectManagement"; import * as userConfigs from "core/usecases/userConfigs"; -import { - type S3Profile, - aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet -} from "./decoupledLogic/s3Profiles"; +import { createS3Profiles } from "./decoupledLogic/s3Profiles"; import { name } from "./state"; import type { State as RootState } from "core/bootstrap"; import { getRootContext } from "core/rootContext"; +import { assert } from "tsafe"; const state = (rootState: RootState) => rootState[name]; -const resolvedTemplatedBookmarks = createSelector( - state, - state => state.resolvedTemplatedBookmarks -); +const s3Profiles = createSelector( + (state: RootState) => { + const { oidc } = getRootContext(); -const resolvedTemplatedStsRoles = createSelector( - state, - state => state.resolvedTemplatedStsRoles -); + if (!oidc.isUserLoggedIn) { + return undefined; + } + return projectManagement.protectedSelectors.projectConfigs(state).s3Profiles; + }, + (state: RootState) => { + const { oidc } = getRootContext(); -const userConfigs_s3BookmarksStr = createSelector( - userConfigs.selectors.userConfigs, - userConfigs => userConfigs.s3BookmarksStr -); + if (!oidc.isUserLoggedIn) { + return undefined; + } + return userConfigs.selectors.userConfigs(state).s3BookmarksStr; + }, + createSelector(state, state => state.decodedIdTokens), + (projectConfigs_s3Profiles, userConfigs_s3BookmarksStr, decodedIdTokens) => + createS3Profiles({ + onyxiaInstanceS3ConfigEntries: getRootContext().s3Config.entries, + userData: (() => { + if (decodedIdTokens === undefined) { + return undefined; + } -const s3Profiles = createSelector( - createSelector( - projectManagement.protectedSelectors.projectConfig, - projectConfig => projectConfig.s3Profiles - ), - resolvedTemplatedBookmarks, - resolvedTemplatedStsRoles, - userConfigs_s3BookmarksStr, - ( - s3Profiles_vault, - resolvedTemplatedBookmarks, - resolvedTemplatedStsRoles, - userConfigs_s3BookmarksStr - ): S3Profile[] => - aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ - fromVault: { - s3Profiles: s3Profiles_vault, - userConfigs_s3BookmarksStr - }, - fromConfig: { - entries: getRootContext().s3Config.entries, - resolvedTemplatedBookmarks, - resolvedTemplatedStsRoles - } + assert(projectConfigs_s3Profiles !== undefined); + assert(userConfigs_s3BookmarksStr !== undefined); + + return { + decodedIdTokens, + userConfigs_s3BookmarksStr, + projectConfigs_s3Profiles + }; + })() }) ); @@ -64,16 +58,15 @@ const ambientS3Profile = createSelector( : s3Profiles => s3Profiles.profileName === ambientProfileName ) ?? s3Profiles.find(s3Profile => s3Profile.profileName === "default") ?? - s3Profiles.find(s3Profile => s3Profile.origin === "defined in region") ?? + s3Profiles.find( + s3Profile => + s3Profile.origin === "onyxia instance config (setup by admin)" + ) ?? s3Profiles.find(() => true) ); } ); -export const privateSelectors = { - resolvedTemplatedBookmarks -}; - export const selectors = { s3Profiles, ambientS3Profile diff --git a/web/src/core/usecases/s3ProfilesManagement/state.ts b/web/src/core/usecases/s3ProfilesManagement/state.ts index a2985d3d5..815e8fe2f 100644 --- a/web/src/core/usecases/s3ProfilesManagement/state.ts +++ b/web/src/core/usecases/s3ProfilesManagement/state.ts @@ -2,19 +2,16 @@ import { createUsecaseActions, createObjectThatThrowsIfAccessed } from "clean-architecture"; -import type { ResolvedTemplateBookmark } from "./decoupledLogic/resolveTemplatedBookmark"; -import type { ResolvedTemplateStsRole } from "./decoupledLogic/resolveTemplatedStsRole"; +import type { OidcParams_Partial } from "core/ports/OnyxiaApi/OidcParams"; -type State = { +export type State = { ambientProfileName: string | undefined; - resolvedTemplatedBookmarks: { - correspondingS3ConfigEntryIndex: number; - bookmarks: ResolvedTemplateBookmark[]; - }[]; - resolvedTemplatedStsRoles: { - correspondingS3ConfigEntryIndex: number; - stsRoles: ResolvedTemplateStsRole[]; - }[]; + decodedIdTokens: + | { + oidcParams: OidcParams_Partial; + decodedIdToken: Record; + }[] + | undefined; }; export const name = "s3ProfilesManagement"; @@ -29,17 +26,15 @@ export const { reducer, actions } = createUsecaseActions({ payload }: { payload: { - resolvedTemplatedBookmarks: State["resolvedTemplatedBookmarks"]; - resolvedTemplatedStsRoles: State["resolvedTemplatedStsRoles"]; + decodedIdTokens: State["decodedIdTokens"]; }; } ) => { - const { resolvedTemplatedBookmarks, resolvedTemplatedStsRoles } = payload; + const { decodedIdTokens } = payload; const state: State = { ambientProfileName: undefined, - resolvedTemplatedBookmarks, - resolvedTemplatedStsRoles + decodedIdTokens }; return state; diff --git a/web/src/core/usecases/s3ProfilesManagement/thunks.ts b/web/src/core/usecases/s3ProfilesManagement/thunks.ts index 1160830cf..bea7f0914 100644 --- a/web/src/core/usecases/s3ProfilesManagement/thunks.ts +++ b/web/src/core/usecases/s3ProfilesManagement/thunks.ts @@ -1,13 +1,11 @@ import type { Thunks } from "core/bootstrap"; -import { selectors, privateSelectors } from "./selectors"; +import { selectors } from "./selectors"; import * as projectManagement from "core/usecases/projectManagement"; import { assert } from "tsafe/assert"; import type { S3Client } from "core/ports/S3Client"; import structuredClone from "@ungap/structured-clone"; import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; -import { resolveTemplatedBookmark } from "./decoupledLogic/resolveTemplatedBookmark"; -import { resolveTemplatedStsRole } from "./decoupledLogic/resolveTemplatedStsRole"; -import { actions } from "./state"; +import { actions, type State } from "./state"; import type { S3Profile } from "./decoupledLogic/s3Profiles"; import type { OidcParams_Partial } from "core/ports/OnyxiaApi/OidcParams"; import type { S3Uri } from "core/tools/S3Uri"; @@ -101,15 +99,21 @@ export const protectedThunks = { const doClearCachedS3Token_s3BookmarkClaimValue: boolean = (() => { - const resolvedTemplatedBookmarks = - privateSelectors.resolvedTemplatedBookmarks( - getState() - ); + const admin_bookmarks = selectors + .s3Profiles(getState()) + .filter( + s3Profile => + s3Profile.origin === + "onyxia instance config (setup by admin)" + ) + .map(s3Profile => s3Profile.bookmarks) + .flat() + .filter(bookmark => bookmark.isReadonly); const KEY = "onyxia:s3:resolvedAdminBookmarks-hash"; const hash = fnv1aHashToHex( - JSON.stringify(resolvedTemplatedBookmarks) + JSON.stringify(admin_bookmarks) ); if ( @@ -166,7 +170,7 @@ export const protectedThunks = { const [dispatch, getState] = args; const s3Profiles_vault = structuredClone( - projectManagement.protectedSelectors.projectConfig(getState()).s3Profiles + projectManagement.protectedSelectors.projectConfigs(getState()).s3Profiles ); const i = s3Profiles_vault.findIndex( @@ -202,7 +206,7 @@ export const protectedThunks = { const [dispatch, getState] = args; const s3Profiles_vault = structuredClone( - projectManagement.protectedSelectors.projectConfig(getState()).s3Profiles + projectManagement.protectedSelectors.projectConfigs(getState()).s3Profiles ); const i = s3Profiles_vault.findIndex( @@ -250,8 +254,9 @@ export const protectedThunks = { case "created by user (or group project member)": { const s3Profiles_vault = structuredClone( - projectManagement.protectedSelectors.projectConfig(getState()) - .s3Profiles + projectManagement.protectedSelectors.projectConfigs( + getState() + ).s3Profiles ); const s3Profile_vault = s3Profiles_vault.find( @@ -299,14 +304,17 @@ export const protectedThunks = { ); } break; - case "defined in region": + case "onyxia instance config (setup by admin)": { const { s3BookmarksStr } = userConfigs.selectors.userConfigs(getState()); - const userConfigs_s3Bookmarks = parseUserConfigsS3BookmarksStr({ - userConfigs_s3BookmarksStr: s3BookmarksStr - }); + const userConfigs_s3Bookmarks = + s3BookmarksStr === null + ? [] + : parseUserConfigsS3BookmarksStr({ + userConfigs_s3BookmarksStr: s3BookmarksStr + }); const index = userConfigs_s3Bookmarks.findIndex( entry => @@ -375,7 +383,17 @@ export const protectedThunks = { initialize: () => async (...args) => { - const [dispatch, , { onyxiaApi, paramsOfBootstrapCore, s3Config }] = args; + const [dispatch, , { onyxiaApi, paramsOfBootstrapCore, s3Config, oidc }] = + args; + + if (!oidc.isUserLoggedIn) { + dispatch( + actions.initialized({ + decodedIdTokens: undefined + }) + ); + return; + } const getDecodedIdToken = async (params: { oidcParams_partial: OidcParams_Partial; @@ -407,56 +425,23 @@ export const protectedThunks = { return decodedIdToken; }; - const resolvedTemplatedBookmarks = await Promise.all( - s3Config.entries.map(async (entry, entryIndex) => { - const { bookmarks: bookmarks_region, sts } = entry; - - return { - correspondingS3ConfigEntryIndex: entryIndex, - bookmarks: ( - await Promise.all( - bookmarks_region.map(bookmark => - resolveTemplatedBookmark({ - bookmark_fromConfig: bookmark, - getDecodedIdToken: () => - getDecodedIdToken({ - oidcParams_partial: sts.oidcParams - }) - }) - ) - ) - ).flat() - }; - }) - ); - - const resolvedTemplatedStsRoles = await Promise.all( - s3Config.entries.map(async (entry, entryIndex) => { - const { sts } = entry; - - return { - correspondingS3ConfigEntryIndex: entryIndex, - stsRoles: ( - await Promise.all( - sts.roles.map(role => - resolveTemplatedStsRole({ - stsRole_fromConfig: role, - getDecodedIdToken: () => - getDecodedIdToken({ - oidcParams_partial: sts.oidcParams - }) - }) - ) - ) - ).flat() - }; - }) + const oidcParams_arr = s3Config.entries + .map(entry => entry.sts?.oidcParams) + .filter(oidcParams => oidcParams !== undefined) + .reduce(...removeDuplicates(same)); + + const decodedIdTokens: State["decodedIdTokens"] = await Promise.all( + oidcParams_arr.map(async oidcParams => ({ + oidcParams, + decodedIdToken: await getDecodedIdToken({ + oidcParams_partial: oidcParams + }) + })) ); dispatch( actions.initialized({ - resolvedTemplatedBookmarks, - resolvedTemplatedStsRoles + decodedIdTokens }) ); } diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index 6e0d4985d..4e49203ee 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -216,6 +216,9 @@ export const translations: Translations<"de"> = { S3ShareObjectDialogContainer: { "dialog title": "Objekt teilen" }, + S3SharePrefixDialogContainer: { + "dialog title": "Ordner teilen" + }, S3BookmarksBar: { "s3 bookmarks aria label": "S3-Lesezeichen", "show more bookmarks": "Weitere Lesezeichen anzeigen" @@ -439,6 +442,11 @@ export const translations: Translations<"de"> = { "validity duration one week": "1 Woche", "selected duration": "die ausgewählte Dauer" }, + S3SharePrefixDialog: { + "copy folder URL aria label": "Ordner-URL kopieren", + "public sharing note": + "Jede Person mit diesem Link kann diesen Ordner auch ohne Konto öffnen. Dieser Link ist verfügbar, weil dieser Ordner oder eines seiner untergeordneten Elemente öffentlich gemacht wurde." + }, S3ProfileDialog: { "detail title": "S3-Profildetails", "create title": "Neues benutzerdefiniertes S3-Profil", diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 35df128f2..13c79f1e7 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -322,6 +322,11 @@ export const translations: Translations<"en"> = { "validity duration one week": "1 week", "selected duration": "the selected duration" }, + S3SharePrefixDialog: { + "copy folder URL aria label": "Copy folder URL", + "public sharing note": + "Anyone with this link can open this folder, even without an account. This link is available because this folder or one of its descendants has been made public." + }, S3Explorer: { "page header title": "Data Storage", "no profile title": "Connect your object storage", @@ -336,6 +341,9 @@ export const translations: Translations<"en"> = { S3ShareObjectDialogContainer: { "dialog title": "Share object" }, + S3SharePrefixDialogContainer: { + "dialog title": "Share folder" + }, S3BookmarksBar: { "s3 bookmarks aria label": "S3 bookmarks", "show more bookmarks": "Show more bookmarks" diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index 9e823d64d..95126cb78 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -211,6 +211,9 @@ export const translations: Translations<"es"> = { S3ShareObjectDialogContainer: { "dialog title": "Compartir objeto" }, + S3SharePrefixDialogContainer: { + "dialog title": "Compartir carpeta" + }, S3BookmarksBar: { "s3 bookmarks aria label": "Marcadores S3", "show more bookmarks": "Mostrar más marcadores" @@ -431,6 +434,11 @@ export const translations: Translations<"es"> = { "validity duration one week": "1 semana", "selected duration": "la duración seleccionada" }, + S3SharePrefixDialog: { + "copy folder URL aria label": "Copiar URL de la carpeta", + "public sharing note": + "Cualquier persona que tenga este enlace puede abrir esta carpeta, incluso sin una cuenta. El enlace está disponible porque esta carpeta o uno de sus descendientes se ha hecho público." + }, S3ProfileDialog: { "detail title": "Detalle del perfil S3", "create title": "Nuevo perfil S3 personalizado", diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 5ad8b87c0..288690dca 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -208,6 +208,9 @@ export const translations: Translations<"fi"> = { S3ShareObjectDialogContainer: { "dialog title": "Jaa objekti" }, + S3SharePrefixDialogContainer: { + "dialog title": "Jaa kansio" + }, S3BookmarksBar: { "s3 bookmarks aria label": "S3-kirjanmerkit", "show more bookmarks": "Näytä lisää kirjanmerkkejä" @@ -423,6 +426,11 @@ export const translations: Translations<"fi"> = { "validity duration one week": "1 viikko", "selected duration": "valittu kesto" }, + S3SharePrefixDialog: { + "copy folder URL aria label": "Kopioi kansion URL", + "public sharing note": + "Kuka tahansa linkin saanut voi avata tämän kansion myös ilman käyttäjätiliä. Linkki on käytettävissä, koska tämä kansio tai jokin sen alikohteista on asetettu julkiseksi." + }, S3ProfileDialog: { "detail title": "S3-profiilin tiedot", "create title": "Uusi mukautettu S3-profiili", diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 93cbaee7c..23b526f97 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -214,6 +214,9 @@ export const translations: Translations<"fr"> = { S3ShareObjectDialogContainer: { "dialog title": "Partager l'objet" }, + S3SharePrefixDialogContainer: { + "dialog title": "Partager le dossier" + }, S3BookmarksBar: { "s3 bookmarks aria label": "Favoris S3", "show more bookmarks": "Afficher plus de favoris" @@ -434,6 +437,11 @@ export const translations: Translations<"fr"> = { "validity duration one week": "1 semaine", "selected duration": "la durée sélectionnée" }, + S3SharePrefixDialog: { + "copy folder URL aria label": "Copier l'URL du dossier", + "public sharing note": + "Toute personne disposant de ce lien peut ouvrir ce dossier, même sans compte. Ce lien est disponible car ce dossier ou l'un de ses descendants a été rendu public." + }, S3ProfileDialog: { "detail title": "Détail du profil S3", "create title": "Nouveau profil S3 personnalisé", diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index d3da0d206..ddd296668 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -211,6 +211,9 @@ export const translations: Translations<"it"> = { S3ShareObjectDialogContainer: { "dialog title": "Condividi oggetto" }, + S3SharePrefixDialogContainer: { + "dialog title": "Condividi cartella" + }, S3BookmarksBar: { "s3 bookmarks aria label": "Segnalibri S3", "show more bookmarks": "Mostra altri segnalibri" @@ -430,6 +433,11 @@ export const translations: Translations<"it"> = { "validity duration one week": "1 settimana", "selected duration": "la durata selezionata" }, + S3SharePrefixDialog: { + "copy folder URL aria label": "Copia URL della cartella", + "public sharing note": + "Chiunque disponga di questo link può aprire questa cartella, anche senza un account. Il link è disponibile perché questa cartella o uno dei suoi discendenti è stato reso pubblico." + }, S3ProfileDialog: { "detail title": "Dettaglio profilo S3", "create title": "Nuovo profilo S3 personalizzato", diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index deb9dfe64..48c86bf8c 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -212,6 +212,9 @@ export const translations: Translations<"nl"> = { S3ShareObjectDialogContainer: { "dialog title": "Object delen" }, + S3SharePrefixDialogContainer: { + "dialog title": "Map delen" + }, S3BookmarksBar: { "s3 bookmarks aria label": "S3-bladwijzers", "show more bookmarks": "Meer bladwijzers tonen" @@ -428,6 +431,11 @@ export const translations: Translations<"nl"> = { "validity duration one week": "1 week", "selected duration": "de geselecteerde duur" }, + S3SharePrefixDialog: { + "copy folder URL aria label": "Map-URL kopiëren", + "public sharing note": + "Iedereen met deze link kan deze map openen, ook zonder account. Deze link is beschikbaar omdat deze map of een onderliggend item openbaar is gemaakt." + }, S3ProfileDialog: { "detail title": "S3-profielgegevens", "create title": "Nieuw aangepast S3-profiel", diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index af000279b..8f943ac53 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -208,6 +208,9 @@ export const translations: Translations<"no"> = { S3ShareObjectDialogContainer: { "dialog title": "Del objekt" }, + S3SharePrefixDialogContainer: { + "dialog title": "Del mappe" + }, S3BookmarksBar: { "s3 bookmarks aria label": "S3-bokmerker", "show more bookmarks": "Vis flere bokmerker" @@ -426,6 +429,11 @@ export const translations: Translations<"no"> = { "validity duration one week": "1 uke", "selected duration": "den valgte varigheten" }, + S3SharePrefixDialog: { + "copy folder URL aria label": "Kopier mappe-URL", + "public sharing note": + "Alle med denne lenken kan åpne mappen, også uten en konto. Lenken er tilgjengelig fordi mappen eller et av elementene under den er gjort offentlig." + }, S3ProfileDialog: { "detail title": "S3-profildetaljer", "create title": "Ny egendefinert S3-profil", diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index 6a8493a54..5e0a19f78 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -189,6 +189,9 @@ export const translations: Translations<"zh-CN"> = { S3ShareObjectDialogContainer: { "dialog title": "共享对象" }, + S3SharePrefixDialogContainer: { + "dialog title": "共享文件夹" + }, S3BookmarksBar: { "s3 bookmarks aria label": "S3 书签", "show more bookmarks": "显示更多书签" @@ -397,6 +400,11 @@ export const translations: Translations<"zh-CN"> = { "validity duration one week": "1 周", "selected duration": "所选时长" }, + S3SharePrefixDialog: { + "copy folder URL aria label": "复制文件夹 URL", + "public sharing note": + "任何拥有此链接的人都可以打开此文件夹,即使没有帐户。此链接可用,是因为此文件夹或其后代之一已设为公开。" + }, S3ProfileDialog: { "detail title": "S3 配置文件详情", "create title": "新建自定义 S3 配置文件", diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index f7200dd3e..cdc38feed 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -26,6 +26,8 @@ export type ComponentKey = | import("ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView").I18n | import("ui/shared/codex/S3ShareObjectDialog").I18n | import("ui/pages/s3Explorer/dialogs/S3ShareObjectDialog").I18n + | import("ui/shared/codex/S3SharePrefixDialog").I18n + | import("ui/pages/s3Explorer/dialogs/S3SharePrefixDialog").I18n | import("ui/pages/s3Explorer/dialogs/S3ProfileDialog").I18n | import("ui/pages/s3Explorer/Page").I18n | import("ui/shared/codex/S3Bookmarks/S3BookmarksBar").I18n diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 2a614160d..8a8c93440 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -25,9 +25,8 @@ import { S3ExplorerMainView } from "ui/shared/codex/S3ExplorerMainView"; import { CommandBar } from "ui/shared/CommandBar"; import { S3BookmarksEntryPointList } from "ui/shared/codex/S3Bookmarks/S3BookmarksEntryPointItem"; import { PageHeader } from "onyxia-ui/PageHeader"; -import { customIcons } from "lazy-icons"; +import { customIcons, getIconUrlByName } from "lazy-icons"; import { S3ContextActionButton } from "ui/shared/codex/S3ContextActionButton"; -import { getIconUrlByName } from "lazy-icons"; import { declareComponentKeys, useResolveLocalizedString, useTranslation } from "ui/i18n"; import { CodeTextEditor } from "ui/shared/textEditor/CodeTextEditor"; import { Icon } from "onyxia-ui/Icon"; @@ -41,13 +40,20 @@ const Page = withLoader({ export default Page; async function loader() { - await enforceLogin(); - const core = await getCore(); const route = getRoute(); assert(routeGroup.has(route)); + const shouldEnforceLogin = + core.functions.s3ExplorerUiController.getShouldEnforceLogin({ + routeParams: route.params + }); + + if (shouldEnforceLogin) { + await enforceLogin(); + } + const { routeParams_toSet } = core.functions.s3ExplorerUiController.load({ routeParams: route.params }); @@ -95,6 +101,7 @@ function S3Explorer() { evtDisplayErrorDialogOpen: new Evt(), evtS3ProfileDialogOpen: new Evt(), evtS3ShareObjectDialogOpen: new Evt(), + evtS3SharePrefixDialogOpen: new Evt(), evtMaybeAcknowledgeConfigVolatilityDialogOpen: new Evt() }) ); @@ -151,6 +158,8 @@ function S3Explorer() { const { isCommandBarEnabled } = useCoreState("userConfigs", "userConfigs"); + const { isUserLoggedIn } = useCoreState("userAuthentication", "main"); + const { ref: ref_root, domRect: { height: rootHeight } @@ -223,8 +232,15 @@ function S3Explorer() { profile: mainView.profileSelect.selectedProfile.name }).link; }, - onDelete: s3ExplorerUiController.deleteBookmark, - onRename: ({ s3Uri }) => openBookmarkDialog({ s3Uri }) + onDelete: isUserLoggedIn + ? ({ s3Uri }) => { + assert(isUserLoggedIn); + s3ExplorerUiController.deleteBookmark({ s3Uri }); + } + : undefined, + onRename: isUserLoggedIn + ? ({ s3Uri }) => openBookmarkDialog({ s3Uri }) + : undefined } satisfies S3BookmarksBarProps; const onDownload = (params: { s3Uris: S3Uri[] }) => { @@ -379,15 +395,19 @@ function S3Explorer() { > {t("no profile description")} - + {isUserLoggedIn && ( + + )} ); } @@ -430,9 +450,15 @@ function S3Explorer() { onEditProfile={() => dialogProps.evtS3ProfileDialogOpen.post("detail") } - onCreateNewProfile={() => { - dialogProps.evtS3ProfileDialogOpen.post("create"); - }} + onCreateNewProfile={ + isUserLoggedIn + ? () => { + dialogProps.evtS3ProfileDialogOpen.post( + "create" + ); + } + : undefined + } /> { - if ( - mainView.uriBar.bookmarkStatus.isBookmarked && - mainView.uriBar.bookmarkStatus.isReadonly - ) { - return undefined; - } - - return ({ s3Uri }) => { - const getDisplayName = () => { - const dResult = new Deferred< - | { - doProceed: true; - displayName: string; + onToggleBookmark={ + !isUserLoggedIn || + (mainView.uriBar.bookmarkStatus.isBookmarked && + mainView.uriBar.bookmarkStatus.isReadonly) + ? undefined + : ({ s3Uri }) => { + assert(isUserLoggedIn); + + const getDisplayName = () => { + const dResult = new Deferred< + | { + doProceed: true; + displayName: string; + } + | { doProceed: false } + >(); + + dialogProps.evtCreateOrRenameBookmarkDialogOpen.post( + { + s3Uri, + currentDisplayName: + undefined, + resolveDoProceed: + dResult.resolve + } + ); + + return dResult.pr; + }; + + s3ExplorerUiController.toggleIsS3UriBookmarked( + { + getDisplayName } - | { doProceed: false } - >(); - - dialogProps.evtCreateOrRenameBookmarkDialogOpen.post( - { - s3Uri, - currentDisplayName: undefined, - resolveDoProceed: dResult.resolve - } - ); - - return dResult.pr; - }; - - s3ExplorerUiController.toggleIsS3UriBookmarked( - { - getDisplayName - } - ); - }; - })()} + ); + } + } isBookmarked={ mainView.uriBar.bookmarkStatus.isBookmarked } @@ -619,7 +646,17 @@ function S3Explorer() { s3Uri }); }} - onBookmark={toggleBookmarkFromDataView} + onSharePrefix={({ s3Uri, anonymousProfileName }) => + dialogProps.evtS3SharePrefixDialogOpen.post({ + s3Uri, + anonymousProfileName + }) + } + onBookmark={ + isUserLoggedIn + ? toggleBookmarkFromDataView + : undefined + } bookmarkedS3Uris={mainView.bookmarks.items.map( item => item.s3Uri )} diff --git a/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx index 22b44fa9e..56e1b1779 100644 --- a/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx +++ b/web/src/ui/pages/s3Explorer/dialogs/S3ExplorerDialogs.tsx @@ -28,6 +28,10 @@ import { S3ShareObjectDialog, type S3ShareObjectDialogProps } from "./S3ShareObjectDialog"; +import { + S3SharePrefixDialog, + type S3SharePrefixDialogProps +} from "./S3SharePrefixDialog"; import { MaybeAcknowledgeConfigVolatilityDialog, type MaybeAcknowledgeConfigVolatilityDialogProps @@ -43,6 +47,7 @@ export type S3ExplorerDialogsProps = { evtMakePrefixPublicDialogOpen: MakePrefixPublicDialogProps["evtOpen"]; evtDisplayErrorDialogOpen: DisplayErrorDialogProps["evtOpen"]; evtS3ShareObjectDialogOpen: S3ShareObjectDialogProps["evtOpen"]; + evtS3SharePrefixDialogOpen: S3SharePrefixDialogProps["evtOpen"]; evtMaybeAcknowledgeConfigVolatilityDialogOpen: MaybeAcknowledgeConfigVolatilityDialogProps["evtOpen"]; }; @@ -57,6 +62,7 @@ export function S3ExplorerDialogs(props: S3ExplorerDialogsProps) { evtMakePrefixPublicDialogOpen, evtDisplayErrorDialogOpen, evtS3ShareObjectDialogOpen, + evtS3SharePrefixDialogOpen, evtMaybeAcknowledgeConfigVolatilityDialogOpen } = props; @@ -75,6 +81,7 @@ export function S3ExplorerDialogs(props: S3ExplorerDialogsProps) { + diff --git a/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx index 650e3b041..e9c1bfcf8 100644 --- a/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx +++ b/web/src/ui/pages/s3Explorer/dialogs/S3ProfileDialog.tsx @@ -217,6 +217,7 @@ const S3ProfileDetails = withLoader<{ FallbackComponent: () => null, Component: ({ onCreateNewProfile, onEdit, onClose }) => { const mainView = useCoreState("s3ProfilesDetailsUiController", "mainView"); + const { isUserLoggedIn } = useCoreState("userAuthentication", "main"); const { functions: { s3ProfilesDetailsUiController } @@ -229,10 +230,10 @@ const S3ProfileDetails = withLoader<{ onSelectedProfileChange={ s3ProfilesDetailsUiController.updateSelectedS3Profile } - onCreateNewProfile={onCreateNewProfile} - onEdit={mainView.isReadonly ? undefined : onEdit} + onCreateNewProfile={isUserLoggedIn ? onCreateNewProfile : undefined} + onEdit={mainView.isReadonly || !isUserLoggedIn ? undefined : onEdit} onDelete={ - mainView.isReadonly + mainView.isReadonly || !isUserLoggedIn ? undefined : () => { s3ProfilesDetailsUiController.deleteProfile(); @@ -276,6 +277,7 @@ const S3ProfileForm = withLoader<{ FallbackComponent: () => null, Component: ({ onClose }) => { const mainView = useCoreState("s3ProfilesCreationUiController", "main"); + const { isUserLoggedIn } = useCoreState("userAuthentication", "main"); const { functions: { s3ProfilesCreationUiController } } = getCoreSync(); @@ -355,16 +357,14 @@ const S3ProfileForm = withLoader<{ }), errorMessage: mainView.formValuesErrors.sessionToken }} - onSubmit={(() => { - if (!mainView.isFormSubmittable) { - return undefined; - } - - return async () => { - await s3ProfilesCreationUiController.submit(); - onClose(); - }; - })()} + onSubmit={ + !isUserLoggedIn || !mainView.isFormSubmittable + ? undefined + : async () => { + await s3ProfilesCreationUiController.submit(); + onClose(); + } + } onCancel={onClose} /> ); diff --git a/web/src/ui/pages/s3Explorer/dialogs/S3SharePrefixDialog.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3SharePrefixDialog.tsx new file mode 100644 index 000000000..4bb0e0599 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/dialogs/S3SharePrefixDialog.tsx @@ -0,0 +1,77 @@ +import type { Evt, UnpackEvt } from "evt"; +import { useEvt } from "evt/hooks/useEvt"; +import { useState } from "react"; +import { Dialog } from "onyxia-ui/Dialog"; +import { S3SharePrefixDialog as S3SharePrefixDialog_headless } from "ui/shared/codex/S3SharePrefixDialog"; +import { declareComponentKeys, useTranslation } from "ui/i18n"; +import { routes } from "ui/routes"; +import { stringifyS3Uri, type S3Uri } from "core/tools/S3Uri"; +import { assert } from "tsafe"; + +export type S3SharePrefixDialogProps = { + evtOpen: Evt<{ + s3Uri: S3Uri.TerminatedByDelimiter; + anonymousProfileName: string; + }>; +}; + +export function S3SharePrefixDialog(props: S3SharePrefixDialogProps) { + return ; +} + +function S3SharePrefixDialogContainer(props: S3SharePrefixDialogProps) { + const { evtOpen } = props; + + const [state, setState] = useState< + UnpackEvt | undefined + >(undefined); + + useEvt( + ctx => { + evtOpen.attach(ctx, eventData => setState(eventData)); + }, + [evtOpen] + ); + + const { t } = useTranslation({ S3SharePrefixDialogContainer }); + + const body = (() => { + if (state === undefined) { + return undefined; + } + + const prefixBasename = state.s3Uri.keySegments.at(-1); + + assert(prefixBasename !== undefined); + + const onyxiaUrl = + window.location.origin + + routes.s3Explorer({ + s3UriWithoutScheme: stringifyS3Uri(state.s3Uri).slice("s3://".length), + profile: state.anonymousProfileName + }).link.href; + + return ( + + ); + })(); + + return ( + setState(undefined)} + showCloseButton + /> + ); +} + +const { i18n } = declareComponentKeys<"dialog title">()({ + S3SharePrefixDialogContainer +}); +export type I18n = typeof i18n; diff --git a/web/src/ui/pages/s3Explorer/dialogs/S3StorageDialogs.stories.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3StorageDialogs.stories.tsx deleted file mode 100644 index 8ad0e7a6c..000000000 --- a/web/src/ui/pages/s3Explorer/dialogs/S3StorageDialogs.stories.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { action } from "@storybook/addon-actions"; -import { useEffect, useMemo, useState } from "react"; -import { Dialog } from "onyxia-ui/Dialog"; -import { Evt } from "evt"; -import { parseS3Uri, type S3Uri } from "core/tools/S3Uri"; -import { - CreateOrRenameBookmarkDialog, - type CreateOrRenameBookmarkDialogProps -} from "./CreateOrRenameBookmarkDialog"; -import { - DirectoryCreationDialog, - type DirectoryCreationDialogProps -} from "./DirectoryCreationDialog"; -import { - MakePrefixPublicDialog, - type MakePrefixPublicDialogProps, - type PrefixPolicyAction -} from "./MakePrefixPublicDialog"; -import { - S3ShareObjectDialog, - type S3ShareObjectDialogProps -} from "ui/shared/codex/S3ShareObjectDialog"; -import { useS3DialogClasses } from "ui/shared/codex/S3DialogPrimitives"; -import { DeleteSelectionDialog } from "ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView"; - -const meta = { - title: "Pages/S3 Explorer/Storage modals", - parameters: { - layout: "fullscreen" - } -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -const prefixS3Uri = parsePrefixOrThrow("s3://marchufschmitt/edefede/untitled_folder/"); -const objectUrl = "https://minio.lab.sspcloud.fr/garronej/good_fortnite_game/.DS_Store"; -const signedObjectUrl = - "https://minio.lab.sspcloud.fr/garronej/WhatsApp%20Image%202026-02-15%20at%2017.34.19.jpeg"; -const signedUrl = [ - signedObjectUrl, - "?X-Amz-Algorithm=AWS4-HMAC-SHA256", - "&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD", - "&X-Amz-Credential=X53J0VKTNO4PHEHSO3BG%2F20260604%2Fus-east-1%2Fs3%2Faws4_request", - "&X-Amz-Date=20260604T085902Z", - "&X-Amz-Expires=86400", - `&X-Amz-Security-Token=${getLongSecurityToken()}`, - "&X-Amz-Signature=efebd3e43c23cc0d59d39d4554e860dd55bf09c23f815190cf94080fce515faf", - "&X-Amz-SignedHeaders=host", - "&x-amz-checksum-mode=ENABLED", - "&x-id=GetObject" -].join(""); - -export const AddBookmark: Story = { - render: () => -}; - -export const CreatePrefix: Story = { - render: () => -}; - -export const SharePublicObject: Story = { - render: () => ( - - ) -}; - -export const ShareSignedObject: Story = { - render: () => ( - - ) -}; - -export const MakePrefixPublic: Story = { - render: () => -}; - -export const MakePrefixPrivate: Story = { - render: () => -}; - -export const DeleteSelection: Story = { - render: () => ( - - ) -}; - -function getLongSecurityToken(): string { - return [ - "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9", - "eyJhY2Nlc3NLZXkiOiJYNTNKMFZLVE5PNFBIRUhTTzNCRyIsImFsbG93ZWQtb3JpZ2lucyI6WyIqIl0sImF1ZCI6WyJtaW5pby1kYXRhbm9kZSIsIm9ueXhpYSIsInBvbGFyaXMiLCJhY2NvdW50Il0sImVtYWlsIjoiamFuZS5kb2VAZXhhbXBsZS5vcmciLCJleHAiOjE3ODExNjY2MDksIm5hbWUiOiJKYW5lIERvZSIsInByZWZlcnJlZF91c2VybmFtZSI6ImphbmUiLCJzY29wZSI6Im9wZW5pZCBwcm9maWxlIGdyb3VwcyBlbWFpbCIsInR5cCI6IkJlYXJlciJ9", - "QptTLB2BwHiYq10nwRlJ4qJpKRrPiTrZKM1kdYw8aEKQ3KKouAWddhlP45LW5reQwMMubHCI8cHwW7MNmblA" - ].join("."); -} - -function OpenBookmarkDialog() { - const evtOpen = useMemo( - () => - Evt.create<{ - s3Uri: S3Uri; - currentDisplayName: string | undefined; - resolveDoProceed: ( - result: - | { doProceed: true; displayName: string } - | { doProceed: false } - ) => void; - }>(), - [] - ); - - useEffect(() => { - evtOpen.post({ - s3Uri: prefixS3Uri, - currentDisplayName: undefined, - resolveDoProceed: action("bookmarkDialogResult") - }); - }, [evtOpen]); - - return ; -} - -function OpenDirectoryCreationDialog() { - const evtOpen = useMemo( - () => - Evt.create<{ - exclude: string[]; - resolveDoProceed: ( - result: - | { doProceed: true; prefixSegment: string } - | { doProceed: false } - ) => void; - }>(), - [] - ); - - useEffect(() => { - evtOpen.post({ - exclude: [], - resolveDoProceed: action("directoryCreationDialogResult") - }); - }, [evtOpen]); - - return ; -} - -function OpenPrefixPolicyDialog(props: { action: PrefixPolicyAction }) { - const { action: policyAction } = props; - - const evtOpen = useMemo( - () => - Evt.create<{ - s3Uri: S3Uri.TerminatedByDelimiter; - action?: PrefixPolicyAction; - resolveDoProceed: (doProceed: boolean) => void; - }>(), - [] - ); - - useEffect(() => { - evtOpen.post({ - s3Uri: prefixS3Uri, - action: policyAction, - resolveDoProceed: action("prefixPolicyDialogResult") - }); - }, [evtOpen, policyAction]); - - return ; -} - -function ShareObjectModal( - props: - | (Pick< - S3ShareObjectDialogProps.Public, - "isPublic" | "httpUrl" | "objectBasename" - > & { - isPublic: true; - }) - | (Pick< - S3ShareObjectDialogProps.Private, - "isPublic" | "httpUrl" | "objectBasename" - > & { - isPublic: false; - }) -) { - const dialogClasses = useS3DialogClasses(); - const [validityDuration, setValidityDuration] = - useState("one day"); - - return ( - - ) : ( - { - action("changeValidityDuration")(validityDuration); - setValidityDuration(validityDuration); - }} - /> - ) - } - buttons={<>} - /> - ); -} - -function parsePrefixOrThrow(value: string): S3Uri.TerminatedByDelimiter { - const s3Uri = parseS3Uri({ - value, - delimiter: "/" - }); - - if (!s3Uri.isDelimiterTerminated) { - throw new Error(`Expected a delimiter-terminated S3 URI: ${value}`); - } - - return s3Uri; -} - -function parseObjectOrThrow(value: string): S3Uri.NonTerminatedByDelimiter { - const s3Uri = parseS3Uri({ - value, - delimiter: "/" - }); - - if (s3Uri.isDelimiterTerminated) { - throw new Error(`Expected a non delimiter-terminated S3 URI: ${value}`); - } - - return s3Uri; -} diff --git a/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksBar/S3BookmarksBar.spec.md b/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksBar/S3BookmarksBar.spec.md index ec8d682f1..b1fd8562c 100644 --- a/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksBar/S3BookmarksBar.spec.md +++ b/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksBar/S3BookmarksBar.spec.md @@ -15,8 +15,10 @@ export type S3BookmarksBarProps = { className?: string; items: S3BookmarksBarProps.Item[]; activeItemS3Uri: S3Uri | undefined; - onDelete: (props: { s3Uri: S3Uri }) => void; - onRename: (props: { s3Uri: S3Uri; currentDisplayName: string | undefined }) => void; + onDelete: ((props: { s3Uri: S3Uri }) => void) | undefined; + onRename: + | ((props: { s3Uri: S3Uri; currentDisplayName: string | undefined }) => void) + | undefined; getItemLink: (props: { s3Uri: S3Uri }) => Link; showItemIcons?: boolean; showLeadingIcon?: boolean; @@ -55,7 +57,7 @@ For each entry in props.items, the component renders one `S3BookmarkItem` with: - `link` from `props.getItemLink({ s3Uri: item.s3Uri })` - `isActive` set to true when `item.s3Uri === props.activeItemS3Uri` -If `item.isReadonly === true, callbacks` must be undefined. +If `item.isReadonly === true`, or either mutation callback is undefined, `callbacks` must be undefined. Otherwise `callbacks` must be: diff --git a/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksBar/S3BookmarksBar.tsx b/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksBar/S3BookmarksBar.tsx index 9c1d5dd11..6971fffdf 100644 --- a/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksBar/S3BookmarksBar.tsx +++ b/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksBar/S3BookmarksBar.tsx @@ -21,8 +21,10 @@ export type S3BookmarksBarProps = { className?: string; items: S3BookmarksBarProps.Item[]; activeItemS3Uri: S3Uri | undefined; - onDelete: (props: { s3Uri: S3Uri }) => void; - onRename: (props: { s3Uri: S3Uri; currentDisplayName: string | undefined }) => void; + onDelete: ((props: { s3Uri: S3Uri }) => void) | undefined; + onRename: + | ((props: { s3Uri: S3Uri; currentDisplayName: string | undefined }) => void) + | undefined; getItemLink: (props: { s3Uri: S3Uri }) => Link; showItemIcons?: boolean; showLeadingIcon?: boolean; @@ -81,7 +83,7 @@ export function S3BookmarksBar(props: S3BookmarksBarProps) { const resolveCallbacks = useCallback( (item: S3BookmarksBarProps.Item) => - item.isReadonly + item.isReadonly || onDelete === undefined || onRename === undefined ? undefined : { onDelete: () => onDelete({ s3Uri: item.s3Uri }), diff --git a/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksEntryPointItem/S3BookmarksEntryPointItem.spec.md b/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksEntryPointItem/S3BookmarksEntryPointItem.spec.md index dd1a481a0..74c719b8f 100644 --- a/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksEntryPointItem/S3BookmarksEntryPointItem.spec.md +++ b/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksEntryPointItem/S3BookmarksEntryPointItem.spec.md @@ -17,8 +17,10 @@ export type S3BookmarksEntryPointListProps = { className?: string; items: S3BookmarksEntryPointListProps.Item[]; activeItemS3Uri: S3Uri | undefined; - onDelete: (props: { s3Uri: S3Uri }) => void; - onRename: (props: { s3Uri: S3Uri; currentDisplayName: string | undefined }) => void; + onDelete: ((props: { s3Uri: S3Uri }) => void) | undefined; + onRename: + | ((props: { s3Uri: S3Uri; currentDisplayName: string | undefined }) => void) + | undefined; getItemLink: (props: { s3Uri: S3Uri }) => Link; }; @@ -55,7 +57,7 @@ For each entry in props.items, the component renders one `S3BookmarkItem` with: - `link` from `props.getItemLink({ s3Uri: item.s3Uri })` - `isActive` set to true when `item.s3Uri === props.activeItemS3Uri` -If `item.isReadonly === true`, `callbacks` must be `undefined`. +If `item.isReadonly === true`, or either mutation callback is undefined, `callbacks` must be `undefined`. Otherwise `callbacks` must be: diff --git a/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksEntryPointItem/S3BookmarksEntryPointItem.tsx b/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksEntryPointItem/S3BookmarksEntryPointItem.tsx index bb3e17a01..0a9bf7782 100644 --- a/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksEntryPointItem/S3BookmarksEntryPointItem.tsx +++ b/web/src/ui/shared/codex/S3Bookmarks/S3BookmarksEntryPointItem/S3BookmarksEntryPointItem.tsx @@ -9,8 +9,10 @@ export type S3BookmarksEntryPointListProps = { className?: string; items: S3BookmarksEntryPointListProps.Item[]; activeItemS3Uri: S3Uri | undefined; - onDelete: (props: { s3Uri: S3Uri }) => void; - onRename: (props: { s3Uri: S3Uri; currentDisplayName: string | undefined }) => void; + onDelete: ((props: { s3Uri: S3Uri }) => void) | undefined; + onRename: + | ((props: { s3Uri: S3Uri; currentDisplayName: string | undefined }) => void) + | undefined; getItemLink: (props: { s3Uri: S3Uri }) => Link; }; @@ -46,7 +48,9 @@ export function S3BookmarksEntryPointList(props: S3BookmarksEntryPointListProps) s3Uri={item.s3Uri} link={link} callbacks={ - item.isReadonly + item.isReadonly || + onDelete === undefined || + onRename === undefined ? undefined : { onDelete: () => onDelete({ s3Uri: item.s3Uri }), diff --git a/web/src/ui/shared/codex/S3DialogPrimitives/S3DialogPrimitives.tsx b/web/src/ui/shared/codex/S3DialogPrimitives/S3DialogPrimitives.tsx index 04bed6096..3e996a9f8 100644 --- a/web/src/ui/shared/codex/S3DialogPrimitives/S3DialogPrimitives.tsx +++ b/web/src/ui/shared/codex/S3DialogPrimitives/S3DialogPrimitives.tsx @@ -109,6 +109,33 @@ export function S3DialogCopyUrlField(props: { ); } +export function S3DialogCopyPlainUrlField(props: { + value: string; + copyLabel?: string; + ariaLabel: string; + onCopied?: () => void; +}) { + const { classes } = useStyles_S3DialogCopyField(); + + return ( + ( + + {value} + + )} + /> + ); +} + function S3DialogCopyFieldBase(props: { value: string | undefined; pendingText?: string; @@ -607,6 +634,23 @@ const useStyles_S3DialogCopyField = tss borderRadius: 4 } }, + plainUrlPreview: { + display: "block", + minWidth: 0, + color: theme.colors.useCases.typography.textFocus, + textDecoration: "none", + overflowWrap: "anywhere", + wordBreak: "break-word", + whiteSpace: "normal", + "&:hover": { + textDecoration: "underline" + }, + "&:focus-visible": { + outline: `2px solid ${theme.colors.useCases.typography.textFocus}`, + outlineOffset: 2, + borderRadius: 4 + } + }, urlLine: { display: "flex", alignItems: "baseline", diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md index de05b25cb..daa33c20f 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md @@ -65,7 +65,12 @@ export type S3ExplorerMainViewProps = { onShareObject: (params: { s3Uri: S3Uri.NonTerminatedByDelimiter }) => void; - onBookmark: (params: { s3Uri: S3Uri }) => void; + onSharePrefix: (params: { + s3Uri: S3Uri.TerminatedByDelimiter; + anonymousProfileName: string; + }) => void; + + onBookmark: ((params: { s3Uri: S3Uri }) => void) | undefined; onDisplayCopyFeedback: (params: { s3Uri: S3Uri }) => void; @@ -95,6 +100,7 @@ export namespace S3ExplorerMainViewProps { type: "prefix segment"; s3Uri: S3Uri.TerminatedByDelimiter; policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; + profileNameForSharing: string | undefined; }; export type Object = Common & { @@ -207,6 +213,8 @@ The component renders `S3SelectionActionBar` above the list. - `accessPolicy` - `onClear` +When `onBookmark` is undefined, the bookmark action object remains present for a single actionable item, with its callback set to undefined. + When `listedPrefix.isFullyQualifiedUri === true`, `onClear` must be passed as `undefined` so the selection action bar does not expose a clear-selection control. It must pass `undefined` for selection action objects that do not make sense @@ -214,7 +222,8 @@ for the current selection: - download is available when every selected item is not deleting and does not have an unfinished upload progress state -- share is available only for one selected object +- share is available for one selected object or one prefix whose + `profileNameForSharing` is defined - make public is available only for one selected private prefix whose `policy.canBeMadePublic === true` - make private is available only for one selected public prefix @@ -253,6 +262,7 @@ Typical row actions include: - Copy S3 path - Delete - Overflow menu +- Bookmark, rendered disabled when `onBookmark` is undefined - Row action rules - Actions appear on focus only when: - the row is selected, or @@ -300,13 +310,19 @@ onChangePrefixPolicy({ ### Share -Share is available as a row action only for object rows when the item is not -deleting and does not have an unfinished upload progress state. +Share is available as a row action for object rows and prefix rows whose +`profileNameForSharing` is defined, provided that the item is not deleting and does +not have an unfinished upload progress state. Clicking Share triggers: ```ts onShareObject({ s3Uri: item.s3Uri }); + +onSharePrefix({ + s3Uri: item.s3Uri, + anonymousProfileName: item.profileNameForSharing +}); ``` The resulting UI or side effect is owned by the caller. diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx index ee0d41c2e..93a31d453 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx @@ -21,6 +21,7 @@ type MockNode = uploadProgressPercent: number | undefined; isDeleting: boolean; policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; + profileNameForSharing: string | undefined; } | { type: "object"; @@ -74,21 +75,24 @@ const baseNodes: MockNode[] = [ s3Uri: parsePrefixOrThrow("s3://analytics-data/exports/"), uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: true } + policy: { isPublic: true }, + profileNameForSharing: "anonymous" }, { type: "prefix segment", s3Uri: parsePrefixOrThrow("s3://analytics-data/raw/"), uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: false, canBeMadePublic: true } + policy: { isPublic: false, canBeMadePublic: true }, + profileNameForSharing: undefined }, { type: "prefix segment", s3Uri: parsePrefixOrThrow("s3://analytics-data/tmp/"), uploadProgressPercent: 42, isDeleting: false, - policy: { isPublic: false, canBeMadePublic: false } + policy: { isPublic: false, canBeMadePublic: false }, + profileNameForSharing: undefined }, { type: "object", @@ -122,14 +126,16 @@ const nestedNodes: MockNode[] = [ s3Uri: parsePrefixOrThrow("s3://analytics-data/exports/2024/"), uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: false, canBeMadePublic: true } + policy: { isPublic: false, canBeMadePublic: true }, + profileNameForSharing: undefined }, { type: "prefix segment", s3Uri: parsePrefixOrThrow("s3://analytics-data/exports/2025/"), uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: true } + policy: { isPublic: true }, + profileNameForSharing: "anonymous" }, { type: "object", @@ -164,6 +170,7 @@ const placeholderArgs: S3ExplorerMainViewProps = { onDelete: action("delete"), onDownload: action("download"), onShareObject: action("shareObject"), + onSharePrefix: action("sharePrefix"), onBookmark: action("bookmark"), bookmarkedS3Uris: [], onChangePrefixPolicy: action("changePrefixPolicy"), @@ -228,6 +235,7 @@ function StatefulExplorer( | "onPutObjects" | "onDownload" | "onShareObject" + | "onSharePrefix" | "onBookmark" | "bookmarkedS3Uris" | "onChangePrefixPolicy" @@ -283,7 +291,8 @@ function StatefulExplorer( }, uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: false, canBeMadePublic: true } + policy: { isPublic: false, canBeMadePublic: true }, + profileNameForSharing: undefined } ]); }} @@ -340,6 +349,9 @@ function StatefulExplorer( onShareObject={({ s3Uri }) => { action("shareObject")(s3Uri); }} + onSharePrefix={params => { + action("sharePrefix")(params); + }} onBookmark={({ s3Uri }) => { action("bookmark")(s3Uri); }} @@ -419,6 +431,7 @@ export const EmptyPrefix: Story = { onDelete: action("delete"), onDownload: action("download"), onShareObject: action("shareObject"), + onSharePrefix: action("sharePrefix"), onBookmark: action("bookmark"), bookmarkedS3Uris: [], onChangePrefixPolicy: action("changePrefixPolicy"), @@ -496,6 +509,7 @@ export const FullyQualifiedObject: Story = { onDelete: action("delete"), onDownload: action("download"), onShareObject: action("shareObject"), + onSharePrefix: action("sharePrefix"), onBookmark: action("bookmark"), bookmarkedS3Uris: [], onChangePrefixPolicy: action("changePrefixPolicy"), @@ -525,6 +539,7 @@ export const AccessDenied: Story = { onDelete: action("delete"), onDownload: action("download"), onShareObject: action("shareObject"), + onSharePrefix: action("sharePrefix"), onBookmark: action("bookmark"), bookmarkedS3Uris: [], onChangePrefixPolicy: action("changePrefixPolicy"), diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx index 03973f13a..e1689e15a 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx @@ -82,7 +82,12 @@ export type S3ExplorerMainViewProps = { onShareObject: (params: { s3Uri: S3Uri.NonTerminatedByDelimiter }) => void; - onBookmark: (params: { s3Uri: S3Uri }) => void; + onSharePrefix: (params: { + s3Uri: S3Uri.TerminatedByDelimiter; + anonymousProfileName: string; + }) => void; + + onBookmark: ((params: { s3Uri: S3Uri }) => void) | undefined; onDisplayCopyFeedback: (params: { s3Uri: S3Uri }) => void; @@ -112,6 +117,7 @@ export namespace S3ExplorerMainViewProps { type: "prefix segment"; s3Uri: S3Uri.TerminatedByDelimiter; policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; + profileNameForSharing: string | undefined; }; export type Object = Common & { @@ -138,6 +144,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { onDelete, onDownload, onShareObject, + onSharePrefix, onBookmark, bookmarkedS3Uris, onChangePrefixPolicy, @@ -373,10 +380,6 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { const selectedItemForSingleItemAction = selectedItems.length === 1 ? selectedItems[0] : undefined; - const selectedObjectForSingleItemAction = - selectedItemForSingleItemAction?.type === "object" - ? selectedItemForSingleItemAction - : undefined; const selectedPrefixForSingleItemAction = selectedItemForSingleItemAction?.type === "prefix segment" ? selectedItemForSingleItemAction @@ -526,17 +529,29 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { } ); - const requestShareForObject = useConstCallback( - (item: S3ExplorerMainViewProps.Item.Object) => { - if (!getIsItemActionAvailable(item)) { + const requestShareForItem = useConstCallback((item: S3ExplorerMainViewProps.Item) => { + if (!getIsItemActionAvailable(item)) { + return; + } + + switch (item.type) { + case "object": + onShareObject({ + s3Uri: item.s3Uri + }); return; - } + case "prefix segment": + if (item.profileNameForSharing === undefined) { + return; + } - onShareObject({ - s3Uri: item.s3Uri - }); + onSharePrefix({ + s3Uri: item.s3Uri, + anonymousProfileName: item.profileNameForSharing + }); + return; } - ); + }); const requestPrefixPolicyChangeForItem = useConstCallback( (item: S3ExplorerMainViewProps.Item.PrefixSegment) => { @@ -585,9 +600,8 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { const requestBookmarkForItem = useConstCallback( (item: S3ExplorerMainViewProps.Item) => { - if (!getIsItemActionAvailable(item)) { - return; - } + assert(onBookmark !== undefined); + assert(getIsItemActionAvailable(item)); onBookmark({ s3Uri: item.s3Uri @@ -629,14 +643,14 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { requestDeletionForItems([item]); }); - const onShareObjectFactory = useCallbackFactory(([itemKey]: [string]) => { + const onShareFactory = useCallbackFactory(([itemKey]: [string]) => { const item = itemByKey.get(itemKey); - if (item === undefined || item.type !== "object") { + if (item === undefined) { return; } - requestShareForObject(item); + requestShareForItem(item); }); const onChangePrefixPolicyFactory = useCallbackFactory(([itemKey]: [string]) => { @@ -788,10 +802,13 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { !getIsItemActionAvailable(selectedItemForSingleItemAction) ? undefined : { - callback: () => - requestBookmarkForItem( - selectedItemForSingleItemAction - ), + callback: + onBookmark !== undefined + ? () => + requestBookmarkForItem( + selectedItemForSingleItemAction + ) + : undefined, isBookmarked: bookmarkedItemKeySet.has( stringifyS3Uri( selectedItemForSingleItemAction.s3Uri @@ -800,15 +817,19 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { } } share={ - selectedObjectForSingleItemAction === undefined || + selectedItemForSingleItemAction === undefined || !getIsItemActionAvailable( - selectedObjectForSingleItemAction - ) + selectedItemForSingleItemAction + ) || + (selectedItemForSingleItemAction.type === + "prefix segment" && + selectedItemForSingleItemAction.profileNameForSharing === + undefined) ? undefined : { callback: () => - requestShareForObject( - selectedObjectForSingleItemAction + requestShareForItem( + selectedItemForSingleItemAction ) } } @@ -1128,11 +1149,11 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { itemKey )} onDelete={onDeleteFactory(itemKey)} - onShareObject={ - item.type === "object" - ? onShareObjectFactory( - itemKey - ) + onShare={ + item.type === "object" || + item.profileNameForSharing !== + undefined + ? onShareFactory(itemKey) : undefined } onChangePrefixPolicy={ @@ -1148,6 +1169,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { itemKey )} onBookmark={ + onBookmark !== undefined && getIsItemActionAvailable(item) ? onBookmarkFactory(itemKey) : undefined @@ -2422,7 +2444,7 @@ type ItemRowProps = { onRowClick: (event: MouseEvent) => void; onNavigate: () => void; onDelete: () => void; - onShareObject: (() => void) | undefined; + onShare: (() => void) | undefined; onChangePrefixPolicy: (() => void) | undefined; onDownload: (() => void) | undefined; onBookmark: (() => void) | undefined; @@ -2444,7 +2466,7 @@ const ItemRow = memo(function ItemRow(props: ItemRowProps) { onRowClick, onNavigate, onDelete, - onShareObject, + onShare, onChangePrefixPolicy, onDownload, onBookmark, @@ -2457,7 +2479,7 @@ const ItemRow = memo(function ItemRow(props: ItemRowProps) { const canNavigate = !item.isDeleting && !(item.type === "object" && isUploading); const isItemActionAvailable = getIsItemActionAvailable(item); const isDownloadAvailable = onDownload !== undefined && isItemActionAvailable; - const isShareAvailable = onShareObject !== undefined && isItemActionAvailable; + const isShareAvailable = onShare !== undefined && isItemActionAvailable; const prefixPolicyAction = getPrefixPolicyAction(item); const isPrefixPolicyActionAvailable = onChangePrefixPolicy !== undefined && isItemActionAvailable; @@ -2755,51 +2777,49 @@ const ItemRow = memo(function ItemRow(props: ItemRowProps) { /> - {onBookmark !== undefined && ( - + - { + event.stopPropagation(); + assert(isItemActionAvailable); + assert(onBookmark !== undefined); + onBookmark(); + }} > - - - - )} - {onShareObject !== undefined && ( + {isBookmarked ? ( + + ) : ( + + )} + + + + {onShare !== undefined && ( @@ -2933,7 +2953,7 @@ function areItemRowPropsEqual( previousProps.onRowClick === nextProps.onRowClick && previousProps.onNavigate === nextProps.onNavigate && previousProps.onDelete === nextProps.onDelete && - previousProps.onShareObject === nextProps.onShareObject && + previousProps.onShare === nextProps.onShare && previousProps.onChangePrefixPolicy === nextProps.onChangePrefixPolicy && previousProps.onDownload === nextProps.onDownload && previousProps.onBookmark === nextProps.onBookmark && diff --git a/web/src/ui/shared/codex/S3ProfileSelect/S3ProfilSelect.spec.md b/web/src/ui/shared/codex/S3ProfileSelect/S3ProfilSelect.spec.md index f0bbb7019..76b1b4940 100644 --- a/web/src/ui/shared/codex/S3ProfileSelect/S3ProfilSelect.spec.md +++ b/web/src/ui/shared/codex/S3ProfileSelect/S3ProfilSelect.spec.md @@ -27,7 +27,7 @@ type S3ProfileSelectProps = { /** Opens the settings/details view for the currently selected profile */ onEditProfile: () => void; - onCreateNewProfile: () => void; + onCreateNewProfile: (() => void) | undefined; }; ``` @@ -106,6 +106,8 @@ Clicking `New S3 Profile`: - closes the dropdown, - calls `props.onCreateNewProfile().`@ +The action row is not rendered when `onCreateNewProfile` is undefined. + ### Outside click behavior Clicking outside the component closes the dropdown. diff --git a/web/src/ui/shared/codex/S3ProfileSelect/S3ProfileSelect.tsx b/web/src/ui/shared/codex/S3ProfileSelect/S3ProfileSelect.tsx index a48575739..237367f2b 100644 --- a/web/src/ui/shared/codex/S3ProfileSelect/S3ProfileSelect.tsx +++ b/web/src/ui/shared/codex/S3ProfileSelect/S3ProfileSelect.tsx @@ -21,7 +21,7 @@ export type S3ProfileSelectProps = { /** Opens the settings/details view for the currently selected profile */ onEditProfile: () => void; - onCreateNewProfile: () => void; + onCreateNewProfile: (() => void) | undefined; }; export function S3ProfileSelect(props: S3ProfileSelectProps) { @@ -102,11 +102,6 @@ export function S3ProfileSelect(props: S3ProfileSelectProps) { onSelectedProfileChange({ profileName }); }; - const handleCreateNewProfile = () => { - setIsOpen(false); - onCreateNewProfile(); - }; - return (
-
- + {onCreateNewProfile !== undefined && ( + <> +
+ + + )}
)}
diff --git a/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.spec.md b/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.spec.md index d7f341091..2d4743c87 100644 --- a/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.spec.md +++ b/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.spec.md @@ -37,7 +37,7 @@ type S3SelectionActionBarProps = { bookmark: | { - callback: () => void; + callback: (() => void) | undefined; isBookmarked: boolean; } | undefined; @@ -71,6 +71,8 @@ A section displaying the available actions, aligned to the left of the bar The layout is horizontal and must remain on a single line. +The bookmark action remains visible but disabled when its `callback` is undefined. + # Rendering rules The component is rendered only when: @@ -123,7 +125,7 @@ Each action button is rendered only when its action object prop is defined. - Share → rendered when `share !== undefined` - Access policy → rendered when `accessPolicy !== undefined` -Clicking a rendered action calls the matching action object's `callback`, except for Copy S3 URI. +Clicking an enabled rendered action calls the matching action object's `callback`, except for Copy S3 URI. A bookmark whose callback is undefined is disabled and cannot be clicked. Clicking Copy S3 URI copies `copyS3Uri.s3UriStr` to the clipboard by using `copyToClipboard(copyS3Uri.s3UriStr)`, then shows the copied confirmation state. diff --git a/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.tsx b/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.tsx index e1ebf30e2..36a1c140c 100644 --- a/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.tsx +++ b/web/src/ui/shared/codex/S3SelectionActionBar/S3SelectionActionBar.tsx @@ -30,7 +30,7 @@ export type S3SelectionActionBarProps = { | undefined; bookmark: | { - callback: () => void; + callback: (() => void) | undefined; isBookmarked: boolean; } | undefined; @@ -51,16 +51,12 @@ type Action = { key: string; label: string; icon: ReactElement; - onClick: () => void; + onClick: (() => void) | undefined; tooltipTitle?: ReactNode; tooltipClassName?: string; isActive?: boolean; }; -type OptionalAction = Omit & { - onClick: (() => void) | undefined; -}; - export function S3SelectionActionBar(props: S3SelectionActionBarProps) { const { className, @@ -103,105 +99,111 @@ export function S3SelectionActionBar(props: S3SelectionActionBarProps) { ); - const actionCandidates: OptionalAction[] = [ - { - key: "download", - label: t("download"), - icon: ( - - ), - onClick: download?.callback - }, - { - key: "delete", - label: t("delete"), - icon: ( - - ), - onClick: deleteAction?.callback - }, - { - key: "copy", - label: t("copy s3 uri"), - icon: ( - - ), - onClick: - copyS3Uri === undefined - ? undefined - : () => { - setIsS3UriCopied(true); - copyS3Uri.callback(); - }, - tooltipTitle: - copyS3Uri === undefined ? undefined : isS3UriCopied ? ( - copiedTooltipTitle - ) : ( - - {t("copy s3 uri tooltip", { - s3UriStr: copyS3Uri.s3UriStr - })} - - ), - tooltipClassName: classes.copyTooltipBubble - }, - { - key: "bookmark", - label: - bookmark?.isBookmarked === true - ? t("delete from bookmarks") - : t("add to bookmarks"), - icon: - bookmark?.isBookmarked === true ? ( - - ) : ( - - ), - onClick: bookmark?.callback, - isActive: bookmark?.isBookmarked === true - }, - { - key: "share", - label: t("share"), - icon: ( - - ), - onClick: share?.callback - }, - { - key: "access-policy", - label: accessPolicy?.isPublic === true ? t("make private") : t("make public"), - icon: ( - - ), - onClick: accessPolicy?.callback - } + const actionCandidates: (Action | undefined)[] = [ + download === undefined + ? undefined + : { + key: "download", + label: t("download"), + icon: ( + + ), + onClick: download.callback + }, + deleteAction === undefined + ? undefined + : { + key: "delete", + label: t("delete"), + icon: ( + + ), + onClick: deleteAction.callback + }, + copyS3Uri === undefined + ? undefined + : { + key: "copy", + label: t("copy s3 uri"), + icon: ( + + ), + onClick: () => { + setIsS3UriCopied(true); + copyS3Uri.callback(); + }, + tooltipTitle: isS3UriCopied ? ( + copiedTooltipTitle + ) : ( + + {t("copy s3 uri tooltip", { + s3UriStr: copyS3Uri.s3UriStr + })} + + ), + tooltipClassName: classes.copyTooltipBubble + }, + bookmark === undefined + ? undefined + : { + key: "bookmark", + label: bookmark.isBookmarked + ? t("delete from bookmarks") + : t("add to bookmarks"), + icon: bookmark.isBookmarked ? ( + + ) : ( + + ), + onClick: bookmark.callback, + isActive: bookmark.isBookmarked + }, + share === undefined + ? undefined + : { + key: "share", + label: t("share"), + icon: ( + + ), + onClick: share.callback + }, + accessPolicy === undefined + ? undefined + : { + key: "access-policy", + label: accessPolicy.isPublic ? t("make private") : t("make public"), + icon: ( + + ), + onClick: accessPolicy.callback + } ]; const actions = actionCandidates.filter( - (action): action is Action => action.onClick !== undefined + (action): action is Action => action !== undefined ); const selectedLabel = @@ -238,8 +240,13 @@ export function S3SelectionActionBar(props: S3SelectionActionBarProps) { const button = (
)} - {!isUndefinedPrefixMode && (onToggleBookmark || isBookmarked) && ( + {!isUndefinedPrefixMode && ( { event.stopPropagation(); assert(currentS3Uri !== undefined); - onToggleBookmark?.({ s3Uri: currentS3Uri }); + assert(onToggleBookmark !== undefined); + onToggleBookmark({ s3Uri: currentS3Uri }); }} disabled={!onToggleBookmark} className={cx( @@ -2075,7 +2076,7 @@ type NavigationCrumbItem = { isPublicStart: boolean; }; -const longPressDelayMs = 200; +const longPressDelayMs = 270; const hintsPanelHorizontalEdgePaddingPx = 8; const hintsPanelVerticalOffsetPx = 6; const hintsPanelFallbackWidthPx = 280; diff --git a/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.spec.md b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.spec.md index 760197794..2f5c92f9a 100644 --- a/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.spec.md +++ b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.spec.md @@ -6,7 +6,7 @@ - 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`. +- Let the user start profile creation when `onCreateNewProfile` is defined. - Expose the edit action when available, and render it disabled when the selected profile is read-only. - Expose the delete action next to edit when `onDelete` is provided. - Display connection fields for the endpoint URL and the default region when a default region exists. @@ -35,3 +35,5 @@ The parent must provide at least one profile name and keep `profileName` synchro Credential copy buttons always copy the raw credential value even though the rendered text is shortened. When `onDelete` is undefined, no delete affordance is rendered. When `onDelete` is defined, the delete button invokes it without owning confirmation or persistence logic. + +When `onCreateNewProfile` is undefined, no profile-creation affordance is rendered. diff --git a/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.tsx b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.tsx index 8739bc2a7..ab691dbb0 100644 --- a/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.tsx +++ b/web/src/ui/shared/codex/s3ProfileDialog/S3ProfileDetails/S3ProfileDetails.tsx @@ -34,7 +34,7 @@ export type Props = { onSelectedProfileChange: (params: { profileName: string }) => void; - onCreateNewProfile: () => void; + onCreateNewProfile: (() => void) | undefined; onEdit: (() => void) | undefined; @@ -345,7 +345,7 @@ function ProfileDropdown(props: { availableProfileNames: string[]; profileName: string; onSelectedProfileChange: (params: { profileName: string }) => void; - onCreateNewProfile: () => void; + onCreateNewProfile: (() => void) | undefined; }) { const { availableProfileNames, @@ -415,11 +415,6 @@ function ProfileDropdown(props: { onSelectedProfileChange({ profileName: nextProfileName }); }; - const handleCreateNewProfile = () => { - setIsOpen(false); - onCreateNewProfile(); - }; - return (
+ {onCreateNewProfile !== undefined && ( + <> +
+ + + )}
)}