From c2993ebebbf44f70e1b79f1c79b43771d1259b84 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Wed, 19 Aug 2026 17:28:01 +0000 Subject: [PATCH 01/15] Implement share feature for public folders --- web/src/core/ports/OnyxiaApi/S3Config.ts | 4 + .../computeUploadStatusAtPrefix.ts | 1 + .../s3ExplorerUiController/selectors.ts | 112 +++++++++++++++--- .../decoupledLogic/s3Profiles.ts | 44 +++++-- web/src/ui/i18n/resources/de.tsx | 8 ++ web/src/ui/i18n/resources/en.tsx | 8 ++ web/src/ui/i18n/resources/es.tsx | 8 ++ web/src/ui/i18n/resources/fi.tsx | 8 ++ web/src/ui/i18n/resources/fr.tsx | 8 ++ web/src/ui/i18n/resources/it.tsx | 8 ++ web/src/ui/i18n/resources/nl.tsx | 8 ++ web/src/ui/i18n/resources/no.tsx | 8 ++ web/src/ui/i18n/resources/zh-CN.tsx | 8 ++ web/src/ui/i18n/types.ts | 2 + web/src/ui/pages/s3Explorer/Page.tsx | 11 ++ .../s3Explorer/dialogs/S3ExplorerDialogs.tsx | 7 ++ .../dialogs/S3SharePrefixDialog.tsx | 58 +++++++++ .../dialogs/S3StorageDialogs.stories.tsx | 3 +- .../S3ExplorerMainView.spec.md | 27 ++++- .../S3ExplorerMainView.stories.tsx | 43 ++++++- .../S3ExplorerMainView/S3ExplorerMainView.tsx | 91 +++++++++----- .../S3SharePrefixDialog.tsx | 102 ++++++++++++++++ .../shared/codex/S3SharePrefixDialog/index.ts | 1 + 23 files changed, 511 insertions(+), 67 deletions(-) create mode 100644 web/src/ui/pages/s3Explorer/dialogs/S3SharePrefixDialog.tsx create mode 100644 web/src/ui/shared/codex/S3SharePrefixDialog/S3SharePrefixDialog.tsx create mode 100644 web/src/ui/shared/codex/S3SharePrefixDialog/index.ts diff --git a/web/src/core/ports/OnyxiaApi/S3Config.ts b/web/src/core/ports/OnyxiaApi/S3Config.ts index 677501b0d..52c984b3f 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() }); @@ -128,6 +130,7 @@ export namespace S3Config { roles: Entry.StsRole[]; oidcParams: OidcParams_Partial; }; + anonymousProfileName: string | undefined; bookmarks: Entry.Bookmark[]; }; @@ -249,6 +252,7 @@ export function parseS3ConfigFromEnvValue(params: { envValue: string }): S3Confi })() } }, + anonymousProfileName: s3Config.anonymousProfileName, bookmarks: (s3Config.bookmarks ?? []).map( (bookmark): S3Config.Entry.Bookmark => ({ s3UriStr_templated: bookmark.s3Uri, diff --git a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts index a6e26a36c..727157286 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 }, + routeParamsForSharing: undefined, uploadProgressPercent: NaN }); } diff --git a/web/src/core/usecases/s3ExplorerUiController/selectors.ts b/web/src/core/usecases/s3ExplorerUiController/selectors.ts index 2860bb000..e8bbe8854 100644 --- a/web/src/core/usecases/s3ExplorerUiController/selectors.ts +++ b/web/src/core/usecases/s3ExplorerUiController/selectors.ts @@ -114,6 +114,12 @@ export namespace MainView { type: "prefix segment"; s3Uri: S3Uri.TerminatedByDelimiter; policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; + routeParamsForSharing: + | { + profile: string; + s3UriWithoutScheme: string; + } + | undefined; }; export type Object = Common & { @@ -137,6 +143,42 @@ const profileName = createSelector( } ); +const profileName_anonymous = createSelector( + s3ProfilesManagement.selectors.ambientS3Profile, + s3ProfilesManagement.selectors.s3Profiles, + (ambientS3Profile, s3Profiles) => { + if (ambientS3Profile === undefined) { + return undefined; + } + if (ambientS3Profile.origin !== "defined in region") { + return undefined; + } + if ( + !ambientS3Profile.paramsOfCreateS3Client.isStsEnabled && + ambientS3Profile.paramsOfCreateS3Client.credentials === undefined + ) { + return ambientS3Profile.profileName; + } + + const s3Profile_anonymous = s3Profiles.find( + s3Profile => + s3Profile.origin === "defined in region" && + !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, @@ -286,12 +328,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 +369,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 +388,55 @@ 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, + routeParamsForSharing: (() => { + if (profileName_anonymous === undefined) { + return undefined; + } + + const routeParamsForSharing = { + profile: profileName_anonymous, + s3UriWithoutScheme: stringifyS3Uri(item.s3Uri).slice( + "s3://".length + ) + }; + + if (isAnonymousS3Profile) { + return routeParamsForSharing; + } + + if (policy.isPublic) { + return routeParamsForSharing; + } + + // 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 routeParamsForSharing; + })(), + policy }); + } + default: + assert>(false); } } ); diff --git a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts index 0cdeff1aa..fbefee460 100644 --- a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -1,7 +1,7 @@ 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"; @@ -18,7 +18,7 @@ export namespace S3Profile { export type DefinedInRegion = Common & { origin: "defined in region"; - paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts; + paramsOfCreateS3Client: ParamsOfCreateS3Client; }; export type CreatedByUser = Common & { @@ -103,6 +103,10 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { return entry.bookmarks; })(); + const userConfigs_s3Bookmarks = parseUserConfigsS3BookmarksStr({ + userConfigs_s3BookmarksStr: fromVault.userConfigs_s3BookmarksStr + }); + const buildFromRole = (params: { resolvedTemplatedStsRole: ResolvedTemplateStsRole; }): S3Profile.DefinedInRegion => { @@ -165,10 +169,7 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { displayName: title, s3Uri })), - ...parseUserConfigsS3BookmarksStr({ - userConfigs_s3BookmarksStr: - fromVault.userConfigs_s3BookmarksStr - }) + ...userConfigs_s3Bookmarks .filter( entry => entry.profileName === @@ -198,12 +199,37 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { return entry.stsRoles; })(); - assert(resolvedTemplatedStsRoles_forThisProfile.length !== 0); - - return resolvedTemplatedStsRoles_forThisProfile.map( + const s3Profiles = resolvedTemplatedStsRoles_forThisProfile.map( resolvedTemplatedStsRole => buildFromRole({ resolvedTemplatedStsRole }) ); + + if (c.anonymousProfileName !== undefined) { + const profileName = c.anonymousProfileName; + + s3Profiles.push( + id({ + origin: "defined in region", + bookmarks: userConfigs_s3Bookmarks + .filter(entry => entry.profileName === profileName) + .map(entry => ({ + isReadonly: false, + displayName: entry.displayName ?? undefined, + s3Uri: entry.s3Uri + })), + profileName, + paramsOfCreateS3Client: id({ + url: c.url, + isStsEnabled: false, + credentials: undefined, + pathStyleAccess: c.pathStyleAccess, + region: c.region + }) + }) + ); + } + + return s3Profiles; }) .flat() ]; 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..007f0b43f 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -95,6 +95,7 @@ function S3Explorer() { evtDisplayErrorDialogOpen: new Evt(), evtS3ProfileDialogOpen: new Evt(), evtS3ShareObjectDialogOpen: new Evt(), + evtS3SharePrefixDialogOpen: new Evt(), evtMaybeAcknowledgeConfigVolatilityDialogOpen: new Evt() }) ); @@ -619,6 +620,16 @@ function S3Explorer() { s3Uri }); }} + onSharePrefix={params => { + const { prefixName, routeParamsForSharing } = + params; + + dialogProps.evtS3SharePrefixDialogOpen.post({ + prefixName, + link: routes.s3Explorer(routeParamsForSharing) + .link + }); + }} onBookmark={toggleBookmarkFromDataView} 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/S3SharePrefixDialog.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3SharePrefixDialog.tsx new file mode 100644 index 000000000..195c9d8f6 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/dialogs/S3SharePrefixDialog.tsx @@ -0,0 +1,58 @@ +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 type { Link } from "type-route"; + +export type S3SharePrefixDialogProps = { + evtOpen: Evt<{ + prefixName: string; + link: Link; + }>; +}; + +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 }); + + return ( + + ) + } + isOpen={state !== undefined} + onClose={() => 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 index 8ad0e7a6c..b9b876df0 100644 --- a/web/src/ui/pages/s3Explorer/dialogs/S3StorageDialogs.stories.tsx +++ b/web/src/ui/pages/s3Explorer/dialogs/S3StorageDialogs.stories.tsx @@ -100,7 +100,8 @@ export const DeleteSelection: Story = { displayName: "prefix-name", uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: true } + policy: { isPublic: true }, + routeParamsForSharing: undefined }, { type: "object", diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md index de05b25cb..6585eb5b3 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md @@ -65,6 +65,14 @@ export type S3ExplorerMainViewProps = { onShareObject: (params: { s3Uri: S3Uri.NonTerminatedByDelimiter }) => void; + onSharePrefix: (params: { + prefixName: string; + routeParamsForSharing: { + profile: string; + s3UriWithoutScheme: string; + }; + }) => void; + onBookmark: (params: { s3Uri: S3Uri }) => void; onDisplayCopyFeedback: (params: { s3Uri: S3Uri }) => void; @@ -95,6 +103,12 @@ export namespace S3ExplorerMainViewProps { type: "prefix segment"; s3Uri: S3Uri.TerminatedByDelimiter; policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; + routeParamsForSharing: + | { + profile: string; + s3UriWithoutScheme: string; + } + | undefined; }; export type Object = Common & { @@ -214,7 +228,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 + `routeParamsForSharing` 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 @@ -300,13 +315,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 +`routeParamsForSharing` 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({ + prefixName: item.displayName, + routeParamsForSharing: item.routeParamsForSharing +}); ``` 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..dd6604443 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx @@ -21,6 +21,12 @@ type MockNode = uploadProgressPercent: number | undefined; isDeleting: boolean; policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; + routeParamsForSharing: + | { + profile: string; + s3UriWithoutScheme: string; + } + | undefined; } | { type: "object"; @@ -68,27 +74,39 @@ function getDisplayName(s3Uri: S3Uri): string { return s3Uri.keySegments.at(-1) ?? s3Uri.bucket; } +function getRouteParamsForSharing(s3Uri: S3Uri.TerminatedByDelimiter) { + return { + profile: "anonymous", + s3UriWithoutScheme: stringifyS3Uri(s3Uri).slice("s3://".length) + }; +} + const baseNodes: MockNode[] = [ { type: "prefix segment", s3Uri: parsePrefixOrThrow("s3://analytics-data/exports/"), uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: true } + policy: { isPublic: true }, + routeParamsForSharing: getRouteParamsForSharing( + parsePrefixOrThrow("s3://analytics-data/exports/") + ) }, { type: "prefix segment", s3Uri: parsePrefixOrThrow("s3://analytics-data/raw/"), uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: false, canBeMadePublic: true } + policy: { isPublic: false, canBeMadePublic: true }, + routeParamsForSharing: undefined }, { type: "prefix segment", s3Uri: parsePrefixOrThrow("s3://analytics-data/tmp/"), uploadProgressPercent: 42, isDeleting: false, - policy: { isPublic: false, canBeMadePublic: false } + policy: { isPublic: false, canBeMadePublic: false }, + routeParamsForSharing: undefined }, { type: "object", @@ -122,14 +140,18 @@ const nestedNodes: MockNode[] = [ s3Uri: parsePrefixOrThrow("s3://analytics-data/exports/2024/"), uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: false, canBeMadePublic: true } + policy: { isPublic: false, canBeMadePublic: true }, + routeParamsForSharing: undefined }, { type: "prefix segment", s3Uri: parsePrefixOrThrow("s3://analytics-data/exports/2025/"), uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: true } + policy: { isPublic: true }, + routeParamsForSharing: getRouteParamsForSharing( + parsePrefixOrThrow("s3://analytics-data/exports/2025/") + ) }, { type: "object", @@ -164,6 +186,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 +251,7 @@ function StatefulExplorer( | "onPutObjects" | "onDownload" | "onShareObject" + | "onSharePrefix" | "onBookmark" | "bookmarkedS3Uris" | "onChangePrefixPolicy" @@ -283,7 +307,8 @@ function StatefulExplorer( }, uploadProgressPercent: undefined, isDeleting: false, - policy: { isPublic: false, canBeMadePublic: true } + policy: { isPublic: false, canBeMadePublic: true }, + routeParamsForSharing: undefined } ]); }} @@ -340,6 +365,9 @@ function StatefulExplorer( onShareObject={({ s3Uri }) => { action("shareObject")(s3Uri); }} + onSharePrefix={params => { + action("sharePrefix")(params); + }} onBookmark={({ s3Uri }) => { action("bookmark")(s3Uri); }} @@ -419,6 +447,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 +525,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 +555,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..6f9c4cecd 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx @@ -82,6 +82,14 @@ export type S3ExplorerMainViewProps = { onShareObject: (params: { s3Uri: S3Uri.NonTerminatedByDelimiter }) => void; + onSharePrefix: (params: { + prefixName: string; + routeParamsForSharing: { + profile: string; + s3UriWithoutScheme: string; + }; + }) => void; + onBookmark: (params: { s3Uri: S3Uri }) => void; onDisplayCopyFeedback: (params: { s3Uri: S3Uri }) => void; @@ -112,6 +120,12 @@ export namespace S3ExplorerMainViewProps { type: "prefix segment"; s3Uri: S3Uri.TerminatedByDelimiter; policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; + routeParamsForSharing: + | { + profile: string; + s3UriWithoutScheme: string; + } + | undefined; }; export type Object = Common & { @@ -138,6 +152,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { onDelete, onDownload, onShareObject, + onSharePrefix, onBookmark, bookmarkedS3Uris, onChangePrefixPolicy, @@ -373,10 +388,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 +537,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.routeParamsForSharing === undefined) { + return; + } - onShareObject({ - s3Uri: item.s3Uri - }); + onSharePrefix({ + prefixName: item.displayName, + routeParamsForSharing: item.routeParamsForSharing + }); + return; } - ); + }); const requestPrefixPolicyChangeForItem = useConstCallback( (item: S3ExplorerMainViewProps.Item.PrefixSegment) => { @@ -629,14 +652,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]) => { @@ -800,15 +823,19 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { } } share={ - selectedObjectForSingleItemAction === undefined || + selectedItemForSingleItemAction === undefined || !getIsItemActionAvailable( - selectedObjectForSingleItemAction - ) + selectedItemForSingleItemAction + ) || + (selectedItemForSingleItemAction.type === + "prefix segment" && + selectedItemForSingleItemAction.routeParamsForSharing === + undefined) ? undefined : { callback: () => - requestShareForObject( - selectedObjectForSingleItemAction + requestShareForItem( + selectedItemForSingleItemAction ) } } @@ -1128,11 +1155,11 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { itemKey )} onDelete={onDeleteFactory(itemKey)} - onShareObject={ - item.type === "object" - ? onShareObjectFactory( - itemKey - ) + onShare={ + item.type === "object" || + item.routeParamsForSharing !== + undefined + ? onShareFactory(itemKey) : undefined } onChangePrefixPolicy={ @@ -2422,7 +2449,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 +2471,7 @@ const ItemRow = memo(function ItemRow(props: ItemRowProps) { onRowClick, onNavigate, onDelete, - onShareObject, + onShare, onChangePrefixPolicy, onDownload, onBookmark, @@ -2457,7 +2484,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; @@ -2799,7 +2826,7 @@ const ItemRow = memo(function ItemRow(props: ItemRowProps) { )} - {onShareObject !== undefined && ( + {onShare !== undefined && ( @@ -2933,7 +2960,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/S3SharePrefixDialog/S3SharePrefixDialog.tsx b/web/src/ui/shared/codex/S3SharePrefixDialog/S3SharePrefixDialog.tsx new file mode 100644 index 000000000..85d930870 --- /dev/null +++ b/web/src/ui/shared/codex/S3SharePrefixDialog/S3SharePrefixDialog.tsx @@ -0,0 +1,102 @@ +import { alpha } from "@mui/material/styles"; +import { Icon } from "onyxia-ui/Icon"; +import { Text } from "onyxia-ui/Text"; +import { getIconUrlByName } from "lazy-icons"; +import { tss } from "tss"; +import { declareComponentKeys, useTranslation } from "ui/i18n"; +import { + S3DialogCopyUrlField, + S3DialogItemSummary +} from "ui/shared/codex/S3DialogPrimitives"; + +export type S3SharePrefixDialogProps = { + className?: string; + prefixName: string; + url: string; +}; + +export function S3SharePrefixDialog(props: S3SharePrefixDialogProps) { + const { className, prefixName, url } = props; + + const { t } = useTranslation({ S3SharePrefixDialog }); + const { classes, cx } = useStyles(); + + return ( +
+
+ +
+ +
+ +
+ +
+ + + {t("public sharing note")} + +
+
+ ); +} + +const useStyles = tss.withName({ S3SharePrefixDialog }).create(({ theme }) => ({ + root: { + display: "flex", + flexDirection: "column", + boxSizing: "border-box" + }, + prefixSection: { + borderBottom: `1px solid ${theme.colors.useCases.surfaces.surface2}` + }, + prefixSummary: { + minHeight: 56, + gap: theme.spacing(2.5), + "& > :first-child": { + width: 54, + height: 54, + borderRadius: 10, + border: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + backgroundColor: alpha(theme.colors.useCases.surfaces.surface2, 0.38) + }, + "& > :nth-child(2)": { + whiteSpace: "normal", + fontSize: 20, + lineHeight: 1.35, + fontWeight: 500 + }, + marginBottom: theme.spacing(3) + }, + linkSection: { + minWidth: 0, + ...theme.spacing.topBottom("padding", 3) + }, + infoSection: { + display: "grid", + gridTemplateColumns: "32px minmax(0, 1fr)", + gap: theme.spacing(2), + alignItems: "start", + paddingTop: theme.spacing(3), + borderTop: `1px solid ${theme.colors.useCases.surfaces.surface2}`, + color: theme.colors.useCases.typography.textFocus + }, + infoText: { + color: theme.colors.useCases.typography.textSecondary, + lineHeight: 1.55, + maxWidth: 720 + } +})); + +const { i18n } = declareComponentKeys< + "copy folder URL aria label" | "public sharing note" +>()({ S3SharePrefixDialog }); +export type I18n = typeof i18n; diff --git a/web/src/ui/shared/codex/S3SharePrefixDialog/index.ts b/web/src/ui/shared/codex/S3SharePrefixDialog/index.ts new file mode 100644 index 000000000..5d4ec4d56 --- /dev/null +++ b/web/src/ui/shared/codex/S3SharePrefixDialog/index.ts @@ -0,0 +1 @@ +export * from "./S3SharePrefixDialog"; From fd8edfc3ebf56b4c3a8275a0a6ac58fc851df8d7 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Thu, 20 Aug 2026 12:46:50 +0000 Subject: [PATCH 02/15] Update default yaml envs --- web/scripts/unyamlify-env-local.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/scripts/unyamlify-env-local.ts b/web/scripts/unyamlify-env-local.ts index 959e76723..678c09260 100644 --- a/web/scripts/unyamlify-env-local.ts +++ b/web/scripts/unyamlify-env-local.ts @@ -54,6 +54,7 @@ if (!fs.existsSync(envLocalYamlFilePath)) { ` roleSessionName: ""`, ` }`, ` },`, + ` anonymousProfileName: "anonymous",`, ` bookmarks: [`, ` {`, ` s3Uri: "s3://$1/",`, From a5d8b418cffc4f48633f4a6daaa8cfbb58ebbd72 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Thu, 20 Aug 2026 12:48:04 +0000 Subject: [PATCH 03/15] Release candidate --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index cc5393882..f95e97f68 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.1", "license": "MIT", "scripts": { "postinstall": "yarn install-git-hooks && yarn postinstall:code-gen", From dd62607a8dc2fa47c080b8e2f0ed4cc1ab83c697 Mon Sep 17 00:00:00 2001 From: actions Date: Thu, 20 Aug 2026 12:51:27 +0000 Subject: [PATCH 04/15] Automatic minor bump of chart version to 11.7.0-rc.1 --- helm-chart/Chart.yaml | 2 +- helm-chart/README.md | 10 +++++----- helm-chart/values.yaml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml index fe8901915..f53e210f6 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.1 diff --git a/helm-chart/README.md b/helm-chart/README.md index b46a0491d..3682f64e5 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.1" -f onyxia-values.yaml ``` To expose Onyxia with the Kubernetes Gateway API instead of an Ingress, use an `HTTPRoute`. @@ -40,7 +40,7 @@ httpRoute: - datalab.my-domain.net EOF -helm install onyxia onyxia/onyxia --version "11.6.0" -f onyxia-values.yaml +helm install onyxia onyxia/onyxia --version "11.7.0-rc.1" -f onyxia-values.yaml ``` ### Using the Keycloak Theme (Optional) @@ -62,7 +62,7 @@ extraInitContainers: | args: - -c - | - curl -L -f -S -o /extensions/onyxia.jar https://github.com/InseeFrLab/onyxia/releases/download/v11.6.0/keycloak-theme.jar + curl -L -f -S -o /extensions/onyxia.jar https://github.com/InseeFrLab/onyxia/releases/download/v11.7.0-rc.1/keycloak-theme.jar volumeMounts: - name: extensions mountPath: /extensions @@ -97,7 +97,7 @@ api: ``` - [The REST API (`api`)](https://github.com/InseeFrLab/onyxia-api/blob/v4.12.0/README.md#configuration) -- [The Web Application (`web`)](https://github.com/InseeFrLab/onyxia/blob/web-v5.6.0/web/.env) +- [The Web Application (`web`)](https://github.com/InseeFrLab/onyxia/blob/web-v5.7.0-rc.1/web/.env) Below is a sample `onyxia-values.yaml` file that illustrates where to specify the `api` and `web` configuration parameters. @@ -150,4 +150,4 @@ httpRoute: If you are building your own service catalog for Onyxia ([learn how](https://docs.onyxia.sh/catalog-of-services)). Here are defined the onyxia reserved parameter and the structure of the dynamic context: -[`values.schema.json` `"x-onyxia"` specifications](https://github.com/InseeFrLab/onyxia/blob/web-v5.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.1/web/src/core/ports/OnyxiaApi/XOnyxia.ts) diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index daacbff24..171136a64 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.1 pullPolicy: IfNotPresent imagePullSecrets: [] From ebcb5a3717a41f1d50f2b5500ae41260e0a56f47 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Thu, 20 Aug 2026 14:23:27 +0000 Subject: [PATCH 05/15] improve ui and code for sharing folder --- .../computeUploadStatusAtPrefix.ts | 2 +- .../s3ExplorerUiController/selectors.ts | 22 +- web/src/ui/pages/s3Explorer/Page.tsx | 14 +- .../dialogs/S3SharePrefixDialog.tsx | 41 ++- .../dialogs/S3StorageDialogs.stories.tsx | 282 ------------------ .../S3DialogPrimitives/S3DialogPrimitives.tsx | 44 +++ .../S3ExplorerMainView.spec.md | 22 +- .../S3ExplorerMainView.stories.tsx | 30 +- .../S3ExplorerMainView/S3ExplorerMainView.tsx | 24 +- .../S3SharePrefixDialog.spec.md | 84 ++++++ .../S3SharePrefixDialog.stories.tsx | 35 +++ .../S3SharePrefixDialog.tsx | 30 +- 12 files changed, 242 insertions(+), 388 deletions(-) delete mode 100644 web/src/ui/pages/s3Explorer/dialogs/S3StorageDialogs.stories.tsx create mode 100644 web/src/ui/shared/codex/S3SharePrefixDialog/S3SharePrefixDialog.spec.md create mode 100644 web/src/ui/shared/codex/S3SharePrefixDialog/S3SharePrefixDialog.stories.tsx diff --git a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts index 727157286..b4f599aaa 100644 --- a/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts +++ b/web/src/core/usecases/s3ExplorerUiController/decoupledLogic/computeUploadStatusAtPrefix.ts @@ -73,7 +73,7 @@ export function computeUploadStatusAtPrefix(params: { s3Uri: s3Uri_newItem, isDeleting: false, policy: { isPublic: false, canBeMadePublic: false }, - routeParamsForSharing: undefined, + profileNameForSharing: undefined, uploadProgressPercent: NaN }); } diff --git a/web/src/core/usecases/s3ExplorerUiController/selectors.ts b/web/src/core/usecases/s3ExplorerUiController/selectors.ts index e8bbe8854..8f45b7691 100644 --- a/web/src/core/usecases/s3ExplorerUiController/selectors.ts +++ b/web/src/core/usecases/s3ExplorerUiController/selectors.ts @@ -114,12 +114,7 @@ export namespace MainView { type: "prefix segment"; s3Uri: S3Uri.TerminatedByDelimiter; policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; - routeParamsForSharing: - | { - profile: string; - s3UriWithoutScheme: string; - } - | undefined; + profileNameForSharing: string | undefined; }; export type Object = Common & { @@ -402,24 +397,17 @@ const items = createSelector( s3Uri: item.s3Uri, uploadProgressPercent: undefined, isDeleting: false, - routeParamsForSharing: (() => { + profileNameForSharing: (() => { if (profileName_anonymous === undefined) { return undefined; } - const routeParamsForSharing = { - profile: profileName_anonymous, - s3UriWithoutScheme: stringifyS3Uri(item.s3Uri).slice( - "s3://".length - ) - }; - if (isAnonymousS3Profile) { - return routeParamsForSharing; + return profileName_anonymous; } if (policy.isPublic) { - return routeParamsForSharing; + return profileName_anonymous; } // NOTE: Semantically, this is wrong, it's sharable @@ -430,7 +418,7 @@ const items = createSelector( return undefined; } - return routeParamsForSharing; + return profileName_anonymous; })(), policy }); diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 007f0b43f..9b400a7f9 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -620,16 +620,12 @@ function S3Explorer() { s3Uri }); }} - onSharePrefix={params => { - const { prefixName, routeParamsForSharing } = - params; - + onSharePrefix={({ s3Uri, anonymousProfileName }) => dialogProps.evtS3SharePrefixDialogOpen.post({ - prefixName, - link: routes.s3Explorer(routeParamsForSharing) - .link - }); - }} + s3Uri, + anonymousProfileName + }) + } onBookmark={toggleBookmarkFromDataView} bookmarkedS3Uris={mainView.bookmarks.items.map( item => item.s3Uri diff --git a/web/src/ui/pages/s3Explorer/dialogs/S3SharePrefixDialog.tsx b/web/src/ui/pages/s3Explorer/dialogs/S3SharePrefixDialog.tsx index 195c9d8f6..4bb0e0599 100644 --- a/web/src/ui/pages/s3Explorer/dialogs/S3SharePrefixDialog.tsx +++ b/web/src/ui/pages/s3Explorer/dialogs/S3SharePrefixDialog.tsx @@ -4,12 +4,14 @@ 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 type { Link } from "type-route"; +import { routes } from "ui/routes"; +import { stringifyS3Uri, type S3Uri } from "core/tools/S3Uri"; +import { assert } from "tsafe"; export type S3SharePrefixDialogProps = { evtOpen: Evt<{ - prefixName: string; - link: Link; + s3Uri: S3Uri.TerminatedByDelimiter; + anonymousProfileName: string; }>; }; @@ -33,18 +35,35 @@ function S3SharePrefixDialogContainer(props: S3SharePrefixDialogProps) { 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 ( - ) - } + body={body} isOpen={state !== undefined} onClose={() => setState(undefined)} showCloseButton 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 b9b876df0..000000000 --- a/web/src/ui/pages/s3Explorer/dialogs/S3StorageDialogs.stories.tsx +++ /dev/null @@ -1,282 +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/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 6585eb5b3..42c0d653d 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md @@ -66,11 +66,8 @@ export type S3ExplorerMainViewProps = { onShareObject: (params: { s3Uri: S3Uri.NonTerminatedByDelimiter }) => void; onSharePrefix: (params: { - prefixName: string; - routeParamsForSharing: { - profile: string; - s3UriWithoutScheme: string; - }; + s3Uri: S3Uri.TerminatedByDelimiter; + anonymousProfileName: string; }) => void; onBookmark: (params: { s3Uri: S3Uri }) => void; @@ -103,12 +100,7 @@ export namespace S3ExplorerMainViewProps { type: "prefix segment"; s3Uri: S3Uri.TerminatedByDelimiter; policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; - routeParamsForSharing: - | { - profile: string; - s3UriWithoutScheme: string; - } - | undefined; + profileNameForSharing: string | undefined; }; export type Object = Common & { @@ -229,7 +221,7 @@ for the current selection: - download is available when every selected item is not deleting and does not have an unfinished upload progress state - share is available for one selected object or one prefix whose - `routeParamsForSharing` is defined + `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 @@ -316,7 +308,7 @@ onChangePrefixPolicy({ ### Share Share is available as a row action for object rows and prefix rows whose -`routeParamsForSharing` is defined, provided that the item is not deleting and does +`profileNameForSharing` is defined, provided that the item is not deleting and does not have an unfinished upload progress state. Clicking Share triggers: @@ -325,8 +317,8 @@ Clicking Share triggers: onShareObject({ s3Uri: item.s3Uri }); onSharePrefix({ - prefixName: item.displayName, - routeParamsForSharing: item.routeParamsForSharing + s3Uri: item.s3Uri, + anonymousProfileName: item.profileNameForSharing }); ``` diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx index dd6604443..93a31d453 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.stories.tsx @@ -21,12 +21,7 @@ type MockNode = uploadProgressPercent: number | undefined; isDeleting: boolean; policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; - routeParamsForSharing: - | { - profile: string; - s3UriWithoutScheme: string; - } - | undefined; + profileNameForSharing: string | undefined; } | { type: "object"; @@ -74,13 +69,6 @@ function getDisplayName(s3Uri: S3Uri): string { return s3Uri.keySegments.at(-1) ?? s3Uri.bucket; } -function getRouteParamsForSharing(s3Uri: S3Uri.TerminatedByDelimiter) { - return { - profile: "anonymous", - s3UriWithoutScheme: stringifyS3Uri(s3Uri).slice("s3://".length) - }; -} - const baseNodes: MockNode[] = [ { type: "prefix segment", @@ -88,9 +76,7 @@ const baseNodes: MockNode[] = [ uploadProgressPercent: undefined, isDeleting: false, policy: { isPublic: true }, - routeParamsForSharing: getRouteParamsForSharing( - parsePrefixOrThrow("s3://analytics-data/exports/") - ) + profileNameForSharing: "anonymous" }, { type: "prefix segment", @@ -98,7 +84,7 @@ const baseNodes: MockNode[] = [ uploadProgressPercent: undefined, isDeleting: false, policy: { isPublic: false, canBeMadePublic: true }, - routeParamsForSharing: undefined + profileNameForSharing: undefined }, { type: "prefix segment", @@ -106,7 +92,7 @@ const baseNodes: MockNode[] = [ uploadProgressPercent: 42, isDeleting: false, policy: { isPublic: false, canBeMadePublic: false }, - routeParamsForSharing: undefined + profileNameForSharing: undefined }, { type: "object", @@ -141,7 +127,7 @@ const nestedNodes: MockNode[] = [ uploadProgressPercent: undefined, isDeleting: false, policy: { isPublic: false, canBeMadePublic: true }, - routeParamsForSharing: undefined + profileNameForSharing: undefined }, { type: "prefix segment", @@ -149,9 +135,7 @@ const nestedNodes: MockNode[] = [ uploadProgressPercent: undefined, isDeleting: false, policy: { isPublic: true }, - routeParamsForSharing: getRouteParamsForSharing( - parsePrefixOrThrow("s3://analytics-data/exports/2025/") - ) + profileNameForSharing: "anonymous" }, { type: "object", @@ -308,7 +292,7 @@ function StatefulExplorer( uploadProgressPercent: undefined, isDeleting: false, policy: { isPublic: false, canBeMadePublic: true }, - routeParamsForSharing: undefined + profileNameForSharing: undefined } ]); }} diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx index 6f9c4cecd..f6bbf7e15 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx @@ -83,11 +83,8 @@ export type S3ExplorerMainViewProps = { onShareObject: (params: { s3Uri: S3Uri.NonTerminatedByDelimiter }) => void; onSharePrefix: (params: { - prefixName: string; - routeParamsForSharing: { - profile: string; - s3UriWithoutScheme: string; - }; + s3Uri: S3Uri.TerminatedByDelimiter; + anonymousProfileName: string; }) => void; onBookmark: (params: { s3Uri: S3Uri }) => void; @@ -120,12 +117,7 @@ export namespace S3ExplorerMainViewProps { type: "prefix segment"; s3Uri: S3Uri.TerminatedByDelimiter; policy: { isPublic: true } | { isPublic: false; canBeMadePublic: boolean }; - routeParamsForSharing: - | { - profile: string; - s3UriWithoutScheme: string; - } - | undefined; + profileNameForSharing: string | undefined; }; export type Object = Common & { @@ -549,13 +541,13 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { }); return; case "prefix segment": - if (item.routeParamsForSharing === undefined) { + if (item.profileNameForSharing === undefined) { return; } onSharePrefix({ - prefixName: item.displayName, - routeParamsForSharing: item.routeParamsForSharing + s3Uri: item.s3Uri, + anonymousProfileName: item.profileNameForSharing }); return; } @@ -829,7 +821,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { ) || (selectedItemForSingleItemAction.type === "prefix segment" && - selectedItemForSingleItemAction.routeParamsForSharing === + selectedItemForSingleItemAction.profileNameForSharing === undefined) ? undefined : { @@ -1157,7 +1149,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { onDelete={onDeleteFactory(itemKey)} onShare={ item.type === "object" || - item.routeParamsForSharing !== + item.profileNameForSharing !== undefined ? onShareFactory(itemKey) : undefined diff --git a/web/src/ui/shared/codex/S3SharePrefixDialog/S3SharePrefixDialog.spec.md b/web/src/ui/shared/codex/S3SharePrefixDialog/S3SharePrefixDialog.spec.md new file mode 100644 index 000000000..06140b932 --- /dev/null +++ b/web/src/ui/shared/codex/S3SharePrefixDialog/S3SharePrefixDialog.spec.md @@ -0,0 +1,84 @@ +# Intent + +`S3SharePrefixDialog` is the share box displayed inside a modal after the user +chooses Share on an S3 prefix. + +Despite its name, this component does not render or control a modal dialog. It +renders only the modal body content and has no dependency on the application router +or core. Its caller provides all display-ready values. + +Its purpose is to: + +- identify the shared folder when its basename is available +- display the complete Onyxia sharing URL +- let the user open or copy that URL +- explain why the folder can be accessed publicly + +# Props + +```ts +export type S3SharePrefixDialogProps = { + className?: string; + prefixBasename?: string; + onyxiaUrl: string; +}; +``` + +# General Structure + +The component renders a regular box composed of: + +1. An optional folder summary row +2. The Onyxia URL and copy action +3. A bottom informational note + +The parent owns modal chrome, title, close button, URL construction, and lifecycle. + +# Rendering Rules + +## Folder Summary + +When `prefixBasename` is defined, display it with a folder icon and a public badge. + +When `prefixBasename` is undefined, omit the summary row without leaving an empty +placeholder. + +## Onyxia URL + +Display `onyxiaUrl` exactly as provided. Do not parse, shorten, elide, or rearrange +the URL or its query parameters. + +The complete URL must: + +- remain visible by wrapping onto additional lines when necessary +- never require horizontal scrolling +- be an anchor that opens in a new browser tab +- be used unchanged as the anchor destination + +## Copy + +The copy action copies the complete `onyxiaUrl`, including its origin, path, query +string, and profile parameter. + +After a successful copy, the component shows the standard S3 dialog copied +feedback. + +## Information Note + +Display a note explaining that anyone with the link can open the folder, including +users without an account, because the folder or one of its descendants has been +made public. + +# Accessibility + +- The copy button has an accessible name. +- The URL can receive keyboard focus. +- The URL has a visible focus state. +- The external link uses safe new-tab attributes. + +# Layout Rules + +- The component fills the available modal body width. +- Long folder names must not break the layout. +- Long URLs wrap at any necessary character and remain fully visible. +- The component does not impose modal sizing. diff --git a/web/src/ui/shared/codex/S3SharePrefixDialog/S3SharePrefixDialog.stories.tsx b/web/src/ui/shared/codex/S3SharePrefixDialog/S3SharePrefixDialog.stories.tsx new file mode 100644 index 000000000..af3e2d2a3 --- /dev/null +++ b/web/src/ui/shared/codex/S3SharePrefixDialog/S3SharePrefixDialog.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { S3SharePrefixDialog } from "./S3SharePrefixDialog"; + +const meta = { + title: "Shared/S3SharePrefixDialog", + component: S3SharePrefixDialog +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const onyxiaUrl = + "https://datalab.sspcloud.fr/s3/garronej/public/fortnite?profile=anonymous"; + +export const Default: Story = { + args: { + prefixBasename: "fortnite", + onyxiaUrl + }, + render: args => ( +
+ +
+ ) +}; + +export const LongUrl: Story = { + args: { + prefixBasename: "quarterly statistical exports", + onyxiaUrl: + "https://datalab.sspcloud.fr/s3/garronej/public/quarterly%20statistical%20exports/with/a/deeply/nested/folder/whose/full/url/must/remain/visible?profile=anonymous-profile-with-a-long-name" + }, + render: Default.render +}; diff --git a/web/src/ui/shared/codex/S3SharePrefixDialog/S3SharePrefixDialog.tsx b/web/src/ui/shared/codex/S3SharePrefixDialog/S3SharePrefixDialog.tsx index 85d930870..b9e400dd4 100644 --- a/web/src/ui/shared/codex/S3SharePrefixDialog/S3SharePrefixDialog.tsx +++ b/web/src/ui/shared/codex/S3SharePrefixDialog/S3SharePrefixDialog.tsx @@ -5,36 +5,38 @@ import { getIconUrlByName } from "lazy-icons"; import { tss } from "tss"; import { declareComponentKeys, useTranslation } from "ui/i18n"; import { - S3DialogCopyUrlField, + S3DialogCopyPlainUrlField, S3DialogItemSummary } from "ui/shared/codex/S3DialogPrimitives"; export type S3SharePrefixDialogProps = { className?: string; - prefixName: string; - url: string; + prefixBasename?: string; + onyxiaUrl: string; }; export function S3SharePrefixDialog(props: S3SharePrefixDialogProps) { - const { className, prefixName, url } = props; + const { className, prefixBasename, onyxiaUrl } = props; const { t } = useTranslation({ S3SharePrefixDialog }); const { classes, cx } = useStyles(); return (
-
- -
+ {prefixBasename !== undefined && ( +
+ +
+ )}
-
From 86186d8d22e69920583290917944b8f424fe701f Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Thu, 20 Aug 2026 14:40:26 +0000 Subject: [PATCH 06/15] Update editing mode click threshold for cheap trackpad support --- web/src/ui/shared/codex/S3UriBar/S3UriBar.spec.md | 2 +- web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/ui/shared/codex/S3UriBar/S3UriBar.spec.md b/web/src/ui/shared/codex/S3UriBar/S3UriBar.spec.md index 1f2641c63..5ae493bf9 100644 --- a/web/src/ui/shared/codex/S3UriBar/S3UriBar.spec.md +++ b/web/src/ui/shared/codex/S3UriBar/S3UriBar.spec.md @@ -197,7 +197,7 @@ export type S3UriBarProps = { - Home/root button short click => enter editing mode with `s3://` as the draft. - Key icon short click => enter editing mode and select the object-key portion of the URI, from after `s3://bucket/` to the end. - Segment short click => request navigation (`onS3UriChange`). - - Segment long press (`>= 100ms`) => enter edit mode (internal state). + - Segment long press (`>= 270ms`) => enter edit mode (internal state). - Editing mode: - Input updates are handled by parent via requested prefix changes. - If the parent updates `s3Uri` externally while edit mode is open, the input draft must resync to that external value. diff --git a/web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx b/web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx index 938fae445..5924ac58e 100644 --- a/web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx +++ b/web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx @@ -2075,7 +2075,7 @@ type NavigationCrumbItem = { isPublicStart: boolean; }; -const longPressDelayMs = 200; +const longPressDelayMs = 270; const hintsPanelHorizontalEdgePaddingPx = 8; const hintsPanelVerticalOffsetPx = 6; const hintsPanelFallbackWidthPx = 280; From d254560cacbbcd49fccee54621ba6e0e83ba1930 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Thu, 20 Aug 2026 14:40:59 +0000 Subject: [PATCH 07/15] Release candidate --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index f95e97f68..db0dc33d1 100644 --- a/web/package.json +++ b/web/package.json @@ -2,7 +2,7 @@ "name": "onyxia-web", "homepage": "https://onyxia.sh", "type": "module", - "version": "5.7.0-rc.1", + "version": "5.7.0-rc.2", "license": "MIT", "scripts": { "postinstall": "yarn install-git-hooks && yarn postinstall:code-gen", From aa886221f694bb238cc661e81fe17559869c6e1f Mon Sep 17 00:00:00 2001 From: actions Date: Thu, 20 Aug 2026 14:44:49 +0000 Subject: [PATCH 08/15] Automatic minor bump of chart version to 11.7.0-rc.2 --- helm-chart/Chart.yaml | 2 +- helm-chart/README.md | 10 +++++----- helm-chart/values.yaml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml index f53e210f6..f1fc8b2d4 100644 --- a/helm-chart/Chart.yaml +++ b/helm-chart/Chart.yaml @@ -14,4 +14,4 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. -version: 11.7.0-rc.1 +version: 11.7.0-rc.2 diff --git a/helm-chart/README.md b/helm-chart/README.md index 3682f64e5..6654cd831 100644 --- a/helm-chart/README.md +++ b/helm-chart/README.md @@ -22,7 +22,7 @@ ingress: - host: datalab.my-domain.net EOF -helm install onyxia onyxia/onyxia --version "11.7.0-rc.1" -f onyxia-values.yaml +helm install onyxia onyxia/onyxia --version "11.7.0-rc.2" -f onyxia-values.yaml ``` To expose Onyxia with the Kubernetes Gateway API instead of an Ingress, use an `HTTPRoute`. @@ -40,7 +40,7 @@ httpRoute: - datalab.my-domain.net EOF -helm install onyxia onyxia/onyxia --version "11.7.0-rc.1" -f onyxia-values.yaml +helm install onyxia onyxia/onyxia --version "11.7.0-rc.2" -f onyxia-values.yaml ``` ### Using the Keycloak Theme (Optional) @@ -62,7 +62,7 @@ extraInitContainers: | args: - -c - | - curl -L -f -S -o /extensions/onyxia.jar https://github.com/InseeFrLab/onyxia/releases/download/v11.7.0-rc.1/keycloak-theme.jar + curl -L -f -S -o /extensions/onyxia.jar https://github.com/InseeFrLab/onyxia/releases/download/v11.7.0-rc.2/keycloak-theme.jar volumeMounts: - name: extensions mountPath: /extensions @@ -97,7 +97,7 @@ api: ``` - [The REST API (`api`)](https://github.com/InseeFrLab/onyxia-api/blob/v4.12.0/README.md#configuration) -- [The Web Application (`web`)](https://github.com/InseeFrLab/onyxia/blob/web-v5.7.0-rc.1/web/.env) +- [The Web Application (`web`)](https://github.com/InseeFrLab/onyxia/blob/web-v5.7.0-rc.2/web/.env) Below is a sample `onyxia-values.yaml` file that illustrates where to specify the `api` and `web` configuration parameters. @@ -150,4 +150,4 @@ httpRoute: If you are building your own service catalog for Onyxia ([learn how](https://docs.onyxia.sh/catalog-of-services)). Here are defined the onyxia reserved parameter and the structure of the dynamic context: -[`values.schema.json` `"x-onyxia"` specifications](https://github.com/InseeFrLab/onyxia/blob/web-v5.7.0-rc.1/web/src/core/ports/OnyxiaApi/XOnyxia.ts) +[`values.schema.json` `"x-onyxia"` specifications](https://github.com/InseeFrLab/onyxia/blob/web-v5.7.0-rc.2/web/src/core/ports/OnyxiaApi/XOnyxia.ts) diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml index 171136a64..c96a8046a 100644 --- a/helm-chart/values.yaml +++ b/helm-chart/values.yaml @@ -43,7 +43,7 @@ web: replicaCount: 1 image: repository: inseefrlab/onyxia-web - tag: 5.7.0-rc.1 + tag: 5.7.0-rc.2 pullPolicy: IfNotPresent imagePullSecrets: [] From b3a64913569cac57fd992ffcbf9097de928989c8 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 24 Aug 2026 15:55:50 +0000 Subject: [PATCH 09/15] Rename misleading origin enum for s3Profile --- web/src/core/usecases/launcher/selectors.ts | 2 +- web/src/core/usecases/launcher/thunks.ts | 2 +- web/src/core/usecases/s3ExplorerUiController/selectors.ts | 6 +++--- .../usecases/s3ProfilesDetailsUiController/selectors.ts | 2 +- .../s3ProfilesManagement/decoupledLogic/s3Profiles.ts | 8 ++++---- web/src/core/usecases/s3ProfilesManagement/selectors.ts | 2 +- web/src/core/usecases/s3ProfilesManagement/thunks.ts | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/web/src/core/usecases/launcher/selectors.ts b/web/src/core/usecases/launcher/selectors.ts index cc68a0956..be55b73b4 100644 --- a/web/src/core/usecases/launcher/selectors.ts +++ b/web/src/core/usecases/launcher/selectors.ts @@ -177,7 +177,7 @@ const s3ProfileSelect = createSelector( } const availableConfigs = s3Configs.filter( - config => canInjectPersonalInfos || config.origin !== "defined in region" + config => canInjectPersonalInfos || config.origin !== "onyxia instance config" ); // 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..83367f2d7 100644 --- a/web/src/core/usecases/launcher/thunks.ts +++ b/web/src/core/usecases/launcher/thunks.ts @@ -185,7 +185,7 @@ export const thunks = { s3Profile => s3Profile.profileName === "default" ) ?? s3Profiles.find( - s3Profile => s3Profile.origin === "defined in region" + s3Profile => s3Profile.origin === "onyxia instance config" ) ?? s3Profiles.find(() => true) )?.profileName; diff --git a/web/src/core/usecases/s3ExplorerUiController/selectors.ts b/web/src/core/usecases/s3ExplorerUiController/selectors.ts index 8f45b7691..6a4ddebfa 100644 --- a/web/src/core/usecases/s3ExplorerUiController/selectors.ts +++ b/web/src/core/usecases/s3ExplorerUiController/selectors.ts @@ -145,7 +145,7 @@ const profileName_anonymous = createSelector( if (ambientS3Profile === undefined) { return undefined; } - if (ambientS3Profile.origin !== "defined in region") { + if (ambientS3Profile.origin !== "onyxia instance config") { return undefined; } if ( @@ -157,7 +157,7 @@ const profileName_anonymous = createSelector( const s3Profile_anonymous = s3Profiles.find( s3Profile => - s3Profile.origin === "defined in region" && + s3Profile.origin === "onyxia instance config" && !s3Profile.paramsOfCreateS3Client.isStsEnabled && s3Profile.paramsOfCreateS3Client.credentials === undefined && s3Profile.paramsOfCreateS3Client.url === @@ -222,7 +222,7 @@ const profileSelect = createSelector( selectedProfile: { name: ambientS3Profile.profileName, url: ambientS3Profile.paramsOfCreateS3Client.url, - isReadonly: ambientS3Profile.origin === "defined in region" + isReadonly: ambientS3Profile.origin === "onyxia instance config" }, availableProfileNames: s3Profiles.map(s3Profile => s3Profile.profileName) }; diff --git a/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts b/web/src/core/usecases/s3ProfilesDetailsUiController/selectors.ts index 129d61959..1e3a0f43a 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": return true; } })(), diff --git a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts index fbefee460..641a42012 100644 --- a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -17,7 +17,7 @@ export namespace S3Profile { }; export type DefinedInRegion = Common & { - origin: "defined in region"; + origin: "onyxia instance config"; paramsOfCreateS3Client: ParamsOfCreateS3Client; }; @@ -124,7 +124,7 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { }; return { - origin: "defined in region", + origin: "onyxia instance config", profileName: resolvedTemplatedStsRole.profileName, bookmarks: [ ...resolvedTemplatedBookmarks_forThisProfile @@ -209,7 +209,7 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { s3Profiles.push( id({ - origin: "defined in region", + origin: "onyxia instance config", bookmarks: userConfigs_s3Bookmarks .filter(entry => entry.profileName === profileName) .map(entry => ({ @@ -239,7 +239,7 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { return 0; } - return a.origin === "defined in region" ? -1 : 1; + return a.origin === "onyxia instance config" ? -1 : 1; })) { const s3Profiles_conflicting = s3Profiles.filter( s3Profile_i => diff --git a/web/src/core/usecases/s3ProfilesManagement/selectors.ts b/web/src/core/usecases/s3ProfilesManagement/selectors.ts index 4ea00f0d1..d542a369d 100644 --- a/web/src/core/usecases/s3ProfilesManagement/selectors.ts +++ b/web/src/core/usecases/s3ProfilesManagement/selectors.ts @@ -64,7 +64,7 @@ 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") ?? s3Profiles.find(() => true) ); } diff --git a/web/src/core/usecases/s3ProfilesManagement/thunks.ts b/web/src/core/usecases/s3ProfilesManagement/thunks.ts index 1160830cf..5ff3e9f0d 100644 --- a/web/src/core/usecases/s3ProfilesManagement/thunks.ts +++ b/web/src/core/usecases/s3ProfilesManagement/thunks.ts @@ -299,7 +299,7 @@ export const protectedThunks = { ); } break; - case "defined in region": + case "onyxia instance config": { const { s3BookmarksStr } = userConfigs.selectors.userConfigs(getState()); From fbbfd8e4396e19b7d018ab42ce36de5e59cba1c5 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Mon, 24 Aug 2026 19:28:34 +0000 Subject: [PATCH 10/15] Refactor s3 profil agregation --- web/src/core/ports/OnyxiaApi/S3Config.ts | 81 +++--- .../resolveTemplatedBookmark.ts | 33 +-- .../decoupledLogic/resolveTemplatedStsRole.ts | 26 +- .../decoupledLogic/s3Profiles.ts | 264 ++++++++++-------- .../decoupledLogic/userConfigsS3Bookmarks.ts | 6 +- .../s3ProfilesManagement/selectors.ts | 52 +--- .../usecases/s3ProfilesManagement/state.ts | 23 +- .../usecases/s3ProfilesManagement/thunks.ts | 88 ++---- 8 files changed, 257 insertions(+), 316 deletions(-) diff --git a/web/src/core/ports/OnyxiaApi/S3Config.ts b/web/src/core/ports/OnyxiaApi/S3Config.ts index 52c984b3f..433a39f80 100644 --- a/web/src/core/ports/OnyxiaApi/S3Config.ts +++ b/web/src/core/ports/OnyxiaApi/S3Config.ts @@ -135,39 +135,46 @@ export namespace S3Config { }; 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 Bookmark = { - s3UriStr_templated: string; - title: LocalizedString; - forProfileNames: string[]; - } & ( - | { - claimName: undefined; - includedClaimPattern?: never; - excludedClaimPattern?: never; - } - | { - claimName: string; - includedClaimPattern: string | undefined; - excludedClaimPattern: string | undefined; - } - ); + export type StsRole = StsRole.NonTemplated | StsRole.Templated; + + 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; + }; + } } } @@ -222,8 +229,9 @@ export function parseS3ConfigFromEnvValue(params: { envValue: string }): S3Confi roleSessionName: role.roleSessionName, profileName: role.profileName, ...(role.claimName === undefined - ? { claimName: undefined } + ? { isTemplated: false } : { + isTemplated: true, claimName: role.claimName, includedClaimPattern: role.includedClaimPattern, excludedClaimPattern: role.excludedClaimPattern @@ -255,7 +263,7 @@ export function parseS3ConfigFromEnvValue(params: { envValue: string }): S3Confi 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 @@ -264,8 +272,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 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 641a42012..8ec6cbeb5 100644 --- a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -3,10 +3,13 @@ import type { S3Config } from "core/ports/OnyxiaApi/S3Config"; import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; 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; @@ -34,33 +37,34 @@ export namespace S3Profile { }; } -export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { - fromVault: { +export function createS3Profiles(params: { + onyxiaInstanceS3ConfigEntries: S3Config.Entry[]; + persistenceLayerData: { 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; - }; + decodedIdTokens: { + oidcParams: OidcParams_Partial; + decodedIdToken: Record; + }[]; }): S3Profile[] { - const { fromVault, fromConfig } = params; + const { onyxiaInstanceS3ConfigEntries, persistenceLayerData, decodedIdTokens } = + params; + + const bookmarks_user: { + displayName: string | undefined; + s3Uri: S3Uri; + profileName: string; + }[] = + persistenceLayerData.userConfigs_s3BookmarksStr === null + ? [] + : parseUserConfigsS3BookmarksStr({ + userConfigs_s3BookmarksStr: + persistenceLayerData.userConfigs_s3BookmarksStr + }); const s3Profiles: S3Profile[] = [ - ...fromVault.s3Profiles + ...persistenceLayerData.s3Profiles .map((c): S3Profile.CreatedByUser => { const url = c.url; const pathStyleAccess = c.pathStyleAccess; @@ -87,30 +91,102 @@ 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 []; - } - - const entry = fromConfig.resolvedTemplatedBookmarks.find( - e => e.correspondingS3ConfigEntryIndex === index + ...onyxiaInstanceS3ConfigEntries + .map((c): S3Profile.DefinedInRegion[] => { + const decodedIdToken = (() => { + const wrap = decodedIdTokens.find(wrap => + same(wrap.oidcParams, c.sts.oidcParams) ); - assert(entry !== undefined); + assert(wrap !== undefined); - return entry.bookmarks; + return wrap.decodedIdToken; })(); - const userConfigs_s3Bookmarks = parseUserConfigsS3BookmarksStr({ - userConfigs_s3BookmarksStr: fromVault.userConfigs_s3BookmarksStr - }); + 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 + } + ]; + } + + 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: { - resolvedTemplatedStsRole: ResolvedTemplateStsRole; + stsRole: StsRole; }): S3Profile.DefinedInRegion => { - const { resolvedTemplatedStsRole } = params; + const { stsRole } = params; const paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts = { url: c.url, @@ -120,88 +196,42 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { region: c.region, oidcParams: c.sts.oidcParams, durationSeconds: c.sts.durationSeconds, - role: resolvedTemplatedStsRole + role: stsRole }; + const { profileName } = stsRole; + return { origin: "onyxia instance config", - 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 - })), - ...userConfigs_s3Bookmarks - .filter( - entry => - entry.profileName === - resolvedTemplatedStsRole.profileName - ) - .map(entry => ({ - isReadonly: false, - displayName: entry.displayName ?? undefined, - s3Uri: entry.s3Uri - })) - ], + profileName, + bookmarks: getBookmarksForProfileName({ profileName }), paramsOfCreateS3Client }; }; - const resolvedTemplatedStsRoles_forThisProfile = (() => { - if (fromConfig.resolvedTemplatedStsRoles === undefined) { - return []; - } - - const entry = fromConfig.resolvedTemplatedStsRoles.find( - e => e.correspondingS3ConfigEntryIndex === index - ); - - assert(entry !== undefined); - - return entry.stsRoles; - })(); - - const s3Profiles = resolvedTemplatedStsRoles_forThisProfile.map( - resolvedTemplatedStsRole => - buildFromRole({ resolvedTemplatedStsRole }) + const stsRoles: StsRole[] = 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: S3Profile.DefinedInRegion[] = stsRoles.map(stsRole => + buildFromRole({ stsRole }) ); if (c.anonymousProfileName !== undefined) { @@ -210,13 +240,7 @@ export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { s3Profiles.push( id({ origin: "onyxia instance config", - bookmarks: userConfigs_s3Bookmarks - .filter(entry => entry.profileName === profileName) - .map(entry => ({ - isReadonly: false, - displayName: entry.displayName ?? undefined, - s3Uri: entry.s3Uri - })), + bookmarks: getBookmarksForProfileName({ profileName }), profileName, paramsOfCreateS3Client: id({ url: c.url, 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 d542a369d..cccf5483f 100644 --- a/web/src/core/usecases/s3ProfilesManagement/selectors.ts +++ b/web/src/core/usecases/s3ProfilesManagement/selectors.ts @@ -1,55 +1,35 @@ 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 { type S3Profile, createS3Profiles } from "./decoupledLogic/s3Profiles"; import { name } from "./state"; import type { State as RootState } from "core/bootstrap"; import { getRootContext } from "core/rootContext"; const state = (rootState: RootState) => rootState[name]; -const resolvedTemplatedBookmarks = createSelector( - state, - state => state.resolvedTemplatedBookmarks -); - -const resolvedTemplatedStsRoles = createSelector( - state, - state => state.resolvedTemplatedStsRoles -); - -const userConfigs_s3BookmarksStr = createSelector( - userConfigs.selectors.userConfigs, - userConfigs => userConfigs.s3BookmarksStr -); - const s3Profiles = createSelector( createSelector( projectManagement.protectedSelectors.projectConfig, projectConfig => projectConfig.s3Profiles ), - resolvedTemplatedBookmarks, - resolvedTemplatedStsRoles, - userConfigs_s3BookmarksStr, + createSelector( + userConfigs.selectors.userConfigs, + userConfigs => userConfigs.s3BookmarksStr + ), + createSelector(state, state => state.decodedIdTokens), ( - s3Profiles_vault, - resolvedTemplatedBookmarks, - resolvedTemplatedStsRoles, - userConfigs_s3BookmarksStr + s3Profiles_persistenceLayer, + userConfigs_s3BookmarksStr, + decodedIdTokens ): S3Profile[] => - aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ - fromVault: { - s3Profiles: s3Profiles_vault, + createS3Profiles({ + persistenceLayerData: { + s3Profiles: s3Profiles_persistenceLayer, userConfigs_s3BookmarksStr }, - fromConfig: { - entries: getRootContext().s3Config.entries, - resolvedTemplatedBookmarks, - resolvedTemplatedStsRoles - } + onyxiaInstanceS3ConfigEntries: getRootContext().s3Config.entries, + decodedIdTokens }) ); @@ -70,10 +50,6 @@ const ambientS3Profile = createSelector( } ); -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..339cde95c 100644 --- a/web/src/core/usecases/s3ProfilesManagement/state.ts +++ b/web/src/core/usecases/s3ProfilesManagement/state.ts @@ -2,18 +2,13 @@ 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; }[]; }; @@ -29,17 +24,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 5ff3e9f0d..ef0847ab9 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,20 @@ 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" + ) + .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 ( @@ -304,9 +307,12 @@ export const protectedThunks = { 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 => @@ -407,56 +413,22 @@ 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) + .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 }) ); } From 1596409641869eda0ed5b7948ec36fff4d57a6fc Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Tue, 25 Aug 2026 10:42:21 +0000 Subject: [PATCH 11/15] Enable shared directory to be accessed without login --- web/src/core/bootstrap.ts | 8 +- .../clusterEventsMonitor/selectors.ts | 6 +- web/src/core/usecases/launcher/selectors.ts | 4 +- web/src/core/usecases/launcher/thunks.ts | 4 +- .../usecases/projectManagement/selectors.ts | 8 +- .../core/usecases/projectManagement/thunks.ts | 3 +- .../restorableConfigManagement/selectors.ts | 2 +- .../restorableConfigManagement/thunks.ts | 8 +- .../s3ExplorerUiController/selectors.ts | 7 +- .../usecases/s3ExplorerUiController/thunks.ts | 31 ++ .../selectors.ts | 6 +- .../selectors.ts | 2 +- .../decoupledLogic/s3Profiles.ts | 397 +++++++++--------- .../s3ProfilesManagement/selectors.ts | 57 ++- .../usecases/s3ProfilesManagement/state.ts | 10 +- .../usecases/s3ProfilesManagement/thunks.ts | 26 +- web/src/ui/pages/s3Explorer/Page.tsx | 14 +- 17 files changed, 342 insertions(+), 251 deletions(-) diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index de4d528fd..dda47bc64 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -339,13 +339,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/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 be55b73b4..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 !== "onyxia instance config" + 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 83367f2d7..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 === "onyxia instance config" + 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/selectors.ts b/web/src/core/usecases/s3ExplorerUiController/selectors.ts index 6a4ddebfa..205cbe8d7 100644 --- a/web/src/core/usecases/s3ExplorerUiController/selectors.ts +++ b/web/src/core/usecases/s3ExplorerUiController/selectors.ts @@ -145,7 +145,7 @@ const profileName_anonymous = createSelector( if (ambientS3Profile === undefined) { return undefined; } - if (ambientS3Profile.origin !== "onyxia instance config") { + if (ambientS3Profile.origin !== "onyxia instance config (setup by admin)") { return undefined; } if ( @@ -157,7 +157,7 @@ const profileName_anonymous = createSelector( const s3Profile_anonymous = s3Profiles.find( s3Profile => - s3Profile.origin === "onyxia instance config" && + s3Profile.origin === "onyxia instance config (setup by admin)" && !s3Profile.paramsOfCreateS3Client.isStsEnabled && s3Profile.paramsOfCreateS3Client.credentials === undefined && s3Profile.paramsOfCreateS3Client.url === @@ -222,7 +222,8 @@ const profileSelect = createSelector( selectedProfile: { name: ambientS3Profile.profileName, url: ambientS3Profile.paramsOfCreateS3Client.url, - isReadonly: ambientS3Profile.origin === "onyxia instance config" + isReadonly: + ambientS3Profile.origin === "onyxia instance config (setup by admin)" }, availableProfileNames: s3Profiles.map(s3Profile => s3Profile.profileName) }; 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 1e3a0f43a..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 "onyxia instance config": + case "onyxia instance config (setup by admin)": return true; } })(), diff --git a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts index 8ec6cbeb5..ea0cf256f 100644 --- a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -11,7 +11,7 @@ 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 = { @@ -19,12 +19,12 @@ export namespace S3Profile { bookmarks: Bookmark[]; }; - export type DefinedInRegion = Common & { - origin: "onyxia instance config"; + 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; @@ -39,33 +39,47 @@ export namespace S3Profile { export function createS3Profiles(params: { onyxiaInstanceS3ConfigEntries: S3Config.Entry[]; - persistenceLayerData: { - s3Profiles: projectManagement.ProjectConfigs.S3Profile[]; - userConfigs_s3BookmarksStr: string | null; - }; - decodedIdTokens: { - oidcParams: OidcParams_Partial; - decodedIdToken: Record; - }[]; + userData: + | { + projectConfigs_s3Profiles: projectManagement.ProjectConfigs.S3Profile[]; + userConfigs_s3BookmarksStr: string | null; + decodedIdTokens: { + oidcParams: OidcParams_Partial; + decodedIdToken: Record; + }[]; + } + | undefined; }): S3Profile[] { - const { onyxiaInstanceS3ConfigEntries, persistenceLayerData, decodedIdTokens } = - params; + const { onyxiaInstanceS3ConfigEntries, userData } = params; const bookmarks_user: { displayName: string | undefined; s3Uri: S3Uri; profileName: string; - }[] = - persistenceLayerData.userConfigs_s3BookmarksStr === null - ? [] - : parseUserConfigsS3BookmarksStr({ - userConfigs_s3BookmarksStr: - persistenceLayerData.userConfigs_s3BookmarksStr - }); - - const s3Profiles: S3Profile[] = [ - ...persistenceLayerData.s3Profiles - .map((c): S3Profile.CreatedByUser => { + }[] = (() => { + 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 { projectConfigs_s3Profiles } = userData; + + return projectConfigs_s3Profiles + .map((c): S3Profile.UserCreated => { const url = c.url; const pathStyleAccess = c.pathStyleAccess; const region = c.region; @@ -90,181 +104,190 @@ export function createS3Profiles(params: { })) }; }) - .sort((a, b) => b.creationTime - a.creationTime), - ...onyxiaInstanceS3ConfigEntries - .map((c): S3Profile.DefinedInRegion[] => { - const decodedIdToken = (() => { - const wrap = decodedIdTokens.find(wrap => - same(wrap.oidcParams, c.sts.oidcParams) - ); - - 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 - } - ]; - } + .sort((a, b) => b.creationTime - a.creationTime); + })(); + + const s3Profiles_admin: S3Profile.SetupByAdmin[] = onyxiaInstanceS3ConfigEntries + .map((c): S3Profile.SetupByAdmin[] => { + const decodedIdToken = (() => { + if (userData === undefined) { + return undefined; + } - const bookmarks = resolveTemplatedBookmark({ - bookmark_fromConfig, - decodedIdToken - }); + const { decodedIdTokens } = userData; - 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 wrap = decodedIdTokens.find(wrap => + same(wrap.oidcParams, c.sts.oidcParams) + ); + + 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 + } + ]; + } + + if (decodedIdToken === undefined) { + return []; + } + + 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 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 escapedRegex = stringWithWildcards + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + .replace(/\\\*/g, ".*"); - const buildFromRole = (params: { - stsRole: StsRole; - }): S3Profile.DefinedInRegion => { - const { stsRole } = 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: stsRole - }; - - const { profileName } = stsRole; - - return { - origin: "onyxia instance config", - profileName, - bookmarks: getBookmarksForProfileName({ profileName }), - paramsOfCreateS3Client - }; - }; + return new RegExp(`^${escapedRegex}$`).test(candidate); + }; - const stsRoles: StsRole[] = c.sts.roles - .map(stsRole_fromConfig => { - if (!stsRole_fromConfig.isTemplated) { - return [ - { - roleARN: stsRole_fromConfig.roleARN, - roleSessionName: stsRole_fromConfig.roleSessionName, - profileName: stsRole_fromConfig.profileName - } - ]; - } + 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; + + 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 stsRoles = resolveTemplatedStsRole({ - stsRole_fromConfig: stsRole_fromConfig, - decodedIdToken - }); + const { profileName } = stsRole; - return stsRoles; + return { + origin: "onyxia instance config (setup by admin)", + profileName, + bookmarks: getBookmarksForProfileName({ profileName }), + paramsOfCreateS3Client + }; + }; + + const stsRoles: StsRole[] = + decodedIdToken === 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(); - - const s3Profiles: S3Profile.DefinedInRegion[] = stsRoles.map(stsRole => - buildFromRole({ stsRole }) ); + } - if (c.anonymousProfileName !== undefined) { - const profileName = c.anonymousProfileName; - - s3Profiles.push( - id({ - origin: "onyxia instance config", - bookmarks: getBookmarksForProfileName({ profileName }), - profileName, - paramsOfCreateS3Client: id({ - url: c.url, - isStsEnabled: false, - credentials: undefined, - pathStyleAccess: c.pathStyleAccess, - region: c.region - }) - }) - ); - } + return s3Profiles_admin; + }) + .flat(); - return s3Profiles; - }) - .flat() - ]; - - for (const s3Profile of [...s3Profiles].sort((a, b) => { - if (a.origin === b.origin) { - return 0; - } + const s3Profiles: S3Profile[] = [...s3Profiles_admin, ...s3Profiles_user]; - return a.origin === "onyxia instance config" ? -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/selectors.ts b/web/src/core/usecases/s3ProfilesManagement/selectors.ts index cccf5483f..3011993e3 100644 --- a/web/src/core/usecases/s3ProfilesManagement/selectors.ts +++ b/web/src/core/usecases/s3ProfilesManagement/selectors.ts @@ -1,35 +1,49 @@ import { createSelector } from "clean-architecture"; import * as projectManagement from "core/usecases/projectManagement"; import * as userConfigs from "core/usecases/userConfigs"; -import { type S3Profile, createS3Profiles } 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 s3Profiles = createSelector( - createSelector( - projectManagement.protectedSelectors.projectConfig, - projectConfig => projectConfig.s3Profiles - ), - createSelector( - userConfigs.selectors.userConfigs, - userConfigs => userConfigs.s3BookmarksStr - ), + (state: RootState) => { + const { oidc } = getRootContext(); + + if (!oidc.isUserLoggedIn) { + return undefined; + } + return projectManagement.protectedSelectors.projectConfigs(state).s3Profiles; + }, + (state: RootState) => { + const { oidc } = getRootContext(); + + if (!oidc.isUserLoggedIn) { + return undefined; + } + return userConfigs.selectors.userConfigs(state).s3BookmarksStr; + }, createSelector(state, state => state.decodedIdTokens), - ( - s3Profiles_persistenceLayer, - userConfigs_s3BookmarksStr, - decodedIdTokens - ): S3Profile[] => + (projectConfigs_s3Profiles, userConfigs_s3BookmarksStr, decodedIdTokens) => createS3Profiles({ - persistenceLayerData: { - s3Profiles: s3Profiles_persistenceLayer, - userConfigs_s3BookmarksStr - }, onyxiaInstanceS3ConfigEntries: getRootContext().s3Config.entries, - decodedIdTokens + userData: (() => { + if (decodedIdTokens === undefined) { + return undefined; + } + + assert(projectConfigs_s3Profiles !== undefined); + assert(userConfigs_s3BookmarksStr !== undefined); + + return { + decodedIdTokens, + userConfigs_s3BookmarksStr, + projectConfigs_s3Profiles + }; + })() }) ); @@ -44,7 +58,10 @@ const ambientS3Profile = createSelector( : s3Profiles => s3Profiles.profileName === ambientProfileName ) ?? s3Profiles.find(s3Profile => s3Profile.profileName === "default") ?? - s3Profiles.find(s3Profile => s3Profile.origin === "onyxia instance config") ?? + s3Profiles.find( + s3Profile => + s3Profile.origin === "onyxia instance config (setup by admin)" + ) ?? s3Profiles.find(() => true) ); } diff --git a/web/src/core/usecases/s3ProfilesManagement/state.ts b/web/src/core/usecases/s3ProfilesManagement/state.ts index 339cde95c..815e8fe2f 100644 --- a/web/src/core/usecases/s3ProfilesManagement/state.ts +++ b/web/src/core/usecases/s3ProfilesManagement/state.ts @@ -6,10 +6,12 @@ import type { OidcParams_Partial } from "core/ports/OnyxiaApi/OidcParams"; export type State = { ambientProfileName: string | undefined; - decodedIdTokens: { - oidcParams: OidcParams_Partial; - decodedIdToken: Record; - }[]; + decodedIdTokens: + | { + oidcParams: OidcParams_Partial; + decodedIdToken: Record; + }[] + | undefined; }; export const name = "s3ProfilesManagement"; diff --git a/web/src/core/usecases/s3ProfilesManagement/thunks.ts b/web/src/core/usecases/s3ProfilesManagement/thunks.ts index ef0847ab9..172183037 100644 --- a/web/src/core/usecases/s3ProfilesManagement/thunks.ts +++ b/web/src/core/usecases/s3ProfilesManagement/thunks.ts @@ -103,7 +103,8 @@ export const protectedThunks = { .s3Profiles(getState()) .filter( s3Profile => - s3Profile.origin === "onyxia instance config" + s3Profile.origin === + "onyxia instance config (setup by admin)" ) .map(s3Profile => s3Profile.bookmarks) .flat() @@ -169,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( @@ -205,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( @@ -253,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( @@ -302,7 +304,7 @@ export const protectedThunks = { ); } break; - case "onyxia instance config": + case "onyxia instance config (setup by admin)": { const { s3BookmarksStr } = userConfigs.selectors.userConfigs(getState()); @@ -381,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; diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index 9b400a7f9..e94bac6bf 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 }); From a22d79bdd9974e74deeb645760b5e86d355eb92e Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Tue, 25 Aug 2026 16:05:15 +0000 Subject: [PATCH 12/15] Remove disable actions that cannot be perormed as anonymous user on s3 --- web/src/ui/pages/s3Explorer/Page.tsx | 124 ++++++---- .../s3Explorer/dialogs/S3ProfileDialog.tsx | 26 +-- .../S3BookmarksBar/S3BookmarksBar.spec.md | 8 +- .../S3BookmarksBar/S3BookmarksBar.tsx | 8 +- .../S3BookmarksEntryPointItem.spec.md | 8 +- .../S3BookmarksEntryPointItem.tsx | 10 +- .../S3ExplorerMainView.spec.md | 5 +- .../S3ExplorerMainView/S3ExplorerMainView.tsx | 101 ++++---- .../S3ProfileSelect/S3ProfilSelect.spec.md | 4 +- .../codex/S3ProfileSelect/S3ProfileSelect.tsx | 42 ++-- .../S3SelectionActionBar.spec.md | 6 +- .../S3SelectionActionBar.tsx | 220 ++++++++++-------- web/src/ui/shared/codex/S3UriBar/S3UriBar.tsx | 5 +- .../S3ProfileDetails/S3ProfileDetails.spec.md | 4 +- .../S3ProfileDetails/S3ProfileDetails.tsx | 44 ++-- 15 files changed, 341 insertions(+), 274 deletions(-) diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx index e94bac6bf..8a8c93440 100644 --- a/web/src/ui/pages/s3Explorer/Page.tsx +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -158,6 +158,8 @@ function S3Explorer() { const { isCommandBarEnabled } = useCoreState("userConfigs", "userConfigs"); + const { isUserLoggedIn } = useCoreState("userAuthentication", "main"); + const { ref: ref_root, domRect: { height: rootHeight } @@ -230,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[] }) => { @@ -386,15 +395,19 @@ function S3Explorer() { > {t("no profile description")} - + {isUserLoggedIn && ( + + )} ); } @@ -437,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 } @@ -632,7 +652,11 @@ function S3Explorer() { anonymousProfileName }) } - onBookmark={toggleBookmarkFromDataView} + onBookmark={ + isUserLoggedIn + ? toggleBookmarkFromDataView + : undefined + } bookmarkedS3Uris={mainView.bookmarks.items.map( item => item.s3Uri )} 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/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/S3ExplorerMainView/S3ExplorerMainView.spec.md b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md index 42c0d653d..daa33c20f 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.spec.md @@ -70,7 +70,7 @@ export type S3ExplorerMainViewProps = { anonymousProfileName: string; }) => void; - onBookmark: (params: { s3Uri: S3Uri }) => void; + onBookmark: ((params: { s3Uri: S3Uri }) => void) | undefined; onDisplayCopyFeedback: (params: { s3Uri: S3Uri }) => void; @@ -213,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 @@ -260,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 diff --git a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx index f6bbf7e15..e1689e15a 100644 --- a/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx +++ b/web/src/ui/shared/codex/S3ExplorerMainView/S3ExplorerMainView.tsx @@ -87,7 +87,7 @@ export type S3ExplorerMainViewProps = { anonymousProfileName: string; }) => void; - onBookmark: (params: { s3Uri: S3Uri }) => void; + onBookmark: ((params: { s3Uri: S3Uri }) => void) | undefined; onDisplayCopyFeedback: (params: { s3Uri: S3Uri }) => void; @@ -600,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 @@ -803,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 @@ -1167,6 +1169,7 @@ export function S3ExplorerMainView(props: S3ExplorerMainViewProps) { itemKey )} onBookmark={ + onBookmark !== undefined && getIsItemActionAvailable(item) ? onBookmarkFactory(itemKey) : undefined @@ -2774,50 +2777,48 @@ const ItemRow = memo(function ItemRow(props: ItemRowProps) { /> - {onBookmark !== undefined && ( - + - { + event.stopPropagation(); + assert(isItemActionAvailable); + assert(onBookmark !== undefined); + onBookmark(); + }} > - - - - )} + {isBookmarked ? ( + + ) : ( + + )} + + + {onShare !== undefined && ( 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( 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 && ( + <> +
+ + + )}
)}
From 6a137f30fc52fcd35f39b91a125ca63baf8763a7 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Tue, 25 Aug 2026 19:46:03 +0000 Subject: [PATCH 13/15] Allow to declare purely anonymous s3 profile in config --- web/scripts/unyamlify-env-local.ts | 9 +- web/src/core/bootstrap.ts | 8 +- web/src/core/ports/OnyxiaApi/S3Config.ts | 120 ++++++++++-------- .../decoupledLogic/s3Profiles.ts | 12 +- .../usecases/s3ProfilesManagement/thunks.ts | 3 +- 5 files changed, 91 insertions(+), 61 deletions(-) diff --git a/web/scripts/unyamlify-env-local.ts b/web/scripts/unyamlify-env-local.ts index 678c09260..e0201ff9d 100644 --- a/web/scripts/unyamlify-env-local.ts +++ b/web/scripts/unyamlify-env-local.ts @@ -59,20 +59,23 @@ if (!fs.existsSync(envLocalYamlFilePath)) { ` {`, ` 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 dda47bc64..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"); diff --git a/web/src/core/ports/OnyxiaApi/S3Config.ts b/web/src/core/ports/OnyxiaApi/S3Config.ts index 433a39f80..5901bc20a 100644 --- a/web/src/core/ports/OnyxiaApi/S3Config.ts +++ b/web/src/core/ports/OnyxiaApi/S3Config.ts @@ -124,12 +124,15 @@ 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[]; }; @@ -208,58 +211,69 @@ 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 - ? { isTemplated: false } - : { - isTemplated: true, - 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 => ({ @@ -285,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/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts index ea0cf256f..757b841cd 100644 --- a/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts +++ b/web/src/core/usecases/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -110,6 +110,12 @@ export function createS3Profiles(params: { const s3Profiles_admin: S3Profile.SetupByAdmin[] = onyxiaInstanceS3ConfigEntries .map((c): S3Profile.SetupByAdmin[] => { const decodedIdToken = (() => { + const { sts } = c; + + if (sts === undefined) { + return undefined; + } + if (userData === undefined) { return undefined; } @@ -117,7 +123,7 @@ export function createS3Profiles(params: { const { decodedIdTokens } = userData; const wrap = decodedIdTokens.find(wrap => - same(wrap.oidcParams, c.sts.oidcParams) + same(wrap.oidcParams, sts.oidcParams) ); assert(wrap !== undefined); @@ -212,6 +218,8 @@ export function createS3Profiles(params: { }): S3Profile.SetupByAdmin => { const { stsRole } = params; + assert(c.sts !== undefined); + const paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts = { url: c.url, pathStyleAccess: c.pathStyleAccess, @@ -234,7 +242,7 @@ export function createS3Profiles(params: { }; const stsRoles: StsRole[] = - decodedIdToken === undefined + decodedIdToken === undefined || c.sts === undefined ? [] : c.sts.roles .map(stsRole_fromConfig => { diff --git a/web/src/core/usecases/s3ProfilesManagement/thunks.ts b/web/src/core/usecases/s3ProfilesManagement/thunks.ts index 172183037..bea7f0914 100644 --- a/web/src/core/usecases/s3ProfilesManagement/thunks.ts +++ b/web/src/core/usecases/s3ProfilesManagement/thunks.ts @@ -426,7 +426,8 @@ export const protectedThunks = { }; const oidcParams_arr = s3Config.entries - .map(entry => entry.sts.oidcParams) + .map(entry => entry.sts?.oidcParams) + .filter(oidcParams => oidcParams !== undefined) .reduce(...removeDuplicates(same)); const decodedIdTokens: State["decodedIdTokens"] = await Promise.all( From 6707741b86ed0b06bee5f3ebb5d2848a0467bfaa Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Tue, 25 Aug 2026 19:47:55 +0000 Subject: [PATCH 14/15] Relase candidate --- web/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/package.json b/web/package.json index db0dc33d1..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.7.0-rc.2", + "version": "5.7.0-rc.3", "license": "MIT", "scripts": { "postinstall": "yarn install-git-hooks && yarn postinstall:code-gen", From d46070705fb3a5c4c150a9b7dc83c219994f919b Mon Sep 17 00:00:00 2001 From: actions Date: Tue, 25 Aug 2026 19:51:20 +0000 Subject: [PATCH 15/15] Automatic minor bump of chart version to 11.7.0-rc.3 --- helm-chart/Chart.yaml | 2 +- helm-chart/README.md | 10 +++++----- helm-chart/values.yaml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml index f1fc8b2d4..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.7.0-rc.2 +version: 11.7.0-rc.3 diff --git a/helm-chart/README.md b/helm-chart/README.md index 6654cd831..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.7.0-rc.2" -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.7.0-rc.2" -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.7.0-rc.2/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.7.0-rc.2/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.7.0-rc.2/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 c96a8046a..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.7.0-rc.2 + tag: 5.7.0-rc.3 pullPolicy: IfNotPresent imagePullSecrets: []