diff --git a/web/package.json b/web/package.json index 3fb0a3feb..182215ee3 100644 --- a/web/package.json +++ b/web/package.json @@ -54,7 +54,7 @@ "async-mutex": "^0.5.0", "axios": "^1.9.0", "bytes": "^3.1.2", - "clean-architecture": "^6.0.3", + "clean-architecture": "^6.1.0", "codemirror": "6.0.1", "codemirror-json-schema": "0.7.9", "compare-versions": "^6.1.1", @@ -78,7 +78,7 @@ "react-dom": "^18.3.1", "run-exclusive": "^2.2.19", "screen-scaler": "^2.0.0", - "tsafe": "^1.8.5", + "tsafe": "^1.8.12", "tss-react": "^4.9.18", "type-route": "1.1.0", "xterm": "^5.3.0", diff --git a/web/spec.md b/web/spec.md new file mode 100644 index 000000000..6624f2c1b --- /dev/null +++ b/web/spec.md @@ -0,0 +1,57 @@ +Before: + +```js +{ + workingDirectory: { + bucketMode: "multi", + bucketNamePrefix: "", + bucketNamePrefixGroup: "projet-" + }, + bookmarkedDirectories: [ + { + fullPath: "donnees-insee/diffusion/", + title: { + fr: "Données de diffusion", + en: "Dissemination Data" + }, + description: { + fr: "Bucket public destiné à la diffusion de données", + en: "Public bucket intended for data dissemination" + } + } + ] +} +``` + +After: + +```js +{ + bookmarkedDirectories: [ + { + fullPath: "$1/", + title: "Personal", + description: "Personal Bucket", + claimName: "preferred_username" + }, + { + fullPath: "projet-$1/", + title: "Group $1", + description: "Shared bucket among members of project $1", + claimName: "groups", + excludedClaimPattern: "^USER_ONYXIA$" + }, + { + fullPath: "donnees-insee/diffusion/", + title: { + fr: "Données de diffusion", + en: "Dissemination Data" + }, + description: { + fr: "Bucket public destiné à la diffusion de données", + en: "Public bucket intended for data dissemination" + } + } + ]; +} +``` diff --git a/web/src/core/adapters/onyxiaApi/ApiTypes.ts b/web/src/core/adapters/onyxiaApi/ApiTypes.ts index 1e1464a2d..6375eb3c5 100644 --- a/web/src/core/adapters/onyxiaApi/ApiTypes.ts +++ b/web/src/core/adapters/onyxiaApi/ApiTypes.ts @@ -90,16 +90,24 @@ export type ApiTypes = { sts?: { URL?: string; durationSeconds?: number; - role: - | { - roleARN: string; - roleSessionName: string; - } - | undefined; + role?: ArrayOrNot< + { + roleARN: string; + roleSessionName: string; + } & ( + | { claimName?: undefined } + | { + claimName: string; + includedClaimPattern?: string; + excludedClaimPattern?: string; + } + ) + >; oidcConfiguration?: Partial; }; /** Ok to be undefined only if sts is undefined */ + // NOTE: Remove in next major workingDirectory?: | { bucketMode: "shared"; @@ -115,14 +123,15 @@ export type ApiTypes = { bookmarkedDirectories?: ({ fullPath: string; title: LocalizedString; - description: LocalizedString | undefined; - tags: LocalizedString[] | undefined; + description?: LocalizedString; + tags?: LocalizedString[]; + forStsRoleSessionName?: string | string[]; } & ( - | { claimName: undefined } + | { claimName?: undefined } | { claimName: string; - includedClaimPattern: string; - excludedClaimPattern: string; + includedClaimPattern?: string; + excludedClaimPattern?: string; } ))[]; }>; diff --git a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts index 02c9892c2..585d60c44 100644 --- a/web/src/core/adapters/onyxiaApi/onyxiaApi.ts +++ b/web/src/core/adapters/onyxiaApi/onyxiaApi.ts @@ -21,6 +21,8 @@ import { exclude } from "tsafe/exclude"; import type { ApiTypes } from "./ApiTypes"; import { Evt } from "evt"; import { id } from "tsafe/id"; +import { parseS3UriPrefix } from "core/tools/S3Uri"; +import type { LocalizedString } from "core/ports/OnyxiaApi"; export function createOnyxiaApi(params: { url: string; @@ -187,6 +189,45 @@ export function createOnyxiaApi(params: { })() }); + const bookmarkedDirectories_test = await (async () => { + if (!window.location.href.includes("localhost")) { + return []; + } + + return id< + ({ + fullPath: string; + title: LocalizedString; + description?: LocalizedString; + tags?: LocalizedString[]; + forStsRoleSessionName?: string | string[]; + } & ( + | { claimName?: undefined } + | { + claimName: string; + includedClaimPattern?: string; + excludedClaimPattern?: string; + } + ))[] + >([ + { + fullPath: "$1/", + title: "Personal", + description: "Personal Bucket", + claimName: "preferred_username", + forStsRoleSessionName: undefined + }, + { + fullPath: "projet-$1/", + title: "Group $1", + description: "Shared bucket among members of project $1", + claimName: "groups", + excludedClaimPattern: "^USER_ONYXIA$", + forStsRoleSessionName: undefined + } + ]); + })(); + const regions = data.regions.map( (apiRegion): DeploymentRegion => id({ @@ -289,30 +330,268 @@ export function createOnyxiaApi(params: { }; }) .filter(exclude(undefined)) - .map(s3Config_api => ({ + .map(s3Config_api => + id({ + url: s3Config_api.URL, + pathStyleAccess: + s3Config_api.pathStyleAccess ?? true, + region: s3Config_api.region, + sts: { + url: s3Config_api.sts.URL, + durationSeconds: + s3Config_api.sts.durationSeconds, + role: (() => { + if ( + s3Config_api.sts.role === + undefined + ) { + return undefined; + } + + const entry = + s3Config_api.sts + .role instanceof Array + ? s3Config_api.sts.role[0] + : s3Config_api.sts.role; + + return { + roleARN: entry.roleARN, + roleSessionName: + entry.roleSessionName + }; + })(), + oidcParams: + apiTypesOidcConfigurationToOidcParams_Partial( + s3Config_api.sts + .oidcConfiguration + ) + }, + workingDirectory: + s3Config_api.workingDirectory, + bookmarkedDirectories: + s3Config_api.bookmarkedDirectories?.map( + bookmarkedDirectory_api => { + const { + fullPath, + title, + description, + tags, + ...rest + } = bookmarkedDirectory_api; + + return id( + { + fullPath, + title, + description, + tags, + ...(rest.claimName === + undefined + ? { + claimName: + undefined + } + : { + claimName: + rest.claimName, + includedClaimPattern: + rest.includedClaimPattern, + excludedClaimPattern: + rest.excludedClaimPattern + }) + } + ); + } + ) ?? [] + }) + ); + + const s3Profiles: DeploymentRegion.S3Next.S3Profile[] = + s3Configs_api + .filter( + s3Configs_api => + s3Configs_api.sts !== undefined + ) + .map( + ( + s3Config_api + ): DeploymentRegion.S3Next.S3Profile => { + return { + url: s3Config_api.URL, + pathStyleAccess: + s3Config_api.pathStyleAccess ?? + true, + region: s3Config_api.region, + sts: (() => { + const sts_api = s3Config_api.sts; + + assert(sts_api !== undefined); + + return { + url: sts_api.URL, + durationSeconds: + sts_api.durationSeconds, + roles: (() => { + if ( + sts_api.role === + undefined + ) { + return []; + } + + const rolesArray = + sts_api.role instanceof + Array + ? sts_api.role + : [sts_api.role]; + + return rolesArray.map( + ( + role_api + ): DeploymentRegion.S3Next.S3Profile.StsRole => ({ + roleARN: + role_api.roleARN, + roleSessionName: + role_api.roleSessionName, + ...(role_api.claimName === + undefined + ? { + claimName: + undefined + } + : { + claimName: + role_api.claimName, + includedClaimPattern: + role_api.includedClaimPattern, + excludedClaimPattern: + role_api.excludedClaimPattern + }) + }) + ); + })(), + oidcParams: + apiTypesOidcConfigurationToOidcParams_Partial( + sts_api.oidcConfiguration + ) + } as any; + })(), + bookmarks: [ + ...bookmarkedDirectories_test, + ...(s3Config_api.bookmarkedDirectories ?? + []) + ].map( + ( + bookmarkedDirectory_api + ): DeploymentRegion.S3Next.S3Profile.Bookmark => { + return id( + { + s3UriPrefix: (() => { + const s3UriPrefix = `s3://${bookmarkedDirectory_api.fullPath}`; + + // NOTE: Just for checking shape. + parseS3UriPrefix({ + s3UriPrefix, + strict: true + }); + + return s3UriPrefix; + })(), + title: bookmarkedDirectory_api.title, + description: + bookmarkedDirectory_api.description, + tags: + bookmarkedDirectory_api.tags ?? + [], + forStsRoleSessionNames: + (() => { + const v = + bookmarkedDirectory_api.forStsRoleSessionName; + + if ( + v === + undefined + ) { + return []; + } + + if ( + typeof v === + "string" + ) { + return [ + v + ]; + } + + return v; + })(), + ...(bookmarkedDirectory_api.claimName === + undefined + ? { + claimName: + undefined + } + : { + claimName: + bookmarkedDirectory_api.claimName, + includedClaimPattern: + bookmarkedDirectory_api.includedClaimPattern, + excludedClaimPattern: + bookmarkedDirectory_api.excludedClaimPattern + }) + } + ); + } + ) + }; + } + ); + + const s3Profiles_defaultValuesOfCreationForm: DeploymentRegion["_s3Next"]["s3Profiles_defaultValuesOfCreationForm"] = + (() => { + const s3Config_api = (() => { + config_without_sts: { + const s3Config_api = s3Configs_api.find( + s3Config_api => + s3Config_api.sts === undefined + ); + + if (s3Config_api === undefined) { + break config_without_sts; + } + + return s3Config_api; + } + + if (s3Configs_api.length === 0) { + return undefined; + } + + const [s3Config_api] = s3Configs_api; + + return s3Config_api; + })(); + + if (s3Config_api === undefined) { + return undefined; + } + + return { url: s3Config_api.URL, pathStyleAccess: s3Config_api.pathStyleAccess ?? true, - region: s3Config_api.region, - sts: { - url: s3Config_api.sts.URL, - durationSeconds: - s3Config_api.sts.durationSeconds, - role: s3Config_api.sts.role, - oidcParams: - apiTypesOidcConfigurationToOidcParams_Partial( - s3Config_api.sts.oidcConfiguration - ) - }, - workingDirectory: - s3Config_api.workingDirectory, - bookmarkedDirectories: - s3Config_api.bookmarkedDirectories ?? [] - })); + region: s3Config_api.region + }; + })(); return { s3Configs, - s3ConfigCreationFormDefaults + s3ConfigCreationFormDefaults, + _s3Next: id({ + s3Profiles, + s3Profiles_defaultValuesOfCreationForm + }) }; })(), allowedURIPatternForUserDefinedInitScript: diff --git a/web/src/core/adapters/s3Client/s3Client.ts b/web/src/core/adapters/s3Client/s3Client.ts index c97b5dd0e..e5e574df6 100644 --- a/web/src/core/adapters/s3Client/s3Client.ts +++ b/web/src/core/adapters/s3Client/s3Client.ts @@ -5,7 +5,7 @@ import { } from "core/tools/getNewlyRequestedOrCachedToken"; import { assert, is } from "tsafe/assert"; import type { Oidc } from "core/ports/Oidc"; -import { bucketNameAndObjectNameFromS3Path } from "./utils/bucketNameAndObjectNameFromS3Path"; +import { parseS3UriPrefix, getIsS3UriPrefix, parseS3Uri } from "core/tools/S3Uri"; import { exclude } from "tsafe/exclude"; import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; import { getPolicyAttributes } from "core/tools/getPolicyAttributes"; @@ -51,7 +51,6 @@ export namespace ParamsOfCreateS3Client { roleSessionName: string; } | undefined; - nameOfBucketToCreateIfNotExist: string | undefined; }; } @@ -222,51 +221,6 @@ export function createS3Client( return { getAwsS3Client }; })(); - create_bucket: { - if (!params.isStsEnabled) { - break create_bucket; - } - - const { nameOfBucketToCreateIfNotExist } = params; - - if (nameOfBucketToCreateIfNotExist === undefined) { - break create_bucket; - } - - const { awsS3Client } = await getAwsS3Client(); - - const { CreateBucketCommand, BucketAlreadyExists, BucketAlreadyOwnedByYou } = - await import("@aws-sdk/client-s3"); - - try { - await awsS3Client.send( - new CreateBucketCommand({ - Bucket: nameOfBucketToCreateIfNotExist - }) - ); - } catch (error) { - assert(is(error)); - - if ( - !(error instanceof BucketAlreadyExists) && - !(error instanceof BucketAlreadyOwnedByYou) - ) { - console.log( - "An unexpected error occurred while creating the bucket, we ignore it:", - error - ); - break create_bucket; - } - - console.log( - [ - `The above network error is expected we tried creating the `, - `bucket ${nameOfBucketToCreateIfNotExist} in case it didn't exist but it did.` - ].join(" ") - ); - } - } - return { getNewlyRequestedOrCachedToken, clearCachedToken, getAwsS3Client }; })(); @@ -281,27 +235,62 @@ export function createS3Client( return getNewlyRequestedOrCachedToken(); }, listObjects: async ({ path }) => { - const { bucketName, prefix } = (() => { - const { bucketName, objectName } = - bucketNameAndObjectNameFromS3Path(path); - - const prefix = - objectName === "" - ? "" - : objectName.endsWith("/") - ? objectName - : `${objectName}/`; - - return { - bucketName, - prefix - }; - })(); + const { bucket: bucketName, keyPrefix: prefix } = parseS3UriPrefix({ + s3UriPrefix: `s3://${path}`, + strict: true + }); const { getAwsS3Client } = await prApi; const { awsS3Client } = await getAwsS3Client(); + const Contents: import("@aws-sdk/client-s3")._Object[] = []; + const CommonPrefixes: import("@aws-sdk/client-s3").CommonPrefix[] = []; + + { + let continuationToken: string | undefined; + + do { + const listObjectsV2Command = new ( + await import("@aws-sdk/client-s3") + ).ListObjectsV2Command({ + Bucket: bucketName, + Prefix: prefix, + Delimiter: "/", + ContinuationToken: continuationToken + }); + + let resp: import("@aws-sdk/client-s3").ListObjectsV2CommandOutput; + + try { + resp = await awsS3Client.send(listObjectsV2Command); + } catch (error) { + const { NoSuchBucket, S3ServiceException } = await import( + "@aws-sdk/client-s3" + ); + + if (error instanceof NoSuchBucket) { + return { isSuccess: false, errorCase: "no such bucket" }; + } + + if ( + error instanceof S3ServiceException && + error.$metadata?.httpStatusCode === 403 + ) { + return { isSuccess: false, errorCase: "access denied" }; + } + + throw error; + } + + Contents.push(...(resp.Contents ?? [])); + + CommonPrefixes.push(...(resp.CommonPrefixes ?? [])); + + continuationToken = resp.NextContinuationToken; + } while (continuationToken !== undefined); + } + const { isBucketPolicyAvailable, allowedPrefix, bucketPolicy } = await (async () => { const { GetBucketPolicyCommand, S3ServiceException } = await import( @@ -409,30 +398,6 @@ export function createS3Client( }; })(); - const Contents: import("@aws-sdk/client-s3")._Object[] = []; - const CommonPrefixes: import("@aws-sdk/client-s3").CommonPrefix[] = []; - - { - let continuationToken: string | undefined; - - do { - const resp = await awsS3Client.send( - new (await import("@aws-sdk/client-s3")).ListObjectsV2Command({ - Bucket: bucketName, - Prefix: prefix, - Delimiter: "/", - ContinuationToken: continuationToken - }) - ); - - Contents.push(...(resp.Contents ?? [])); - - CommonPrefixes.push(...(resp.CommonPrefixes ?? [])); - - continuationToken = resp.NextContinuationToken; - } while (continuationToken !== undefined); - } - const policyAttributes = (path: string) => { return getPolicyAttributes(allowedPrefix, path); }; @@ -464,16 +429,49 @@ export function createS3Client( ); return { + isSuccess: true, objects: [...directories, ...files], bucketPolicy, isBucketPolicyAvailable }; }, + // TODO: @ddecrulle Please refactor this, objectName can either be a + // a keyPrefix or a fully qualified key but there is multiple level of + // indirection, the check is done deep instead of upfront. + // I'm pretty sure that having a * at the end of the resourceArn when setting access right + // for a specific object is not what we want. + // When extracting things to standalone utils the contract must be clearly + // defined, here it is not so it only give the feeling of decoupling but + // in reality it's impossible to guess what addResourceArnInGetObjectStatement is doing + // plus naming things is hard, bad names and bad abstractions are harmful because misleading. + // this function is not adding anything to anything it's returning something. + // Plus resourceArn already encapsulate the bucketName and objectName. + // Here we have 4 functions that are used once, that involve implicit coupling and with misleading name. + // SO, if you can't abstract away in a clean way, just don't and put everything inline + // in closures. At least we know where we stand. setPathAccessPolicy: async ({ currentBucketPolicy, policy, path }) => { const { getAwsS3Client } = await prApi; const { awsS3Client } = await getAwsS3Client(); - const { bucketName, objectName } = bucketNameAndObjectNameFromS3Path(path); + const { bucketName, objectName } = (() => { + if (getIsS3UriPrefix(`s3://${path}`)) { + const s3UriPrefixObj = parseS3UriPrefix({ + s3UriPrefix: `s3://${path}`, + strict: true + }); + return { + bucketName: s3UriPrefixObj.bucket, + objectName: s3UriPrefixObj.keyPrefix + }; + } + + const s3UriObj = parseS3Uri(`s3://${path}`); + + return { + bucketName: s3UriObj.bucket, + objectName: s3UriObj.key + }; + })(); const resourceArn = `arn:aws:s3:::${bucketName}/${objectName}*`; const bucketArn = `arn:aws:s3:::${bucketName}`; @@ -525,7 +523,7 @@ export function createS3Client( import("@aws-sdk/lib-storage").then(({ Upload }) => Upload) ]); - const { bucketName, objectName } = bucketNameAndObjectNameFromS3Path(path); + const { bucket: bucketName, key: objectName } = parseS3Uri(`s3://${path}`); const upload = new Upload({ client: awsS3Client, @@ -556,7 +554,7 @@ export function createS3Client( await upload.done(); }, deleteFile: async ({ path }) => { - const { bucketName, objectName } = bucketNameAndObjectNameFromS3Path(path); + const { bucket: bucketName, key: objectName } = parseS3Uri(`s3://${path}`); const { getAwsS3Client } = await prApi; @@ -571,7 +569,7 @@ export function createS3Client( }, deleteFiles: async ({ paths }) => { //bucketName is the same for all paths - const { bucketName } = bucketNameAndObjectNameFromS3Path(paths[0]); + const { bucket: bucketName } = parseS3Uri(`s3://${paths[0]}`); const { getAwsS3Client } = await prApi; @@ -580,7 +578,7 @@ export function createS3Client( const { DeleteObjectsCommand } = await import("@aws-sdk/client-s3"); const objects = paths.map(path => { - const { objectName } = bucketNameAndObjectNameFromS3Path(path); + const { key: objectName } = parseS3Uri(`s3://${path}`); return { Key: objectName }; }); @@ -597,7 +595,7 @@ export function createS3Client( } }, getFileDownloadUrl: async ({ path, validityDurationSecond }) => { - const { bucketName, objectName } = bucketNameAndObjectNameFromS3Path(path); + const { bucket: bucketName, key: objectName } = parseS3Uri(`s3://${path}`); const { getAwsS3Client } = await prApi; @@ -620,7 +618,7 @@ export function createS3Client( }, getFileContent: async ({ path, range }) => { - const { bucketName, objectName } = bucketNameAndObjectNameFromS3Path(path); + const { bucket: bucketName, key: objectName } = parseS3Uri(`s3://${path}`); const { getAwsS3Client } = await prApi; const { awsS3Client } = await getAwsS3Client(); @@ -646,7 +644,7 @@ export function createS3Client( }, getFileContentType: async ({ path }) => { - const { bucketName, objectName } = bucketNameAndObjectNameFromS3Path(path); + const { bucket: bucketName, key: objectName } = parseS3Uri(`s3://${path}`); const { getAwsS3Client } = await prApi; @@ -660,6 +658,52 @@ export function createS3Client( ); return head.ContentType; + }, + createBucket: async ({ bucket }) => { + const { getAwsS3Client } = await prApi; + + const { awsS3Client } = await getAwsS3Client(); + + const { + CreateBucketCommand, + BucketAlreadyExists, + BucketAlreadyOwnedByYou, + S3ServiceException + } = await import("@aws-sdk/client-s3"); + + try { + await awsS3Client.send( + new CreateBucketCommand({ + Bucket: bucket + }) + ); + } catch (error) { + assert(is(error)); + + if ( + error instanceof S3ServiceException && + error.$metadata?.httpStatusCode === 403 + ) { + return { + isSuccess: false, + errorCase: "access denied", + errorMessage: error.message + }; + } + + if ( + !(error instanceof BucketAlreadyExists) && + !(error instanceof BucketAlreadyOwnedByYou) + ) { + return { + isSuccess: false, + errorCase: "already exist", + errorMessage: error.message + }; + } + } + + return { isSuccess: true }; } }; diff --git a/web/src/core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path.ts b/web/src/core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path.ts deleted file mode 100644 index 1603254d1..000000000 --- a/web/src/core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * "/bucket-name/object/name" => { bucketName: "bucket-name", objectName: "object/name" } - * "bucket-name/object/name" => { bucketName: "bucket-name", objectName: "object/name" } - * "bucket-name/object/name/" => { bucketName: "bucket-name", objectName: "object/name/" } - * "bucket-name/" => { bucketName: "bucket-name", objectName: "" } - * "bucket-name" => { bucketName: "bucket-name", objectName: "" } - * "s3://bucket-name/object/name" => { bucketName: "bucket-name", objectName: "object/name" } - */ -export function bucketNameAndObjectNameFromS3Path(path: string) { - const [bucketName, ...rest] = path.replace(/^(s3:)?\/+/, "").split("/"); - - return { - bucketName, - objectName: rest.join("/") - }; -} diff --git a/web/src/core/bootstrap.ts b/web/src/core/bootstrap.ts index 7dd134067..8ad19f5a8 100644 --- a/web/src/core/bootstrap.ts +++ b/web/src/core/bootstrap.ts @@ -157,7 +157,7 @@ export async function bootstrapCore( } const result = await dispatch( - usecases.s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + usecases.s3ProfilesManagement.protectedThunks.getS3ConfigAndClientForExplorer() ); if (result === undefined) { @@ -166,15 +166,15 @@ export async function bootstrapCore( }; } - const { s3Config, s3Client } = result; + const { s3Profile, s3Client } = result; return { s3Client, - s3_endpoint: s3Config.paramsOfCreateS3Client.url, - s3_url_style: s3Config.paramsOfCreateS3Client.pathStyleAccess + s3_endpoint: s3Profile.paramsOfCreateS3Client.url, + s3_url_style: s3Profile.paramsOfCreateS3Client.pathStyleAccess ? "path" : "vhost", - s3_region: s3Config.region + s3_region: s3Profile.paramsOfCreateS3Client.region }; } }) @@ -273,6 +273,7 @@ export async function bootstrapCore( if (oidc.isUserLoggedIn) { await dispatch(usecases.s3ConfigManagement.protectedThunks.initialize()); + await dispatch(usecases.s3ProfilesManagement.protectedThunks.initialize()); } pluginSystemInitCore({ core, context }); diff --git a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts index 8d8453a3b..5463f65c4 100644 --- a/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts +++ b/web/src/core/ports/OnyxiaApi/DeploymentRegion.ts @@ -25,6 +25,16 @@ export type DeploymentRegion = { workingDirectory: DeploymentRegion.S3Config["workingDirectory"] | undefined; }) | undefined; + _s3Next: { + s3Profiles: DeploymentRegion.S3Next.S3Profile[]; + s3Profiles_defaultValuesOfCreationForm: + | Pick< + DeploymentRegion.S3Next.S3Profile, + "url" | "pathStyleAccess" | "region" + > + | undefined; + }; + allowedURIPatternForUserDefinedInitScript: string; kafka: | { @@ -160,4 +170,57 @@ export namespace DeploymentRegion { }; } } + + export namespace S3Next { + /** https://github.com/InseeFrLab/onyxia-api/blob/main/docs/region-configuration.md#s3 */ + export type S3Profile = { + url: string; + pathStyleAccess: boolean; + region: string | undefined; + sts: { + url: string | undefined; + durationSeconds: number | undefined; + roles: S3Profile.StsRole[]; + oidcParams: OidcParams_Partial; + }; + bookmarks: S3Profile.Bookmark[]; + }; + + export namespace S3Profile { + export type StsRole = { + roleARN: string; + roleSessionName: string; + } & ( + | { + claimName: undefined; + includedClaimPattern?: never; + excludedClaimPattern?: never; + } + | { + claimName: string; + includedClaimPattern: string | undefined; + excludedClaimPattern: string | undefined; + } + ); + + export type Bookmark = { + s3UriPrefix: string; + title: LocalizedString; + description: LocalizedString | undefined; + tags: LocalizedString[]; + forStsRoleSessionNames: string[]; + } & ( + | { + claimName: undefined; + includedClaimPattern?: never; + excludedClaimPattern?: never; + } + | { + claimName: string; + includedClaimPattern: string | undefined; + excludedClaimPattern: string | undefined; + } + ); + } + } } diff --git a/web/src/core/ports/OnyxiaApi/XOnyxia.ts b/web/src/core/ports/OnyxiaApi/XOnyxia.ts index 275a85090..ed76d4d3a 100644 --- a/web/src/core/ports/OnyxiaApi/XOnyxia.ts +++ b/web/src/core/ports/OnyxiaApi/XOnyxia.ts @@ -115,19 +115,8 @@ export type XOnyxiaContext = { AWS_SESSION_TOKEN: string | undefined; AWS_DEFAULT_REGION: string; AWS_S3_ENDPOINT: string; - AWS_BUCKET_NAME: string; port: number; pathStyleAccess: boolean; - /** - * The user is assumed to have read/write access on every - * object starting with this prefix on the bucket - **/ - objectNamePrefix: string; - /** - * Only for making it easier for charts editors. - * / - * */ - workingDirectoryPath: string; /** * If true the bucket's (directory) should be accessible without any credentials. * In this case s3.AWS_ACCESS_KEY_ID, s3.AWS_SECRET_ACCESS_KEY and s3.AWS_SESSION_TOKEN diff --git a/web/src/core/ports/S3Client.ts b/web/src/core/ports/S3Client.ts index d560c19a8..eb1734f30 100644 --- a/web/src/core/ports/S3Client.ts +++ b/web/src/core/ports/S3Client.ts @@ -34,11 +34,18 @@ export type S3Client = { /** * In charge of creating bucket if doesn't exist. */ - listObjects: (params: { path: string }) => Promise<{ - objects: S3Object[]; - bucketPolicy: S3BucketPolicy | undefined; - isBucketPolicyAvailable: boolean; - }>; + listObjects: (params: { path: string }) => Promise< + | { + isSuccess: false; + errorCase: "access denied" | "no such bucket"; + } + | { + isSuccess: true; + objects: S3Object[]; + bucketPolicy: S3BucketPolicy | undefined; + isBucketPolicyAvailable: boolean; + } + >; setPathAccessPolicy: (params: { path: string; @@ -71,6 +78,15 @@ export type S3Client = { getFileContentType: (params: { path: string }) => Promise; + createBucket: (params: { bucket: string }) => Promise< + | { isSuccess: true } + | { + isSuccess: false; + errorCase: "already exist" | "access denied" | "unknown"; + errorMessage: string; + } + >; + // getPresignedUploadUrl: (params: { // path: string; // validityDurationSecond: number; diff --git a/web/src/core/tools/S3Uri.ts b/web/src/core/tools/S3Uri.ts new file mode 100644 index 000000000..dbf1e931e --- /dev/null +++ b/web/src/core/tools/S3Uri.ts @@ -0,0 +1,84 @@ +export type S3UriPrefixObj = { + bucket: string; + /** "" | `${string}/` */ + keyPrefix: string; +}; + +export function parseS3UriPrefix(params: { + s3UriPrefix: string; + strict: boolean; +}): S3UriPrefixObj { + const { s3UriPrefix, strict } = params; + + const match = s3UriPrefix.match(/^s3:\/\/([^/]+)(\/?.*)$/); + + if (match === null) { + throw new Error(`Malformed S3 URI Prefix: ${s3UriPrefix}`); + } + + const bucket = match[1]; + + let keyPrefix = match[2]; + + if (strict && !keyPrefix.endsWith("/")) { + throw new Error( + [ + `Invalid S3 URI Prefix: "${s3UriPrefix}".`, + `A S3 URI Prefix should end with a "/" character.` + ].join(" ") + ); + } + + keyPrefix = match[2].replace(/^\//, ""); + + if (keyPrefix !== "" && !keyPrefix.endsWith("/")) { + keyPrefix += "/"; + } + + const s3UriPrefixObj = { bucket, keyPrefix }; + + return s3UriPrefixObj; +} + +export function stringifyS3UriPrefixObj(s3UriPrefixObj: S3UriPrefixObj): string { + return `s3://${s3UriPrefixObj.bucket}/${s3UriPrefixObj.keyPrefix}`; +} + +export function getIsS3UriPrefix(str: string): boolean { + try { + parseS3UriPrefix({ + s3UriPrefix: str, + strict: true + }); + } catch { + return false; + } + + return true; +} + +export type S3UriObj = { + bucket: string; + key: string; +}; + +export function parseS3Uri(s3Uri: string): S3UriObj { + if (getIsS3UriPrefix(s3Uri)) { + throw new Error(`${s3Uri} is a S3 URI Prefix, not a fully qualified S3 URI.`); + } + + let s3UriPrefixObj: S3UriPrefixObj; + + try { + s3UriPrefixObj = parseS3UriPrefix({ s3UriPrefix: s3Uri, strict: false }); + } catch { + throw new Error(`Malformed S3 URI: ${s3Uri}`); + } + + const s3UriObj: S3UriObj = { + bucket: s3UriPrefixObj.bucket, + key: s3UriPrefixObj.keyPrefix.replace(/\/$/, "") + }; + + return s3UriObj; +} diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts new file mode 100644 index 000000000..95de61d5d --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/evt.ts @@ -0,0 +1,46 @@ +import type { CreateEvt } from "core/bootstrap"; +import { Evt } from "evt"; +import { name, type RouteParams } from "./state"; +import { onlyIfChanged } from "evt/operators/onlyIfChanged"; +import { protectedSelectors } from "./selectors"; +import { same } from "evt/tools/inDepth/same"; + +export const evt = Evt.create<{ + actionName: "updateRoute"; + method: "replace" | "push"; + routeParams: RouteParams; +}>(); + +export const createEvt = (({ evtAction, getState }) => { + evtAction + .pipe(action => (action.usecaseName !== name ? null : [action.actionName])) + .pipe(() => protectedSelectors.isStateInitialized(getState())) + .pipe(actionName => [ + { + actionName, + routeParams: protectedSelectors.routeParams(getState()) + } + ]) + .pipe( + onlyIfChanged({ + areEqual: (a, b) => same(a.routeParams, b.routeParams) + }) + ) + .attach(({ actionName, routeParams }) => { + evt.post({ + actionName: "updateRoute", + method: (() => { + switch (actionName) { + case "routeParamsSet": + case "selectedS3ProfileUpdated": + return "replace" as const; + case "s3UrlUpdated": + return "push" as const; + } + })(), + routeParams + }); + }); + + return evt; +}) satisfies CreateEvt; diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/index.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/index.ts new file mode 100644 index 000000000..8cede8377 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/index.ts @@ -0,0 +1,4 @@ +export * from "./thunks"; +export * from "./selectors"; +export * from "./state"; +export * from "./evt"; diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts new file mode 100644 index 000000000..1d84aa910 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/selectors.ts @@ -0,0 +1,120 @@ +import type { State as RootState } from "core/bootstrap"; +import { name } from "./state"; +import { isObjectThatThrowIfAccessed, createSelector } from "clean-architecture"; +import { assert } from "tsafe"; +import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; +import type { LocalizedString } from "core/ports/OnyxiaApi"; +import { type S3UriPrefixObj, parseS3UriPrefix } from "core/tools/S3Uri"; +import { same } from "evt/tools/inDepth/same"; + +const state = (rootState: RootState) => rootState[name]; + +export const protectedSelectors = { + isStateInitialized: createSelector( + state, + state => !isObjectThatThrowIfAccessed(state) + ), + routeParams: createSelector(state, state => state.routeParams) +}; + +export type View = { + selectedS3ProfileId: string | undefined; + selectedS3Profile_creationTime: number | undefined; + availableS3Profiles: { + id: string; + displayName: string; + }[]; + bookmarks: { + displayName: LocalizedString | undefined; + s3UriPrefixObj: S3UriPrefixObj; + }[]; + s3UriPrefixObj: S3UriPrefixObj | undefined; + bookmarkStatus: + | { + isBookmarked: false; + } + | { + isBookmarked: true; + isReadonly: boolean; + }; +}; + +const view = createSelector( + protectedSelectors.isStateInitialized, + protectedSelectors.routeParams, + s3ProfilesManagement.selectors.s3Profiles, + (isStateInitialized, routeParams, s3Profiles): View => { + assert(isStateInitialized); + + if (routeParams.profile === undefined) { + return { + selectedS3ProfileId: undefined, + selectedS3Profile_creationTime: undefined, + availableS3Profiles: [], + bookmarks: [], + s3UriPrefixObj: undefined, + bookmarkStatus: { + isBookmarked: false + } + }; + } + + const selectedS3ProfileId = routeParams.profile; + + const s3Profile = s3Profiles.find( + s3Profile => s3Profile.id === selectedS3ProfileId + ); + + // TODO: Handle this case gratefully + assert( + s3Profile !== undefined, + "The profile in the root url does not exist in configuration" + ); + + const s3UriPrefixObj = + routeParams.path === "" + ? undefined + : parseS3UriPrefix({ + s3UriPrefix: `s3://${routeParams.path}`, + strict: false + }); + + return { + selectedS3ProfileId, + selectedS3Profile_creationTime: + s3Profile.origin !== "created by user (or group project member)" + ? undefined + : s3Profile.creationTime, + availableS3Profiles: s3Profiles.map(s3Profile => ({ + id: s3Profile.id, + displayName: s3Profile.paramsOfCreateS3Client.url + })), + bookmarks: s3Profile.bookmarks, + s3UriPrefixObj, + bookmarkStatus: (() => { + if (s3UriPrefixObj === undefined) { + return { + isBookmarked: false + }; + } + + const bookmark = s3Profile.bookmarks.find(bookmark => + same(bookmark.s3UriPrefixObj, s3UriPrefixObj) + ); + + if (bookmark === undefined) { + return { + isBookmarked: false + }; + } + + return { + isBookmarked: true, + isReadonly: bookmark.isReadonly + }; + })() + }; + } +); + +export const selectors = { view }; diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts new file mode 100644 index 000000000..4fa0b41a6 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/state.ts @@ -0,0 +1,55 @@ +import { createUsecaseActions } from "clean-architecture"; +import { createObjectThatThrowsIfAccessed } from "clean-architecture"; +import { type S3UriPrefixObj, stringifyS3UriPrefixObj } from "core/tools/S3Uri"; + +export const name = "s3ExplorerRootUiController"; + +export type RouteParams = { + profile?: string; + path: string; +}; + +export type State = { + routeParams: RouteParams; +}; + +export const { actions, reducer } = createUsecaseActions({ + name, + initialState: createObjectThatThrowsIfAccessed(), + reducers: { + routeParamsSet: ( + _state, + { + payload + }: { + payload: { + routeParams: RouteParams; + }; + } + ) => { + const { routeParams } = payload; + + return { routeParams }; + }, + s3UrlUpdated: ( + state, + { payload }: { payload: { s3UriPrefixObj: S3UriPrefixObj | undefined } } + ) => { + const { s3UriPrefixObj } = payload; + + state.routeParams.path = + s3UriPrefixObj === undefined + ? "" + : stringifyS3UriPrefixObj(s3UriPrefixObj).slice("s3://".length); + }, + selectedS3ProfileUpdated: ( + state, + { payload }: { payload: { s3ProfileId: string } } + ) => { + const { s3ProfileId } = payload; + + state.routeParams.profile = s3ProfileId; + state.routeParams.path = ""; + } + } +}); diff --git a/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts new file mode 100644 index 000000000..14467b706 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ExplorerRootUiController/thunks.ts @@ -0,0 +1,127 @@ +import type { Thunks } from "core/bootstrap"; +import { actions, type RouteParams } from "./state"; +import { protectedSelectors } from "./selectors"; +import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; +import { selectors } from "./selectors"; +import { evt } from "./evt"; +import type { S3UriPrefixObj } from "core/tools/S3Uri"; +import { assert } from "tsafe/assert"; + +export const thunks = { + load: + (params: { routeParams: RouteParams }) => + (...args): { routeParams_toSet: RouteParams | undefined } => { + const [dispatch, getState] = args; + + const { routeParams } = params; + + if (routeParams.profile !== undefined) { + dispatch(actions.routeParamsSet({ routeParams })); + return { routeParams_toSet: undefined }; + } + + const isStateInitialized = protectedSelectors.isStateInitialized(getState()); + + if (isStateInitialized) { + const routeParams = protectedSelectors.routeParams(getState()); + return { routeParams_toSet: routeParams }; + } + + const s3Profiles = s3ProfilesManagement.selectors.s3Profiles(getState()); + + const s3Profile = + s3Profiles.find(s3Profile => s3Profile.origin === "defined in region") ?? + s3Profiles[0]; + + const routeParams_toSet: RouteParams = { + profile: s3Profile === undefined ? undefined : s3Profile.id, + path: "" + }; + + dispatch(actions.routeParamsSet({ routeParams: routeParams_toSet })); + + return { routeParams_toSet }; + }, + notifyRouteParamsExternallyUpdated: + (params: { routeParams: RouteParams }) => + (...args) => { + const { routeParams } = params; + const [dispatch] = args; + const { routeParams_toSet } = dispatch(thunks.load({ routeParams })); + + if (routeParams_toSet !== undefined) { + evt.post({ + actionName: "updateRoute", + method: "replace", + routeParams: routeParams_toSet + }); + } + }, + updateS3Url: + (params: { s3UriPrefixObj: S3UriPrefixObj | undefined }) => + (...args) => { + const [dispatch] = args; + + const { s3UriPrefixObj } = params; + + dispatch(actions.s3UrlUpdated({ s3UriPrefixObj })); + }, + updateSelectedS3Profile: + (params: { s3ProfileId: string }) => + async (...args) => { + const [dispatch] = args; + + const { s3ProfileId } = params; + + await dispatch( + s3ProfilesManagement.protectedThunks.changeIsDefault({ + s3ProfileId, + usecase: "explorer", + value: true + }) + ); + + dispatch( + actions.selectedS3ProfileUpdated({ + s3ProfileId + }) + ); + }, + toggleIsDirectoryPathBookmarked: (() => { + let isRunning = false; + + return () => + async (...args) => { + if (isRunning) { + return; + } + + isRunning = true; + + const [dispatch, getState] = args; + + const { selectedS3ProfileId, s3UriPrefixObj, bookmarkStatus } = + selectors.view(getState()); + + assert(selectedS3ProfileId !== undefined); + assert(s3UriPrefixObj !== undefined); + + await dispatch( + s3ProfilesManagement.protectedThunks.createDeleteOrUpdateBookmark({ + s3ProfileId: selectedS3ProfileId, + s3UriPrefixObj, + action: bookmarkStatus.isBookmarked + ? { + type: "delete" + } + : { + type: "create or update", + displayName: undefined + } + }) + ); + + isRunning = false; + }; + })() +} satisfies Thunks; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/index.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/index.ts new file mode 100644 index 000000000..3f3843384 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/index.ts @@ -0,0 +1,3 @@ +export * from "./state"; +export * from "./selectors"; +export * from "./thunks"; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts new file mode 100644 index 000000000..39a22c86a --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/selectors.ts @@ -0,0 +1,283 @@ +import type { State as RootState } from "core/bootstrap"; +import { createSelector } from "clean-architecture"; +import { name } from "./state"; +import { objectKeys } from "tsafe/objectKeys"; +import { assert } from "tsafe/assert"; +import { id } from "tsafe/id"; +import type { ProjectConfigs } from "core/usecases/projectManagement"; +import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; + +const readyState = (rootState: RootState) => { + const state = rootState[name]; + + if (state.stateDescription !== "ready") { + return null; + } + + return state; +}; + +const isReady = createSelector(readyState, state => state !== null); + +const formValues = createSelector(readyState, state => { + if (state === null) { + return null; + } + + return state.formValues; +}); + +const formValuesErrors = createSelector(formValues, formValues => { + if (formValues === null) { + return null; + } + + const out: Record< + keyof typeof formValues, + "must be an url" | "is required" | "not a valid access key id" | undefined + > = {} as any; + + for (const key of objectKeys(formValues)) { + out[key] = (() => { + required_fields: { + if ( + !( + key === "url" || + key === "friendlyName" || + (!formValues.isAnonymous && + (key === "accessKeyId" || key === "secretAccessKey")) + ) + ) { + break required_fields; + } + + const value = formValues[key]; + + if ((value ?? "").trim() !== "") { + break required_fields; + } + + return "is required"; + } + + if (key === "url") { + const value = formValues[key]; + + try { + new URL(value.startsWith("http") ? value : `https://${value}`); + } catch { + return "must be an url"; + } + } + + return undefined; + })(); + } + + return out; +}); + +const isFormSubmittable = createSelector( + isReady, + formValuesErrors, + (isReady, formValuesErrors) => { + if (!isReady) { + return null; + } + + assert(formValuesErrors !== null); + + return objectKeys(formValuesErrors).every( + key => formValuesErrors[key] === undefined + ); + } +); + +const formattedFormValuesUrl = createSelector( + isReady, + formValues, + formValuesErrors, + (isReady, formValues, formValuesErrors) => { + if (!isReady) { + return null; + } + assert(formValues !== null); + assert(formValuesErrors !== null); + + if (formValuesErrors.url !== undefined) { + return undefined; + } + + const trimmedValue = formValues.url.trim(); + + return trimmedValue.startsWith("http") ? trimmedValue : `https://${trimmedValue}`; + } +); + +const submittableFormValuesAsProjectS3Config = createSelector( + isReady, + formValues, + formattedFormValuesUrl, + isFormSubmittable, + createSelector(readyState, state => { + if (state === null) { + return null; + } + return state.s3ProfileCreationTime; + }), + ( + isReady, + formValues, + formattedFormValuesUrl, + isFormSubmittable, + s3ProfileCreationTime + ) => { + if (!isReady) { + return null; + } + assert(formValues !== null); + assert(formattedFormValuesUrl !== null); + assert(isFormSubmittable !== null); + assert(s3ProfileCreationTime !== null); + + if (!isFormSubmittable) { + return undefined; + } + + assert(formattedFormValuesUrl !== undefined); + + return id({ + creationTime: s3ProfileCreationTime, + friendlyName: formValues.friendlyName.trim(), + url: formattedFormValuesUrl, + region: formValues.region?.trim(), + pathStyleAccess: formValues.pathStyleAccess, + credentials: (() => { + if (formValues.isAnonymous) { + return undefined; + } + + assert(formValues.accessKeyId !== undefined); + assert(formValues.secretAccessKey !== undefined); + + return { + accessKeyId: formValues.accessKeyId, + secretAccessKey: formValues.secretAccessKey, + sessionToken: formValues.sessionToken + }; + })(), + // TODO: Delete once we move on + workingDirectoryPath: "mybucket/my/prefix/", + bookmarks: [] + }); + } +); + +const paramsOfCreateS3Client = createSelector( + isReady, + submittableFormValuesAsProjectS3Config, + (isReady, submittableFormValuesAsProjectS3Config) => { + if (!isReady) { + return null; + } + + assert(submittableFormValuesAsProjectS3Config !== null); + + if (submittableFormValuesAsProjectS3Config === undefined) { + return undefined; + } + + return id({ + url: submittableFormValuesAsProjectS3Config.url, + pathStyleAccess: submittableFormValuesAsProjectS3Config.pathStyleAccess, + isStsEnabled: false, + region: submittableFormValuesAsProjectS3Config.region, + credentials: submittableFormValuesAsProjectS3Config.credentials + }); + } +); + +const urlStylesExamples = createSelector( + isReady, + formattedFormValuesUrl, + (isReady, formattedFormValuesUrl) => { + if (!isReady) { + return null; + } + + assert(formattedFormValuesUrl !== null); + + if (formattedFormValuesUrl === undefined) { + return undefined; + } + + const urlObject = new URL(formattedFormValuesUrl); + + const bucketName = "mybucket"; + const objectNamePrefix = "my/object/name/prefix/"; + + const domain = formattedFormValuesUrl + .split(urlObject.protocol)[1] + .split("//")[1] + .replace(/\/$/, ""); + + return { + pathStyle: `${domain}/${bucketName}/${objectNamePrefix}`, + virtualHostedStyle: `${bucketName}.${domain}/${objectNamePrefix}` + }; + } +); + +const isEditionOfAnExistingConfig = createSelector(readyState, state => { + if (state === null) { + return null; + } + return state.action === "Update existing S3 profile"; +}); + +const main = createSelector( + isReady, + formValues, + formValuesErrors, + isFormSubmittable, + urlStylesExamples, + isEditionOfAnExistingConfig, + ( + isReady, + formValues, + formValuesErrors, + isFormSubmittable, + urlStylesExamples, + isEditionOfAnExistingConfig + ) => { + if (!isReady) { + return { + isReady: false as const + }; + } + + assert(formValues !== null); + assert(formValuesErrors !== null); + assert(isFormSubmittable !== null); + assert(urlStylesExamples !== null); + assert(isEditionOfAnExistingConfig !== null); + + return { + isReady: true, + formValues, + formValuesErrors, + isFormSubmittable, + urlStylesExamples, + isEditionOfAnExistingConfig + }; + } +); + +export const privateSelectors = { + formattedFormValuesUrl, + submittableFormValuesAsProjectS3Config, + formValuesErrors, + paramsOfCreateS3Client +}; + +export const selectors = { main }; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/state.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/state.ts new file mode 100644 index 000000000..969ea6d16 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/state.ts @@ -0,0 +1,94 @@ +import { createUsecaseActions } from "clean-architecture"; +import { id } from "tsafe/id"; +import { assert } from "tsafe/assert"; + +export type State = State.NotInitialized | State.Ready; + +export namespace State { + export type NotInitialized = { + stateDescription: "not initialized"; + }; + + export type Ready = { + stateDescription: "ready"; + formValues: Ready.FormValues; + s3ProfileCreationTime: number; + action: "Update existing S3 profile" | "Create new S3 profile"; + }; + + export namespace Ready { + export type FormValues = { + friendlyName: string; + url: string; + region: string | undefined; + pathStyleAccess: boolean; + isAnonymous: boolean; + accessKeyId: string | undefined; + secretAccessKey: string | undefined; + sessionToken: string | undefined; + }; + } +} + +export type ChangeValueParams< + K extends keyof State.Ready.FormValues = keyof State.Ready.FormValues +> = { + key: K; + value: State.Ready.FormValues[K]; +}; + +export const name = "s3ProfilesCreationUiController"; + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: id( + id({ + stateDescription: "not initialized" + }) + ), + reducers: { + initialized: ( + _state, + { + payload + }: { + payload: { + creationTimeOfS3ProfileToEdit: number | undefined; + initialFormValues: State.Ready["formValues"]; + }; + } + ) => { + const { creationTimeOfS3ProfileToEdit, initialFormValues } = payload; + + return id({ + stateDescription: "ready", + formValues: initialFormValues, + s3ProfileCreationTime: creationTimeOfS3ProfileToEdit ?? Date.now(), + action: + creationTimeOfS3ProfileToEdit === undefined + ? "Create new S3 profile" + : "Update existing S3 profile" + }); + }, + formValueChanged: ( + state, + { + payload + }: { + payload: ChangeValueParams; + } + ) => { + assert(state.stateDescription === "ready"); + + if (state.formValues[payload.key] === payload.value) { + return; + } + + Object.assign(state.formValues, { [payload.key]: payload.value }); + }, + stateResetToNotInitialized: () => + id({ + stateDescription: "not initialized" + }) + } +}); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts new file mode 100644 index 000000000..214f838c8 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesCreationUiController/thunks.ts @@ -0,0 +1,188 @@ +import type { Thunks } from "core/bootstrap"; +import { actions, type State, type ChangeValueParams } from "./state"; +import { assert } from "tsafe/assert"; +import { privateSelectors } from "./selectors"; +import * as s3ProfilesManagement from "core/usecases/_s3Next/s3ProfilesManagement"; +import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; + +export const thunks = { + initialize: + (params: { creationTimeOfS3ProfileToEdit: number | undefined }) => + async (...args) => { + const { creationTimeOfS3ProfileToEdit } = params; + + const [dispatch, getState] = args; + + const s3Profiles = s3ProfilesManagement.selectors.s3Profiles(getState()); + + update_existing_config: { + if (creationTimeOfS3ProfileToEdit === undefined) { + break update_existing_config; + } + + const s3Profile = s3Profiles + .filter( + s3Profile => + s3Profile.origin === + "created by user (or group project member)" + ) + .find( + s3Profile => + s3Profile.creationTime === creationTimeOfS3ProfileToEdit + ); + + assert(s3Profile !== undefined); + + dispatch( + actions.initialized({ + creationTimeOfS3ProfileToEdit, + initialFormValues: { + friendlyName: s3Profile.friendlyName, + url: s3Profile.paramsOfCreateS3Client.url, + region: s3Profile.paramsOfCreateS3Client.region, + pathStyleAccess: + s3Profile.paramsOfCreateS3Client.pathStyleAccess, + ...(() => { + if ( + s3Profile.paramsOfCreateS3Client.credentials === + undefined + ) { + return { + isAnonymous: true, + accessKeyId: undefined, + secretAccessKey: undefined, + sessionToken: undefined + }; + } + + return { + isAnonymous: false, + accessKeyId: + s3Profile.paramsOfCreateS3Client.credentials + .accessKeyId, + secretAccessKey: + s3Profile.paramsOfCreateS3Client.credentials + .secretAccessKey, + sessionToken: + s3Profile.paramsOfCreateS3Client.credentials + .sessionToken + }; + })() + } + }) + ); + + return; + } + + const { s3Profiles_defaultValuesOfCreationForm } = + deploymentRegionManagement.selectors.currentDeploymentRegion( + getState() + )._s3Next; + + if (s3Profiles_defaultValuesOfCreationForm === undefined) { + dispatch( + actions.initialized({ + creationTimeOfS3ProfileToEdit: undefined, + initialFormValues: { + friendlyName: "", + url: "", + region: undefined, + pathStyleAccess: false, + isAnonymous: true, + accessKeyId: undefined, + secretAccessKey: undefined, + sessionToken: undefined + } + }) + ); + return; + } + + dispatch( + actions.initialized({ + creationTimeOfS3ProfileToEdit: undefined, + initialFormValues: { + friendlyName: "", + url: s3Profiles_defaultValuesOfCreationForm.url, + region: s3Profiles_defaultValuesOfCreationForm.region, + pathStyleAccess: + s3Profiles_defaultValuesOfCreationForm.pathStyleAccess ?? + false, + isAnonymous: false, + accessKeyId: undefined, + secretAccessKey: undefined, + sessionToken: undefined + } + }) + ); + }, + reset: + () => + (...args) => { + const [dispatch] = args; + + dispatch(actions.stateResetToNotInitialized()); + }, + submit: + () => + async (...args) => { + const [dispatch, getState] = args; + + const s3Config_vault = + privateSelectors.submittableFormValuesAsProjectS3Config(getState()); + + assert(s3Config_vault !== null); + assert(s3Config_vault !== undefined); + + await dispatch( + s3ProfilesManagement.protectedThunks.createOrUpdateS3Profile({ + s3Config_vault + }) + ); + + dispatch(actions.stateResetToNotInitialized()); + }, + changeValue: + (params: ChangeValueParams) => + async (...args) => { + const { key, value } = params; + + const [dispatch, getState] = args; + dispatch(actions.formValueChanged({ key, value })); + + preset_pathStyleAccess: { + if (key !== "url") { + break preset_pathStyleAccess; + } + + const url = privateSelectors.formattedFormValuesUrl(getState()); + + assert(url !== null); + + if (url === undefined) { + break preset_pathStyleAccess; + } + + if (url.toLowerCase().includes("amazonaws.com")) { + dispatch( + actions.formValueChanged({ + key: "pathStyleAccess", + value: false + }) + ); + break preset_pathStyleAccess; + } + + if (url.toLocaleLowerCase().includes("minio")) { + dispatch( + actions.formValueChanged({ + key: "pathStyleAccess", + value: true + }) + ); + break preset_pathStyleAccess; + } + } + } +} satisfies Thunks; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts new file mode 100644 index 000000000..85fa029dc --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedBookmark.ts @@ -0,0 +1,139 @@ +import type { DeploymentRegion } from "core/ports/OnyxiaApi"; +import { id } from "tsafe/id"; +import type { LocalizedString } from "ui/i18n"; +import { z } from "zod"; +import { getValueAtPath } from "core/tools/Stringifyable"; +import { type S3UriPrefixObj, parseS3UriPrefix } from "core/tools/S3Uri"; + +export type ResolvedTemplateBookmark = { + title: LocalizedString; + description: LocalizedString | undefined; + tags: LocalizedString[]; + s3UriPrefixObj: S3UriPrefixObj; + forStsRoleSessionNames: string[]; +}; + +export async function resolveTemplatedBookmark(params: { + bookmark_region: DeploymentRegion.S3Next.S3Profile.Bookmark; + getDecodedIdToken: () => Promise>; +}): Promise { + const { bookmark_region, getDecodedIdToken } = params; + + if (bookmark_region.claimName === undefined) { + return [ + id({ + s3UriPrefixObj: parseS3UriPrefix({ + s3UriPrefix: bookmark_region.s3UriPrefix, + strict: true + }), + title: bookmark_region.title, + description: bookmark_region.description, + tags: bookmark_region.tags, + forStsRoleSessionNames: bookmark_region.forStsRoleSessionNames + }) + ]; + } + + const { claimName, excludedClaimPattern, includedClaimPattern } = bookmark_region; + + const decodedIdToken = await getDecodedIdToken(); + + const claimValue_arr: string[] = (() => { + let claimValue_untrusted: unknown = (() => { + const candidate = decodedIdToken[claimName]; + + if (candidate !== undefined) { + return candidate; + } + + const claimPath = claimName.split("."); + + if (claimPath.length === 1) { + return undefined; + } + + return getValueAtPath({ + // @ts-expect-error: We know decodedIdToken is Stringifyable + stringifyableObjectOrArray: decodedIdToken, + doDeleteFromSource: false, + doFailOnUnresolved: false, + path: claimPath + }); + })(); + + if (!claimValue_untrusted) { + return []; + } + + let claimValue: string | string[]; + + try { + claimValue = z + .union([z.string(), z.array(z.string())]) + .parse(claimValue_untrusted); + } catch (error) { + throw new Error( + [ + `decodedIdToken -> ${claimName} is supposed to be`, + `string or array of string`, + `The decoded id token is:`, + JSON.stringify(decodedIdToken, null, 2) + ].join(" "), + { cause: error } + ); + } + + return claimValue instanceof Array ? claimValue : [claimValue]; + })(); + + const includedRegex = + includedClaimPattern !== undefined ? new RegExp(includedClaimPattern) : /^(.+)$/; + const excludedRegex = + excludedClaimPattern !== undefined ? new RegExp(excludedClaimPattern) : undefined; + + return claimValue_arr + .map(value => { + if (excludedRegex !== undefined && excludedRegex.test(value)) { + return undefined; + } + + const match = includedRegex.exec(value); + + if (match === null) { + return undefined; + } + + const substituteTemplateString = (str: string) => + str.replace(/\$(\d+)/g, (_, i) => match[parseInt(i)] ?? ""); + + const substituteLocalizedString = ( + locStr: LocalizedString + ): LocalizedString => { + if (typeof locStr === "string") { + return substituteTemplateString(locStr); + } + return Object.fromEntries( + Object.entries(locStr) + .filter(([, value]) => value !== undefined) + .map(([lang, value]) => [lang, substituteTemplateString(value)]) + ); + }; + + return id({ + s3UriPrefixObj: parseS3UriPrefix({ + s3UriPrefix: substituteTemplateString(bookmark_region.s3UriPrefix), + strict: true + }), + title: substituteLocalizedString(bookmark_region.title), + description: + bookmark_region.description === undefined + ? undefined + : substituteLocalizedString(bookmark_region.description), + tags: bookmark_region.tags.map(tag => substituteLocalizedString(tag)), + forStsRoleSessionNames: bookmark_region.forStsRoleSessionNames.map( + stsRoleSessionName => substituteTemplateString(stsRoleSessionName) + ) + }); + }) + .filter(x => x !== undefined); +} diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedStsRole.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedStsRole.ts new file mode 100644 index 000000000..0e8186a07 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/resolveTemplatedStsRole.ts @@ -0,0 +1,104 @@ +import type { DeploymentRegion } from "core/ports/OnyxiaApi"; +import { id } from "tsafe/id"; +import { z } from "zod"; +import { getValueAtPath } from "core/tools/Stringifyable"; + +export type ResolvedTemplateStsRole = { + roleARN: string; + roleSessionName: string; +}; + +export async function resolveTemplatedStsRole(params: { + stsRole_region: DeploymentRegion.S3Next.S3Profile.StsRole; + getDecodedIdToken: () => Promise>; +}): Promise { + const { stsRole_region, getDecodedIdToken } = params; + + if (stsRole_region.claimName === undefined) { + return [ + id({ + roleARN: stsRole_region.roleARN, + roleSessionName: stsRole_region.roleSessionName + }) + ]; + } + + const { claimName, excludedClaimPattern, includedClaimPattern } = stsRole_region; + + const decodedIdToken = await getDecodedIdToken(); + + const claimValue_arr: string[] = (() => { + let claimValue_untrusted: unknown = (() => { + const candidate = decodedIdToken[claimName]; + + if (candidate !== undefined) { + return candidate; + } + + const claimPath = claimName.split("."); + + if (claimPath.length === 1) { + return undefined; + } + + return getValueAtPath({ + // @ts-expect-error: We know decodedIdToken is Stringifyable + stringifyableObjectOrArray: decodedIdToken, + doDeleteFromSource: false, + doFailOnUnresolved: false, + path: claimPath + }); + })(); + + if (!claimValue_untrusted) { + return []; + } + + let claimValue: string | string[]; + + try { + claimValue = z + .union([z.string(), z.array(z.string())]) + .parse(claimValue_untrusted); + } catch (error) { + throw new Error( + [ + `decodedIdToken -> ${claimName} is supposed to be`, + `string or array of string`, + `The decoded id token is:`, + JSON.stringify(decodedIdToken, null, 2) + ].join(" "), + { cause: error } + ); + } + + return claimValue instanceof Array ? claimValue : [claimValue]; + })(); + + const includedRegex = + includedClaimPattern !== undefined ? new RegExp(includedClaimPattern) : /^(.+)$/; + const excludedRegex = + excludedClaimPattern !== undefined ? new RegExp(excludedClaimPattern) : undefined; + + return claimValue_arr + .map(value => { + if (excludedRegex !== undefined && excludedRegex.test(value)) { + return undefined; + } + + const match = includedRegex.exec(value); + + if (match === null) { + return undefined; + } + + const substituteTemplateString = (str: string) => + str.replace(/\$(\d+)/g, (_, i) => match[parseInt(i)] ?? ""); + + return id({ + roleARN: substituteTemplateString(stsRole_region.roleARN), + roleSessionName: substituteTemplateString(stsRole_region.roleSessionName) + }); + }) + .filter(x => x !== undefined); +} diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts new file mode 100644 index 000000000..90417df44 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/s3Profiles.ts @@ -0,0 +1,243 @@ +import * as projectManagement from "core/usecases/projectManagement"; +import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; +import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; +import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; +import { assert, type Equals } from "tsafe"; +import type { LocalizedString } from "core/ports/OnyxiaApi"; +import type { ResolvedTemplateBookmark } from "./resolveTemplatedBookmark"; +import type { ResolvedTemplateStsRole } from "./resolveTemplatedStsRole"; +import type { S3UriPrefixObj } from "core/tools/S3Uri"; +import { parseUserConfigsS3BookmarksStr } from "./userConfigsS3Bookmarks"; + +export type S3Profile = S3Profile.DefinedInRegion | S3Profile.CreatedByUser; + +export namespace S3Profile { + type Common = { + id: string; + isXOnyxiaDefault: boolean; + isExplorerConfig: boolean; + bookmarks: Bookmark[]; + }; + + export type DefinedInRegion = Common & { + origin: "defined in region"; + paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts; + }; + + export type CreatedByUser = Common & { + origin: "created by user (or group project member)"; + creationTime: number; + paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts; + friendlyName: string; + }; + + export type Bookmark = { + isReadonly: boolean; + displayName: LocalizedString | undefined; + s3UriPrefixObj: S3UriPrefixObj; + }; +} + +export function aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet(params: { + fromVault: { + projectConfigs_s3: projectManagement.ProjectConfigs["s3"]; + userConfigs_s3BookmarksStr: string | null; + }; + fromRegion: { + s3Profiles: DeploymentRegion.S3Next.S3Profile[]; + resolvedTemplatedBookmarks: { + correspondingS3ConfigIndexInRegion: number; + bookmarks: ResolvedTemplateBookmark[]; + }[]; + resolvedTemplatedStsRoles: { + correspondingS3ConfigIndexInRegion: number; + stsRoles: ResolvedTemplateStsRole[]; + }[]; + }; +}): S3Profile[] { + const { fromVault, fromRegion } = params; + + const s3Profiles: S3Profile[] = [ + ...fromVault.projectConfigs_s3.s3Configs + .map((c): S3Profile.CreatedByUser => { + const url = c.url; + const pathStyleAccess = c.pathStyleAccess; + const region = c.region; + + const paramsOfCreateS3Client: ParamsOfCreateS3Client.NoSts = { + url, + pathStyleAccess, + isStsEnabled: false, + region, + credentials: c.credentials + }; + + return { + origin: "created by user (or group project member)", + creationTime: c.creationTime, + friendlyName: c.friendlyName, + id: `${c.creationTime}`, + paramsOfCreateS3Client, + isXOnyxiaDefault: false, + isExplorerConfig: false, + // TODO: Actually store custom bookmarks + bookmarks: (c.bookmarks ?? []).map( + ({ displayName, s3UriPrefixObj }) => ({ + displayName, + s3UriPrefixObj, + isReadonly: false + }) + ) + }; + }) + .sort((a, b) => b.creationTime - a.creationTime), + ...fromRegion.s3Profiles + .map((c, index): S3Profile.DefinedInRegion[] => { + const resolvedTemplatedBookmarks_forThisProfile = (() => { + const entry = fromRegion.resolvedTemplatedBookmarks.find( + e => e.correspondingS3ConfigIndexInRegion === index + ); + + assert(entry !== undefined); + + return entry.bookmarks; + })(); + + const buildFromRole = (params: { + resolvedTemplatedStsRole: ResolvedTemplateStsRole | undefined; + }): S3Profile.DefinedInRegion => { + const { resolvedTemplatedStsRole } = params; + + const paramsOfCreateS3Client: ParamsOfCreateS3Client.Sts = { + url: c.url, + pathStyleAccess: c.pathStyleAccess, + isStsEnabled: true, + stsUrl: c.sts.url, + region: c.region, + oidcParams: c.sts.oidcParams, + durationSeconds: c.sts.durationSeconds, + role: resolvedTemplatedStsRole + }; + + const id = `region-${fnv1aHashToHex( + JSON.stringify([c.url, c.sts.oidcParams.clientId ?? ""]) + )}`; + + return { + origin: "defined in region", + id, + bookmarks: [ + ...resolvedTemplatedBookmarks_forThisProfile + .filter(({ forStsRoleSessionNames }) => { + if (forStsRoleSessionNames.length === 0) { + return true; + } + + if (resolvedTemplatedStsRole === undefined) { + return false; + } + + const getDoMatch = (params: { + stringWithWildcards: string; + candidate: string; + }): boolean => { + const { stringWithWildcards, candidate } = params; + + if (!stringWithWildcards.includes("*")) { + return stringWithWildcards === candidate; + } + + const escapedRegex = stringWithWildcards + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + .replace(/\\\*/g, ".*"); + + return new RegExp(`^${escapedRegex}$`).test( + candidate + ); + }; + + return forStsRoleSessionNames.some( + stsRoleSessionName => + getDoMatch({ + stringWithWildcards: stsRoleSessionName, + candidate: + resolvedTemplatedStsRole.roleSessionName + }) + ); + }) + .map(({ title, s3UriPrefixObj }) => ({ + isReadonly: true, + displayName: title, + s3UriPrefixObj + })), + ...parseUserConfigsS3BookmarksStr({ + userConfigs_s3BookmarksStr: + fromVault.userConfigs_s3BookmarksStr + }) + .filter(entry => entry.s3ProfileId === id) + .map(entry => ({ + isReadonly: false, + displayName: entry.displayName, + s3UriPrefixObj: entry.s3UriPrefixObj + })) + ], + paramsOfCreateS3Client, + isXOnyxiaDefault: false, + isExplorerConfig: false + }; + }; + + const resolvedTemplatedStsRoles_forThisProfile = (() => { + const entry = fromRegion.resolvedTemplatedStsRoles.find( + e => e.correspondingS3ConfigIndexInRegion === index + ); + + assert(entry !== undefined); + + return entry.stsRoles; + })(); + + if (resolvedTemplatedStsRoles_forThisProfile.length === 0) { + return [buildFromRole({ resolvedTemplatedStsRole: undefined })]; + } + + return resolvedTemplatedStsRoles_forThisProfile.map( + resolvedTemplatedStsRole => + buildFromRole({ resolvedTemplatedStsRole }) + ); + }) + .flat() + ]; + + ( + [ + ["defaultXOnyxia", fromVault.projectConfigs_s3.s3ConfigId_defaultXOnyxia], + ["explorer", fromVault.projectConfigs_s3.s3ConfigId_explorer] + ] as const + ).forEach(([prop, s3ProfileId]) => { + if (s3ProfileId === undefined) { + return; + } + + const s3Profile = + s3Profiles.find(({ id }) => id === s3ProfileId) ?? + s3Profiles.find(s3Config => s3Config.origin === "defined in region"); + + if (s3Profile === undefined) { + return; + } + + switch (prop) { + case "defaultXOnyxia": + s3Profile.isXOnyxiaDefault = true; + return; + case "explorer": + s3Profile.isExplorerConfig = true; + return; + default: + assert>(false); + } + }); + + return s3Profiles; +} diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts new file mode 100644 index 000000000..da93c41c7 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion.ts @@ -0,0 +1,70 @@ +import * as projectManagement from "core/usecases/projectManagement"; +import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; +import { aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet } from "./s3Profiles"; + +type R = Record< + "s3ConfigId_defaultXOnyxia" | "s3ConfigId_explorer", + | { + isUpdateNeeded: false; + } + | { + isUpdateNeeded: true; + s3ProfileId: string | undefined; + } +>; + +export function updateDefaultS3ProfilesAfterPotentialDeletion(params: { + fromRegion: { s3Profiles: DeploymentRegion.S3Next.S3Profile[] }; + fromVault: { + projectConfigs_s3: projectManagement.ProjectConfigs["s3"]; + }; +}): R { + const { fromRegion, fromVault } = params; + + const s3Profiles = aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ + fromRegion: { + s3Profiles: fromRegion.s3Profiles, + resolvedTemplatedBookmarks: [], + resolvedTemplatedStsRoles: [] + }, + fromVault: { + projectConfigs_s3: fromVault.projectConfigs_s3, + userConfigs_s3BookmarksStr: null + } + }); + + const actions: R = { + s3ConfigId_defaultXOnyxia: { + isUpdateNeeded: false + }, + s3ConfigId_explorer: { + isUpdateNeeded: false + } + }; + + for (const propertyName of [ + "s3ConfigId_defaultXOnyxia", + "s3ConfigId_explorer" + ] as const) { + const s3ConfigId_default = fromVault.projectConfigs_s3[propertyName]; + + if (s3ConfigId_default === undefined) { + continue; + } + + if (s3Profiles.find(({ id }) => id === s3ConfigId_default) !== undefined) { + continue; + } + + const s3ConfigId_toUseAsDefault = s3Profiles.find( + ({ origin }) => origin === "defined in region" + )?.id; + + actions[propertyName] = { + isUpdateNeeded: true, + s3ProfileId: s3ConfigId_toUseAsDefault + }; + } + + return actions; +} diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts new file mode 100644 index 000000000..392b66b34 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/decoupledLogic/userConfigsS3Bookmarks.ts @@ -0,0 +1,27 @@ +import type { S3UriPrefixObj } from "core/tools/S3Uri"; + +export type UserProfileS3Bookmark = { + s3ProfileId: string; + displayName: string | undefined; + s3UriPrefixObj: S3UriPrefixObj; +}; + +export function parseUserConfigsS3BookmarksStr(params: { + userConfigs_s3BookmarksStr: string | null; +}): UserProfileS3Bookmark[] { + const { userConfigs_s3BookmarksStr } = params; + + if (userConfigs_s3BookmarksStr === null) { + return []; + } + + return JSON.parse(userConfigs_s3BookmarksStr); +} + +export function serializeUserConfigsS3Bookmarks(params: { + userConfigs_s3Bookmarks: UserProfileS3Bookmark[]; +}) { + const { userConfigs_s3Bookmarks } = params; + + return JSON.stringify(userConfigs_s3Bookmarks); +} diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/index.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/index.ts new file mode 100644 index 000000000..84fe07fe2 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/index.ts @@ -0,0 +1,4 @@ +export * from "./state"; +export * from "./selectors"; +export * from "./thunks"; +export type { S3Profile } from "./decoupledLogic/s3Profiles"; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts new file mode 100644 index 000000000..eef3feb18 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/selectors.ts @@ -0,0 +1,63 @@ +import { createSelector } from "clean-architecture"; +import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; +import * as projectManagement from "core/usecases/projectManagement"; +import * as userConfigs from "core/usecases/userConfigs"; +import { + type S3Profile, + aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet +} from "./decoupledLogic/s3Profiles"; +import { name } from "./state"; +import type { State as RootState } from "core/bootstrap"; + +const resolvedTemplatedBookmarks = createSelector( + (state: RootState) => state[name], + state => state.resolvedTemplatedBookmarks +); + +const resolvedTemplatedStsRoles = createSelector( + (state: RootState) => state[name], + state => state.resolvedTemplatedStsRoles +); + +const userConfigs_s3BookmarksStr = createSelector( + userConfigs.selectors.userConfigs, + userConfigs => userConfigs.s3BookmarksStr +); + +const s3Profiles = createSelector( + createSelector( + projectManagement.protectedSelectors.projectConfig, + projectConfig => projectConfig.s3 + ), + createSelector( + deploymentRegionManagement.selectors.currentDeploymentRegion, + deploymentRegion => deploymentRegion._s3Next.s3Profiles + ), + resolvedTemplatedBookmarks, + resolvedTemplatedStsRoles, + userConfigs_s3BookmarksStr, + ( + projectConfigs_s3, + s3Profiles_region, + resolvedTemplatedBookmarks, + resolvedTemplatedStsRoles, + userConfigs_s3BookmarksStr + ): S3Profile[] => + aggregateS3ProfilesFromVaultAndRegionIntoAnUnifiedSet({ + fromVault: { + projectConfigs_s3, + userConfigs_s3BookmarksStr + }, + fromRegion: { + s3Profiles: s3Profiles_region, + resolvedTemplatedBookmarks, + resolvedTemplatedStsRoles + } + }) +); + +export const selectors = { s3Profiles }; + +export const protectedSelectors = { + resolvedTemplatedBookmarks +}; diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts new file mode 100644 index 000000000..ad4f77ebd --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/state.ts @@ -0,0 +1,46 @@ +import { + createUsecaseActions, + createObjectThatThrowsIfAccessed +} from "clean-architecture"; +import type { ResolvedTemplateBookmark } from "./decoupledLogic/resolveTemplatedBookmark"; +import type { ResolvedTemplateStsRole } from "./decoupledLogic/resolveTemplatedStsRole"; + +type State = { + resolvedTemplatedBookmarks: { + correspondingS3ConfigIndexInRegion: number; + bookmarks: ResolvedTemplateBookmark[]; + }[]; + resolvedTemplatedStsRoles: { + correspondingS3ConfigIndexInRegion: number; + stsRoles: ResolvedTemplateStsRole[]; + }[]; +}; + +export const name = "s3ProfilesManagement"; + +export const { reducer, actions } = createUsecaseActions({ + name, + initialState: createObjectThatThrowsIfAccessed(), + reducers: { + initialized: ( + _, + { + payload + }: { + payload: { + resolvedTemplatedBookmarks: State["resolvedTemplatedBookmarks"]; + resolvedTemplatedStsRoles: State["resolvedTemplatedStsRoles"]; + }; + } + ) => { + const { resolvedTemplatedBookmarks, resolvedTemplatedStsRoles } = payload; + + const state: State = { + resolvedTemplatedBookmarks, + resolvedTemplatedStsRoles + }; + + return state; + } + } +}); diff --git a/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts new file mode 100644 index 000000000..8d9bdbb82 --- /dev/null +++ b/web/src/core/usecases/_s3Next/s3ProfilesManagement/thunks.ts @@ -0,0 +1,522 @@ +import type { Thunks } from "core/bootstrap"; +import { selectors, protectedSelectors } from "./selectors"; +import * as projectManagement from "core/usecases/projectManagement"; +import { assert } from "tsafe/assert"; +import type { S3Client } from "core/ports/S3Client"; +import { createUsecaseContextApi } from "clean-architecture"; +import { updateDefaultS3ProfilesAfterPotentialDeletion } from "./decoupledLogic/updateDefaultS3ProfilesAfterPotentialDeletion"; +import structuredClone from "@ungap/structured-clone"; +import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; +import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; +import { resolveTemplatedBookmark } from "./decoupledLogic/resolveTemplatedBookmark"; +import { resolveTemplatedStsRole } from "./decoupledLogic/resolveTemplatedStsRole"; +import { actions } from "./state"; +import type { S3Profile } from "./decoupledLogic/s3Profiles"; +import type { OidcParams_Partial } from "core/ports/OnyxiaApi/OidcParams"; +import type { S3UriPrefixObj } from "core/tools/S3Uri"; +import { same } from "evt/tools/inDepth/same"; +import { + parseUserConfigsS3BookmarksStr, + serializeUserConfigsS3Bookmarks +} from "./decoupledLogic/userConfigsS3Bookmarks"; +import * as userConfigs from "core/usecases/userConfigs"; + +export const thunks = { + deleteS3Config: + (params: { s3ProfileCreationTime: number }) => + async (...args) => { + const { s3ProfileCreationTime } = params; + + const [dispatch, getState] = args; + + const projectConfigs_s3 = structuredClone( + projectManagement.protectedSelectors.projectConfig(getState()).s3 + ); + + const i = projectConfigs_s3.s3Configs.findIndex( + ({ creationTime }) => creationTime === s3ProfileCreationTime + ); + + assert(i !== -1); + + projectConfigs_s3.s3Configs.splice(i, 1); + + { + const actions = updateDefaultS3ProfilesAfterPotentialDeletion({ + fromRegion: { + s3Profiles: + deploymentRegionManagement.selectors.currentDeploymentRegion( + getState() + )._s3Next.s3Profiles + }, + fromVault: { + projectConfigs_s3 + } + }); + + await Promise.all( + (["s3ConfigId_defaultXOnyxia", "s3ConfigId_explorer"] as const).map( + async propertyName => { + const action = actions[propertyName]; + + if (!action.isUpdateNeeded) { + return; + } + + projectConfigs_s3[propertyName] = action.s3ProfileId; + } + ) + ); + } + + await dispatch( + projectManagement.protectedThunks.updateConfigValue({ + key: "s3", + value: projectConfigs_s3 + }) + ); + } +} satisfies Thunks; + +export const protectedThunks = { + getS3ClientForSpecificConfig: + (params: { s3ProfileId: string | undefined }) => + async (...args): Promise => { + const { s3ProfileId } = params; + const [, getState, rootContext] = args; + + const { prS3ClientByConfigId: prS3ClientByProfileId } = + getContext(rootContext); + + const s3Profile = (() => { + const s3Profiles = selectors.s3Profiles(getState()); + + const s3Config = s3Profiles.find( + s3Profile => s3Profile.id === s3ProfileId + ); + assert(s3Config !== undefined); + + return s3Config; + })(); + + use_cached_s3Client: { + const prS3Client = prS3ClientByProfileId.get(s3Profile.id); + + if (prS3Client === undefined) { + break use_cached_s3Client; + } + + return prS3Client; + } + + const prS3Client = (async () => { + const { createS3Client } = await import("core/adapters/s3Client"); + const { createOidc, mergeOidcParams } = await import( + "core/adapters/oidc" + ); + const { paramsOfBootstrapCore, onyxiaApi } = rootContext; + + return createS3Client( + s3Profile.paramsOfCreateS3Client, + async oidcParams_partial => { + const { oidcParams } = + await onyxiaApi.getAvailableRegionsAndOidcParams(); + + assert(oidcParams !== undefined); + + const oidc_s3 = await createOidc({ + ...mergeOidcParams({ + oidcParams, + oidcParams_partial + }), + autoLogin: true, + transformBeforeRedirectForKeycloakTheme: + paramsOfBootstrapCore.transformBeforeRedirectForKeycloakTheme, + getCurrentLang: paramsOfBootstrapCore.getCurrentLang, + enableDebugLogs: paramsOfBootstrapCore.enableOidcDebugLogs + }); + + const doClearCachedS3Token_groupClaimValue: boolean = + await (async () => { + const { projects } = await onyxiaApi.getUserAndProjects(); + + const KEY = "onyxia:s3:projects-hash"; + + const hash = fnv1aHashToHex(JSON.stringify(projects)); + + if ( + !oidc_s3.isNewBrowserSession && + sessionStorage.getItem(KEY) === hash + ) { + return false; + } + + sessionStorage.setItem(KEY, hash); + return true; + })(); + + const doClearCachedS3Token_s3BookmarkClaimValue: boolean = + (() => { + const resolvedTemplatedBookmarks = + protectedSelectors.resolvedTemplatedBookmarks( + getState() + ); + + const KEY = "onyxia:s3:resolvedAdminBookmarks-hash"; + + const hash = fnv1aHashToHex( + JSON.stringify(resolvedTemplatedBookmarks) + ); + + if ( + !oidc_s3.isNewBrowserSession && + sessionStorage.getItem(KEY) === hash + ) { + return false; + } + + sessionStorage.setItem(KEY, hash); + return true; + })(); + + return { + oidc: oidc_s3, + doClearCachedS3Token: + doClearCachedS3Token_groupClaimValue || + doClearCachedS3Token_s3BookmarkClaimValue + }; + } + ); + })(); + + prS3ClientByProfileId.set(s3Profile.id, prS3Client); + + return prS3Client; + }, + getS3ConfigAndClientForExplorer: + () => + async ( + ...args + ): Promise => { + const [dispatch, getState] = args; + + const s3Profile = selectors + .s3Profiles(getState()) + .find(s3Profile => s3Profile.isExplorerConfig); + + if (s3Profile === undefined) { + return undefined; + } + + const s3Client = await dispatch( + protectedThunks.getS3ClientForSpecificConfig({ + s3ProfileId: s3Profile.id + }) + ); + + return { s3Client, s3Profile }; + }, + createOrUpdateS3Profile: + (params: { s3Config_vault: projectManagement.ProjectConfigs.S3Config }) => + async (...args) => { + const { s3Config_vault: s3Config_vault } = params; + + const [dispatch, getState] = args; + + const fromVault = structuredClone( + projectManagement.protectedSelectors.projectConfig(getState()).s3 + ); + + const i = fromVault.s3Configs.findIndex( + projectS3Config_i => + projectS3Config_i.creationTime === s3Config_vault.creationTime + ); + + if (i < 0) { + fromVault.s3Configs.push(s3Config_vault); + } else { + fromVault.s3Configs[i] = s3Config_vault; + } + + await dispatch( + projectManagement.protectedThunks.updateConfigValue({ + key: "s3", + value: fromVault + }) + ); + }, + createDeleteOrUpdateBookmark: + (params: { + s3ProfileId: string; + s3UriPrefixObj: S3UriPrefixObj; + action: + | { + type: "create or update"; + displayName: string | undefined; + } + | { + type: "delete"; + }; + }) => + async (...args) => { + const { s3ProfileId, s3UriPrefixObj, action } = params; + + const [dispatch, getState] = args; + + const s3Profiles = selectors.s3Profiles(getState()); + + const s3Profile = s3Profiles.find(s3Profile => s3Profile.id === s3ProfileId); + + assert(s3Profile !== undefined); + + switch (s3Profile.origin) { + case "created by user (or group project member)": + { + const projectConfigs_s3 = structuredClone( + projectManagement.protectedSelectors.projectConfig(getState()) + .s3 + ); + + const s3Config_vault = projectConfigs_s3.s3Configs.find( + s3Config => s3Config.creationTime === s3Profile.creationTime + ); + + assert(s3Config_vault !== undefined); + + s3Config_vault.bookmarks ??= []; + + const index = s3Config_vault.bookmarks.findIndex(bookmark => + same(bookmark.s3UriPrefixObj, s3UriPrefixObj) + ); + + switch (action.type) { + case "create or update": + { + const bookmark_new = { + displayName: action.displayName, + s3UriPrefixObj + }; + + if (index === -1) { + s3Config_vault.bookmarks.push(bookmark_new); + } else { + s3Config_vault.bookmarks[index] = bookmark_new; + } + } + break; + case "delete": + { + assert(index !== -1); + + s3Config_vault.bookmarks.splice(index, 1); + } + break; + } + + await dispatch( + projectManagement.protectedThunks.updateConfigValue({ + key: "s3", + value: projectConfigs_s3 + }) + ); + } + break; + case "defined in region": + { + const { s3BookmarksStr } = + userConfigs.selectors.userConfigs(getState()); + + const userConfigs_s3Bookmarks = parseUserConfigsS3BookmarksStr({ + userConfigs_s3BookmarksStr: s3BookmarksStr + }); + + const index = userConfigs_s3Bookmarks.findIndex( + entry => + entry.s3ProfileId === s3Profile.id && + same(entry.s3UriPrefixObj, s3UriPrefixObj) + ); + + switch (action.type) { + case "create or update": + { + const bookmark_new = { + s3ProfileId: s3Profile.id, + displayName: action.displayName, + s3UriPrefixObj + }; + + if (index === -1) { + userConfigs_s3Bookmarks.push(bookmark_new); + } else { + userConfigs_s3Bookmarks[index] = bookmark_new; + } + } + break; + case "delete": + { + assert(index !== -1); + + userConfigs_s3Bookmarks.splice(index, 1); + } + break; + } + + await dispatch( + userConfigs.thunks.changeValue({ + key: "s3BookmarksStr", + value: serializeUserConfigsS3Bookmarks({ + userConfigs_s3Bookmarks + }) + }) + ); + } + break; + } + }, + changeIsDefault: + (params: { + s3ProfileId: string; + usecase: "defaultXOnyxia" | "explorer"; + value: boolean; + }) => + async (...args) => { + const { s3ProfileId, usecase, value } = params; + + const [dispatch, getState] = args; + + const fromVault = structuredClone( + projectManagement.protectedSelectors.projectConfig(getState()).s3 + ); + + const propertyName = (() => { + switch (usecase) { + case "defaultXOnyxia": + return "s3ConfigId_defaultXOnyxia"; + case "explorer": + return "s3ConfigId_explorer"; + } + })(); + + { + const s3ProfileId_currentDefault = fromVault[propertyName]; + + if (value) { + if (s3ProfileId_currentDefault === s3ProfileId) { + return; + } + } else { + if (s3ProfileId_currentDefault !== s3ProfileId) { + return; + } + } + } + + fromVault[propertyName] = value ? s3ProfileId : undefined; + + await dispatch( + projectManagement.protectedThunks.updateConfigValue({ + key: "s3", + value: fromVault + }) + ); + }, + initialize: + () => + async (...args) => { + const [dispatch, getState, { onyxiaApi, paramsOfBootstrapCore }] = args; + + const deploymentRegion = + deploymentRegionManagement.selectors.currentDeploymentRegion(getState()); + + const getDecodedIdToken = async (params: { + oidcParams_partial: OidcParams_Partial; + }) => { + const { oidcParams_partial } = params; + + const { createOidc, mergeOidcParams } = await import( + "core/adapters/oidc" + ); + + const { oidcParams } = await onyxiaApi.getAvailableRegionsAndOidcParams(); + + assert(oidcParams !== undefined); + + const oidc = await createOidc({ + ...mergeOidcParams({ + oidcParams, + oidcParams_partial + }), + autoLogin: true, + transformBeforeRedirectForKeycloakTheme: + paramsOfBootstrapCore.transformBeforeRedirectForKeycloakTheme, + getCurrentLang: paramsOfBootstrapCore.getCurrentLang, + enableDebugLogs: paramsOfBootstrapCore.enableOidcDebugLogs + }); + + const { decodedIdToken } = await oidc.getTokens(); + + return decodedIdToken; + }; + + const resolvedTemplatedBookmarks = await Promise.all( + deploymentRegion._s3Next.s3Profiles.map( + async (s3Config, s3ConfigIndex) => { + const { bookmarks: bookmarks_region, sts } = s3Config; + + return { + correspondingS3ConfigIndexInRegion: s3ConfigIndex, + bookmarks: ( + await Promise.all( + bookmarks_region.map(bookmark => + resolveTemplatedBookmark({ + bookmark_region: bookmark, + getDecodedIdToken: () => + getDecodedIdToken({ + oidcParams_partial: sts.oidcParams + }) + }) + ) + ) + ).flat() + }; + } + ) + ); + + const resolvedTemplatedStsRoles = await Promise.all( + deploymentRegion._s3Next.s3Profiles.map( + async (s3Config, s3ConfigIndex) => { + const { sts } = s3Config; + + return { + correspondingS3ConfigIndexInRegion: s3ConfigIndex, + stsRoles: ( + await Promise.all( + sts.roles.map(stsRole_region => + resolveTemplatedStsRole({ + stsRole_region, + getDecodedIdToken: () => + getDecodedIdToken({ + oidcParams_partial: sts.oidcParams + }) + }) + ) + ) + ).flat() + }; + } + ) + ); + + dispatch( + actions.initialized({ + resolvedTemplatedBookmarks, + resolvedTemplatedStsRoles + }) + ); + } +} satisfies Thunks; + +const { getContext } = createUsecaseContextApi(() => ({ + prS3ClientByConfigId: new Map>() +})); diff --git a/web/src/core/usecases/fileExplorer/evt.ts b/web/src/core/usecases/fileExplorer/evt.ts new file mode 100644 index 000000000..cbc8c0b24 --- /dev/null +++ b/web/src/core/usecases/fileExplorer/evt.ts @@ -0,0 +1,38 @@ +import type { CreateEvt } from "core/bootstrap"; +import { Evt } from "evt"; +import { name } from "./state"; +import { protectedThunks } from "./thunks"; + +export const createEvt = (({ evtAction, dispatch }) => { + const evtOut = Evt.create<{ + action: "ask confirmation for bucket creation attempt"; + bucket: string; + createBucket: () => Promise<{ isSuccess: boolean }>; + }>(); + + const evtUsecaseAction = evtAction.pipe(action => action.usecaseName === name); + + evtUsecaseAction.$attach( + action => + action.actionName === "navigationCompleted" && + !action.payload.isSuccess && + action.payload.navigationError.errorCase === "no such bucket" && + action.payload.navigationError.shouldAttemptToCreate + ? [action.payload.navigationError] + : null, + ({ bucket, directoryPath }) => + evtOut.post({ + action: "ask confirmation for bucket creation attempt", + bucket, + createBucket: () => + dispatch( + protectedThunks.createBucket({ + bucket, + directoryPath_toNavigateToOnSuccess: directoryPath + }) + ) + }) + ); + + return evtOut; +}) satisfies CreateEvt; diff --git a/web/src/core/usecases/fileExplorer/index.ts b/web/src/core/usecases/fileExplorer/index.ts index 6e655c5cd..dd6008150 100644 --- a/web/src/core/usecases/fileExplorer/index.ts +++ b/web/src/core/usecases/fileExplorer/index.ts @@ -1,3 +1,4 @@ export * from "./state"; export * from "./thunks"; export * from "./selectors"; +export * from "./evt"; diff --git a/web/src/core/usecases/fileExplorer/selectors.ts b/web/src/core/usecases/fileExplorer/selectors.ts index b5d5e160a..704f99a92 100644 --- a/web/src/core/usecases/fileExplorer/selectors.ts +++ b/web/src/core/usecases/fileExplorer/selectors.ts @@ -10,6 +10,7 @@ import { id } from "tsafe/id"; import type { S3Object } from "core/ports/S3Client"; import { join as pathJoin, relative as pathRelative } from "pathe"; import { getUploadProgress } from "./decoupledLogic/uploadProgress"; +import { parseS3UriPrefix } from "core/tools/S3Uri"; const state = (rootState: RootState): State => rootState[name]; @@ -70,18 +71,20 @@ export namespace CurrentWorkingDirectoryView { const currentWorkingDirectoryView = createSelector( createSelector(state, state => state.directoryPath), + createSelector(state, state => state.navigationError), createSelector(state, state => state.objects), createSelector(state, state => state.ongoingOperations), createSelector(state, state => state.s3FilesBeingUploaded), createSelector(state, state => state.isBucketPolicyAvailable), ( directoryPath, + navigationError, objects, ongoingOperations, s3FilesBeingUploaded, isBucketPolicyAvailable ): CurrentWorkingDirectoryView | null => { - if (directoryPath === undefined) { + if (directoryPath === undefined || navigationError !== undefined) { return null; } const items = [ @@ -284,7 +287,10 @@ const shareView = createSelector( } ); -const isNavigationOngoing = createSelector(state, state => state.isNavigationOngoing); +const isNavigationOngoing = createSelector( + state, + state => state.ongoingNavigation !== undefined +); const workingDirectoryPath = createSelector( s3ConfigManagement.selectors.s3Configs, @@ -303,7 +309,7 @@ const pathMinDepth = createSelector(workingDirectoryPath, workingDirectoryPath = }); const main = createSelector( - createSelector(state, state => state.directoryPath), + createSelector(state, state => state.navigationError), uploadProgress, commandLogsEntries, currentWorkingDirectoryView, @@ -313,7 +319,7 @@ const main = createSelector( shareView, isDownloadPreparing, ( - directoryPath, + navigationError, uploadProgress, commandLogsEntries, currentWorkingDirectoryView, @@ -323,9 +329,29 @@ const main = createSelector( shareView, isDownloadPreparing ) => { - if (directoryPath === undefined) { + if (currentWorkingDirectoryView === null) { return { isCurrentWorkingDirectoryLoaded: false as const, + navigationError: (() => { + if (navigationError === undefined) { + return undefined; + } + switch (navigationError.errorCase) { + case "access denied": + return { + errorCase: navigationError.errorCase, + directoryPath: navigationError.directoryPath + } as const; + case "no such bucket": + return { + errorCase: navigationError.errorCase, + bucket: parseS3UriPrefix({ + s3UriPrefix: `s3://${navigationError.directoryPath}`, + strict: false + }).bucket + } as const; + } + })(), isNavigationOngoing, uploadProgress, commandLogsEntries, @@ -335,7 +361,6 @@ const main = createSelector( }; } - assert(currentWorkingDirectoryView !== null); assert(shareView !== null); return { diff --git a/web/src/core/usecases/fileExplorer/state.ts b/web/src/core/usecases/fileExplorer/state.ts index 255b4e1a7..eec8148b7 100644 --- a/web/src/core/usecases/fileExplorer/state.ts +++ b/web/src/core/usecases/fileExplorer/state.ts @@ -8,10 +8,20 @@ import type { S3FilesBeingUploaded } from "./decoupledLogic/uploadProgress"; //All explorer paths are expected to be absolute (start with /) export type State = { + navigationError: + | { + errorCase: "access denied" | "no such bucket"; + directoryPath: string; + } + | undefined; directoryPath: string | undefined; viewMode: "list" | "block"; objects: S3Object[]; - isNavigationOngoing: boolean; + ongoingNavigation: + | { + directoryPath: string; + } + | undefined; ongoingOperations: { operationId: string; operation: "create" | "delete" | "modifyPolicy" | "downloading"; @@ -45,7 +55,7 @@ export const { reducer, actions } = createUsecaseActions({ directoryPath: undefined, objects: [], viewMode: "list", - isNavigationOngoing: false, + ongoingNavigation: undefined, ongoingOperations: [], s3FilesBeingUploaded: [], commandLogsEntries: [], @@ -54,7 +64,8 @@ export const { reducer, actions } = createUsecaseActions({ Statement: [] }, isBucketPolicyAvailable: true, - share: undefined + share: undefined, + navigationError: undefined }), reducers: { fileUploadStarted: ( @@ -111,29 +122,59 @@ export const { reducer, actions } = createUsecaseActions({ state.s3FilesBeingUploaded = []; }, - navigationStarted: state => { + navigationStarted: ( + state, + { payload }: { payload: { directoryPath: string } } + ) => { + const { directoryPath } = payload; + assert(state.share === undefined); - state.isNavigationOngoing = true; + state.ongoingNavigation = { + directoryPath + }; }, navigationCompleted: ( state, { payload }: { - payload: { - directoryPath: string; - objects: S3Object[]; - bucketPolicy: S3BucketPolicy | undefined; - isBucketPolicyAvailable: boolean; - }; + payload: + | { + isSuccess: false; + navigationError: + | { + errorCase: "access denied"; + directoryPath: string; + } + | { + errorCase: "no such bucket"; + directoryPath: string; + bucket: string; + shouldAttemptToCreate: boolean; + }; + } + | { + isSuccess: true; + directoryPath: string; + objects: S3Object[]; + bucketPolicy: S3BucketPolicy | undefined; + isBucketPolicyAvailable: boolean; + }; } ) => { + state.ongoingNavigation = undefined; + + if (!payload.isSuccess) { + state.navigationError = payload.navigationError; + return; + } + const { directoryPath, objects, bucketPolicy, isBucketPolicyAvailable } = payload; + state.navigationError = undefined; state.directoryPath = directoryPath; state.objects = objects; - state.isNavigationOngoing = false; if (bucketPolicy) { state.bucketPolicy = bucketPolicy; } @@ -284,7 +325,7 @@ export const { reducer, actions } = createUsecaseActions({ workingDirectoryChanged: state => { state.directoryPath = undefined; state.objects = []; - state.isNavigationOngoing = false; + state.ongoingNavigation = undefined; }, viewModeChanged: ( state, diff --git a/web/src/core/usecases/fileExplorer/thunks.ts b/web/src/core/usecases/fileExplorer/thunks.ts index 2a9d7b46a..528441827 100644 --- a/web/src/core/usecases/fileExplorer/thunks.ts +++ b/web/src/core/usecases/fileExplorer/thunks.ts @@ -1,4 +1,4 @@ -import { assert } from "tsafe/assert"; +import { assert, type Equals } from "tsafe/assert"; import { Evt } from "evt"; import { Zip, ZipPassThrough } from "fflate/browser"; import type { Thunks } from "core/bootstrap"; @@ -6,13 +6,14 @@ import { name, actions } from "./state"; import { protectedSelectors } from "./selectors"; import { join as pathJoin, basename as pathBasename } from "pathe"; import { crawlFactory } from "core/tools/crawl"; -import * as s3ConfigManagement from "core/usecases/s3ConfigManagement"; +import * as s3ProfileManagement from "core/usecases/_s3Next/s3ProfilesManagement"; import type { S3Object } from "core/ports/S3Client"; import { formatDuration } from "core/tools/timeFormat/formatDuration"; import { relative as pathRelative } from "pathe"; import { id } from "tsafe/id"; import { isAmong } from "tsafe/isAmong"; import { removeDuplicates } from "evt/tools/reducers/removeDuplicates"; +import { parseS3UriPrefix } from "core/tools/S3Uri"; const privateThunks = { startOperationWhenAllConflictingOperationHaveCompleted: @@ -113,14 +114,26 @@ const privateThunks = { const [dispatch, getState, { evtAction }] = args; + { + const { ongoingNavigation } = getState()[name]; + + if ( + ongoingNavigation !== undefined && + ongoingNavigation.directoryPath === directoryPath + ) { + return; + } + } + if ( !doListAgainIfSamePath && - getState()[name].directoryPath === directoryPath + getState()[name].directoryPath === directoryPath && + getState()[name].navigationError === undefined ) { return; } - dispatch(actions.navigationStarted()); + dispatch(actions.navigationStarted({ directoryPath })); const ctx = Evt.newCtx(); @@ -153,17 +166,16 @@ const privateThunks = { }) ); - const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + const { s3Client, s3Profile } = await dispatch( + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); - return r.s3Client; + return r; }); - const { objects, bucketPolicy, isBucketPolicyAvailable } = - await s3Client.listObjects({ - path: directoryPath - }); + const listObjectResult = await s3Client.listObjects({ + path: directoryPath + }); if (ctx.completionStatus !== undefined) { dispatch(actions.commandLogCancelled({ cmdId })); @@ -175,21 +187,81 @@ const privateThunks = { dispatch( actions.commandLogResponseReceived({ cmdId, - resp: objects - .map(({ kind, basename }) => - kind === "directory" ? `${basename}/` : basename - ) - .join("\n") + resp: (() => { + if (listObjectResult.isSuccess) { + return listObjectResult.objects + .map(({ kind, basename }) => + kind === "directory" ? `${basename}/` : basename + ) + .join("\n"); + } + + switch (listObjectResult.errorCase) { + case "access denied": + return "Access Denied"; + case "no such bucket": + return "No Such Bucket"; + default: + assert>( + false + ); + } + })() }) ); dispatch( - actions.navigationCompleted({ - directoryPath, - objects, - bucketPolicy, - isBucketPolicyAvailable - }) + actions.navigationCompleted( + (() => { + if (!listObjectResult.isSuccess) { + switch (listObjectResult.errorCase) { + case "access denied": + return { + isSuccess: false, + navigationError: { + directoryPath, + errorCase: "access denied" + } + }; + case "no such bucket": { + const { bucket } = parseS3UriPrefix({ + s3UriPrefix: `s3://${directoryPath}`, + strict: false + }); + + const shouldAttemptToCreate = + s3Profile.bookmarks.find( + bookmark => + bookmark.s3UriPrefixObj.bucket === bucket + ) !== undefined; + + return { + isSuccess: false, + navigationError: { + directoryPath, + errorCase: "no such bucket", + bucket, + shouldAttemptToCreate + } + }; + } + default: + assert< + Equals + >(false); + } + } + + return { + isSuccess: true, + directoryPath, + objects: listObjectResult.objects, + bucketPolicy: listObjectResult.bucketPolicy, + isBucketPolicyAvailable: + listObjectResult.isBucketPolicyAvailable + }; + })() + ) ); }, downloadObject: @@ -203,7 +275,7 @@ const privateThunks = { const { s3Object } = params; const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -245,7 +317,7 @@ const privateThunks = { const { s3Objects } = params; const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -285,11 +357,13 @@ const privateThunks = { const { crawl } = crawlFactory({ list: async ({ directoryPath }) => { - const { objects } = await s3Client.listObjects({ + const listObjectResult = await s3Client.listObjects({ path: directoryPath }); - return objects.reduce<{ + assert(listObjectResult.isSuccess); + + return listObjectResult.objects.reduce<{ fileBasenames: string[]; directoryBasenames: string[]; }>( @@ -444,7 +518,7 @@ const privateThunks = { ); const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -467,6 +541,62 @@ const privateThunks = { } } satisfies Thunks; +export const protectedThunks = { + createBucket: + (params: { bucket: string; directoryPath_toNavigateToOnSuccess: string }) => + async (...args): Promise<{ isSuccess: boolean }> => { + const { bucket, directoryPath_toNavigateToOnSuccess } = params; + + const [dispatch] = args; + + const s3Client = await dispatch( + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() + ).then(r => { + assert(r !== undefined); + return r.s3Client; + }); + + const cmdId = Date.now(); + + dispatch( + actions.commandLogIssued({ + cmdId, + cmd: `mc mb ${pathJoin("s3", bucket)}` + }) + ); + + const result = await s3Client.createBucket({ bucket }); + + dispatch( + actions.commandLogResponseReceived({ + cmdId, + resp: result.isSuccess + ? `Bucket \`${pathJoin("s3", bucket)}\` created` + : (() => { + switch (result.errorCase) { + case "already exist": + return `Bucket \`${pathJoin("s3", bucket)}\` already exists`; + case "access denied": + return `Access denied while creating \`${pathJoin("s3", bucket)}\`: ${result.errorMessage}`; + case "unknown": + return `Failed to create \`${pathJoin("s3", bucket)}\`: ${result.errorMessage}`; + } + })() + }) + ); + + if (result.isSuccess) { + await dispatch( + thunks.changeCurrentDirectory({ + directoryPath: directoryPath_toNavigateToOnSuccess + }) + ); + } + + return { isSuccess: result.isSuccess }; + } +} satisfies Thunks; + export const thunks = { initialize: (params: { directoryPath: string; viewMode: "list" | "block" }) => @@ -540,7 +670,7 @@ export const thunks = { }) ); const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -773,7 +903,7 @@ export const thunks = { ); const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -786,9 +916,14 @@ export const thunks = { const { crawl } = crawlFactory({ list: async ({ directoryPath }) => { - const { objects } = await s3Client.listObjects({ + const listObjectsResult = await s3Client.listObjects({ path: directoryPath }); + + assert(listObjectsResult.isSuccess); + + const { objects } = listObjectsResult; + return { fileBasenames: objects .filter(obj => obj.kind === "file") @@ -869,7 +1004,7 @@ export const thunks = { ); const s3Client = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); return r.s3Client; @@ -904,8 +1039,8 @@ export const thunks = { assert(directoryPath !== undefined); - const { s3Client, s3Config } = await dispatch( - s3ConfigManagement.protectedThunks.getS3ConfigAndClientForExplorer() + const { s3Client, s3Profile } = await dispatch( + s3ProfileManagement.protectedThunks.getS3ConfigAndClientForExplorer() ).then(r => { assert(r !== undefined); return r; @@ -921,7 +1056,7 @@ export const thunks = { dispatch( actions.shareOpened({ fileBasename, - url: `${s3Config.paramsOfCreateS3Client.url}/${pathJoin(directoryPath, fileBasename)}`, + url: `${s3Profile.paramsOfCreateS3Client.url}/${pathJoin(directoryPath, fileBasename)}`, validityDurationSecondOptions: undefined }) ); diff --git a/web/src/core/usecases/index.ts b/web/src/core/usecases/index.ts index bd292355c..7c2162826 100644 --- a/web/src/core/usecases/index.ts +++ b/web/src/core/usecases/index.ts @@ -25,6 +25,10 @@ import * as projectManagement from "./projectManagement"; import * as viewQuotas from "./viewQuotas"; import * as dataCollection from "./dataCollection"; +import * as s3ProfilesManagement from "./_s3Next/s3ProfilesManagement"; +import * as s3ProfilesCreationUiController from "./_s3Next/s3ProfilesCreationUiController"; +import * as s3ExplorerRootUiController from "./_s3Next/s3ExplorerRootUiController"; + export const usecases = { autoLogoutCountdown, catalog, @@ -51,5 +55,9 @@ export const usecases = { dataExplorer, projectManagement, viewQuotas, - dataCollection + dataCollection, + // Next + s3ProfilesManagement, + s3ProfilesCreationUiController, + s3ExplorerRootUiController }; diff --git a/web/src/core/usecases/launcher/selectors.ts b/web/src/core/usecases/launcher/selectors.ts index 546d4a0ac..2f9400a50 100644 --- a/web/src/core/usecases/launcher/selectors.ts +++ b/web/src/core/usecases/launcher/selectors.ts @@ -7,7 +7,7 @@ import * as projectManagement from "core/usecases/projectManagement"; import * as userConfigs from "core/usecases/userConfigs"; import { exclude } from "tsafe/exclude"; import { createSelector } from "clean-architecture"; -import * as s3ConfigManagement from "core/usecases/s3ConfigManagement"; +import * as s3ConfigManagement from "core/usecases/_s3Next/s3ProfilesManagement"; import { id } from "tsafe/id"; import { computeRootForm } from "./decoupledLogic"; import { computeDiff } from "core/tools/Stringifyable"; @@ -155,7 +155,7 @@ const chartVersion = createSelector(readyState, state => { }); const s3ConfigSelect = createSelector( - s3ConfigManagement.selectors.s3Configs, + s3ConfigManagement.selectors.s3Profiles, isReady, projectManagement.selectors.canInjectPersonalInfos, createSelector(readyState, state => { @@ -177,7 +177,7 @@ const s3ConfigSelect = createSelector( } const availableConfigs = s3Configs.filter( - config => canInjectPersonalInfos || config.origin !== "deploymentRegion" + config => canInjectPersonalInfos || config.origin !== "defined in region" ); // We don't display the s3 config selector if there is no config or only one @@ -189,9 +189,11 @@ const s3ConfigSelect = createSelector( options: availableConfigs.map(s3Config => ({ optionValue: s3Config.id, label: { - dataSource: s3Config.dataSource, + dataSource: new URL(s3Config.paramsOfCreateS3Client.url).hostname, friendlyName: - s3Config.origin === "project" ? s3Config.friendlyName : undefined + s3Config.origin === "created by user (or group project member)" + ? s3Config.friendlyName + : undefined } })), selectedOptionValue: s3Config.s3ConfigId diff --git a/web/src/core/usecases/launcher/thunks.ts b/web/src/core/usecases/launcher/thunks.ts index bc5da3e6f..c8ebf7961 100644 --- a/web/src/core/usecases/launcher/thunks.ts +++ b/web/src/core/usecases/launcher/thunks.ts @@ -3,10 +3,9 @@ import { assert, type Equals, is } from "tsafe/assert"; import * as userAuthentication from "../userAuthentication"; import * as deploymentRegionManagement from "core/usecases/deploymentRegionManagement"; import * as projectManagement from "core/usecases/projectManagement"; -import * as s3ConfigManagement from "core/usecases/s3ConfigManagement"; +import * as s3ConfigManagement from "core/usecases/_s3Next/s3ProfilesManagement"; import * as userConfigsUsecase from "core/usecases/userConfigs"; import * as userProfileForm from "core/usecases/userProfileForm"; -import { bucketNameAndObjectNameFromS3Path } from "core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path"; import { parseUrl } from "core/tools/parseUrl"; import * as secretExplorer from "../secretExplorer"; import { actions } from "./state"; @@ -174,9 +173,12 @@ export const thunks = { const { s3ConfigId, s3ConfigId_default } = (() => { const s3Configs = s3ConfigManagement.selectors - .s3Configs(getState()) + .s3Profiles(getState()) .filter(s3Config => - doInjectPersonalInfos ? true : s3Config.origin === "project" + doInjectPersonalInfos + ? true + : s3Config.origin === + "created by user (or group project member)" ); const s3ConfigId_default = (() => { @@ -681,7 +683,7 @@ export const protectedThunks = { } const s3Configs = - s3ConfigManagement.selectors.s3Configs(getState()); + s3ConfigManagement.selectors.s3Profiles(getState()); const s3Config = s3Configs.find( s3Config => s3Config.id === s3ConfigId @@ -701,21 +703,16 @@ export const protectedThunks = { ? parseUrl(s3Config.paramsOfCreateS3Client.url) : {}; - const { bucketName, objectName: objectNamePrefix } = - bucketNameAndObjectNameFromS3Path(s3Config.workingDirectoryPath); - const s3: XOnyxiaContext["s3"] = { isEnabled: true, AWS_ACCESS_KEY_ID: undefined, AWS_SECRET_ACCESS_KEY: undefined, AWS_SESSION_TOKEN: undefined, - AWS_BUCKET_NAME: bucketName, - AWS_DEFAULT_REGION: s3Config.region ?? "us-east-1", + AWS_DEFAULT_REGION: + s3Config.paramsOfCreateS3Client.region ?? "us-east-1", AWS_S3_ENDPOINT: host, port, pathStyleAccess: s3Config.paramsOfCreateS3Client.pathStyleAccess, - objectNamePrefix, - workingDirectoryPath: s3Config.workingDirectoryPath, isAnonymous: false }; @@ -723,7 +720,7 @@ export const protectedThunks = { const s3Client = await dispatch( s3ConfigManagement.protectedThunks.getS3ClientForSpecificConfig( { - s3ConfigId: s3Config.id + s3ProfileId: s3Config.id } ) ); diff --git a/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts b/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts index 7e90cc574..7b0c151b9 100644 --- a/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts +++ b/web/src/core/usecases/projectManagement/decoupledLogic/ProjectConfigs.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { id } from "tsafe/id"; import type { OptionalIfCanBeUndefined } from "core/tools/OptionalIfCanBeUndefined"; import { zStringifyableAtomic } from "core/tools/Stringifyable"; +import type { S3UriPrefixObj } from "core/tools/S3Uri"; export type ProjectConfigs = { __modelVersion: 1; @@ -33,8 +34,16 @@ export namespace ProjectConfigs { sessionToken: string | undefined; } | undefined; + bookmarks: S3Config.Bookmark[] | undefined; }; + export namespace S3Config { + export type Bookmark = { + displayName: string | undefined; + s3UriPrefixObj: S3UriPrefixObj; + }; + } + export type RestorableServiceConfig = { friendlyName: string; isShared: boolean | undefined; @@ -100,6 +109,23 @@ const zS3Credentials = (() => { return id>(zTargetType); })(); +const zS3ConfigBookmark = (() => { + type TargetType = ProjectConfigs.S3Config.Bookmark; + + const zTargetType = z.object({ + displayName: z.union([z.string(), z.undefined()]), + s3UriPrefixObj: z.object({ + bucket: z.string(), + keyPrefix: z.string() + }) + }); + + assert, OptionalIfCanBeUndefined>>(); + + // @ts-expect-error + return id>(zTargetType); +})(); + const zS3Config = (() => { type TargetType = ProjectConfigs.S3Config; @@ -110,7 +136,8 @@ const zS3Config = (() => { region: z.union([z.string(), z.undefined()]), workingDirectoryPath: z.string(), pathStyleAccess: z.boolean(), - credentials: z.union([zS3Credentials, z.undefined()]) + credentials: z.union([zS3Credentials, z.undefined()]), + bookmarks: z.union([z.array(zS3ConfigBookmark), z.undefined()]) }); assert, OptionalIfCanBeUndefined>>(); diff --git a/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/v0ToV1.ts b/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/v0ToV1.ts index affa7943a..1c62cddd5 100644 --- a/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/v0ToV1.ts +++ b/web/src/core/usecases/projectManagement/decoupledLogic/projectConfigsMigration/v0ToV1.ts @@ -5,6 +5,7 @@ import { join as pathJoin } from "pathe"; import { secretToValue, valueToSecret } from "../secretParsing"; import YAML from "yaml"; import { getS3Configs } from "core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs"; +import type { S3UriPrefixObj } from "core/tools/S3Uri"; namespace v0 { export type ProjectConfigs = { @@ -89,8 +90,16 @@ export namespace v1 { sessionToken: string | undefined; } | undefined; + bookmarks: S3Config.Bookmark[] | undefined; }; + export namespace S3Config { + export type Bookmark = { + displayName: string | undefined; + s3UriPrefixObj: S3UriPrefixObj; + }; + } + export type RestorableServiceConfig = { friendlyName: string; isShared: boolean | undefined; @@ -255,7 +264,8 @@ export async function v0ToV1(params: { workingDirectoryPath: customS3Config_legacy.workingDirectoryPath, pathStyleAccess: customS3Config_legacy.pathStyleAccess, - credentials: customS3Config_legacy.credentials + credentials: customS3Config_legacy.credentials, + bookmarks: [] }); }); diff --git a/web/src/core/usecases/projectManagement/selectors.ts b/web/src/core/usecases/projectManagement/selectors.ts index 09c00408d..aaf31f994 100644 --- a/web/src/core/usecases/projectManagement/selectors.ts +++ b/web/src/core/usecases/projectManagement/selectors.ts @@ -5,6 +5,7 @@ import { assert } from "tsafe/assert"; const state = (rootState: RootState) => rootState[name]; +// TODO: Here this selector should take a s const projectConfig = createSelector(state, state => state.currentProjectConfigs); export const protectedSelectors = { diff --git a/web/src/core/usecases/s3ConfigCreation/selectors.ts b/web/src/core/usecases/s3ConfigCreation/selectors.ts index b9811873e..7b8f264ee 100644 --- a/web/src/core/usecases/s3ConfigCreation/selectors.ts +++ b/web/src/core/usecases/s3ConfigCreation/selectors.ts @@ -3,7 +3,7 @@ import { createSelector } from "clean-architecture"; import { name } from "./state"; import { objectKeys } from "tsafe/objectKeys"; import { assert, type Equals } from "tsafe/assert"; -import { bucketNameAndObjectNameFromS3Path } from "core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path"; +import { parseS3UriPrefix } from "core/tools/S3Uri"; import { id } from "tsafe/id"; import type { ProjectConfigs } from "core/usecases/projectManagement"; import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; @@ -214,7 +214,8 @@ const submittableFormValuesAsProjectS3Config = createSelector( secretAccessKey: formValues.secretAccessKey, sessionToken: formValues.sessionToken }; - })() + })(), + bookmarks: undefined }); } ); @@ -331,8 +332,10 @@ const urlStylesExamples = createSelector( const urlObject = new URL(formattedFormValuesUrl); - const { bucketName, objectName: objectNamePrefix } = - bucketNameAndObjectNameFromS3Path(formattedFormValuesWorkingDirectoryPath); + const { bucket: bucketName, keyPrefix: objectNamePrefix } = parseS3UriPrefix({ + s3UriPrefix: `s3://${formattedFormValuesWorkingDirectoryPath}`, + strict: false + }); const domain = formattedFormValuesUrl .split(urlObject.protocol)[1] diff --git a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts index e737e9b3b..767b5388e 100644 --- a/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts +++ b/web/src/core/usecases/s3ConfigManagement/decoupledLogic/getS3Configs.ts @@ -1,10 +1,9 @@ import * as projectManagement from "core/usecases/projectManagement"; import type { DeploymentRegion } from "core/ports/OnyxiaApi/DeploymentRegion"; -import { bucketNameAndObjectNameFromS3Path } from "core/adapters/s3Client/utils/bucketNameAndObjectNameFromS3Path"; +import { parseS3UriPrefix } from "core/tools/S3Uri"; import type { ParamsOfCreateS3Client } from "core/adapters/s3Client"; import { same } from "evt/tools/inDepth/same"; import { getWorkingDirectoryPath } from "./getWorkingDirectoryPath"; -import { getWorkingDirectoryBucketToCreate } from "./getWorkingDirectoryBucket"; import { fnv1aHashToHex } from "core/tools/fnv1aHashToHex"; import { assert, type Equals } from "tsafe/assert"; import { getProjectS3ConfigId } from "./projectS3ConfigId"; @@ -108,8 +107,10 @@ export function getS3Configs(params: { out = out.replace(/^https?:\/\//, "").replace(/\/$/, ""); - const { bucketName, objectName } = - bucketNameAndObjectNameFromS3Path(workingDirectoryPath); + const { bucket: bucketName, keyPrefix: objectName } = parseS3UriPrefix({ + s3UriPrefix: `s3://${workingDirectoryPath}`, + strict: false + }); out = pathStyleAccess ? `${out}/${bucketName}/${objectName}` @@ -198,13 +199,7 @@ export function getS3Configs(params: { .sort((a, b) => b.creationTime - a.creationTime), ...s3RegionConfigs.map((c, i): S3Config.FromDeploymentRegion => { const id = `region-${fnv1aHashToHex( - JSON.stringify( - Object.fromEntries( - Object.entries(c).sort(([key1], [key2]) => - key1.localeCompare(key2) - ) - ) - ) + JSON.stringify([c.url, c.sts.oidcParams.clientId ?? ""]) )}`; const workingDirectoryContext = @@ -243,11 +238,7 @@ export function getS3Configs(params: { region, oidcParams: c.sts.oidcParams, durationSeconds: c.sts.durationSeconds, - role: c.sts.role, - nameOfBucketToCreateIfNotExist: getWorkingDirectoryBucketToCreate({ - workingDirectory: c.workingDirectory, - context: workingDirectoryContext - }) + role: c.sts.role }; const adminBookmarks: S3Config.FromDeploymentRegion.Location.AdminBookmark[] = diff --git a/web/src/core/usecases/userConfigs.ts b/web/src/core/usecases/userConfigs.ts index 5667c6bef..b3a3f7463 100644 --- a/web/src/core/usecases/userConfigs.ts +++ b/web/src/core/usecases/userConfigs.ts @@ -32,6 +32,7 @@ export type UserConfigs = Id< selectedProjectId: string | null; isCommandBarEnabled: boolean; userProfileStr: string | null; + s3BookmarksStr: string | null; } >; @@ -153,7 +154,8 @@ export const protectedThunks = { doDisplayAcknowledgeConfigVolatilityDialogIfNoVault: true, selectedProjectId: null, isCommandBarEnabled: paramsOfBootstrapCore.isCommandBarEnabledByDefault, - userProfileStr: null + userProfileStr: null, + s3BookmarksStr: null }; const dirPath = await dispatch(privateThunks.getDirPath()); diff --git a/web/src/ui/App/LeftBar.tsx b/web/src/ui/App/LeftBar.tsx index a6992b5cf..84860bb8a 100644 --- a/web/src/ui/App/LeftBar.tsx +++ b/web/src/ui/App/LeftBar.tsx @@ -121,6 +121,18 @@ export const LeftBar = memo((props: Props) => { link: routes.sqlOlapShell().link, availability: isDevModeEnabled ? "available" : "not visible" }, + { + itemId: "s3Explorer", + icon: customIcons.filesSvgUrl, + label: "S3 Explorer", + link: routes.s3Explorer({ + path: "" + }).link, + availability: + isDevModeEnabled && isFileExplorerEnabled + ? "available" + : "not visible" + }, { groupId: "custom-leftbar-links", label: t("divider: onyxia instance specific features") @@ -168,6 +180,9 @@ export const LeftBar = memo((props: Props) => { return "dataExplorer"; case "dataCollection": return "dataCollection"; + case "s3Explorer": + case "s3Explorer_root": + return "s3Explorer"; case "page404": return null; case "document": diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index 3e03b5441..2771a5ad0 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -349,6 +349,18 @@ export const translations: Translations<"de"> = { cancel: "Abbrechen", "go to settings": "Zu den Einstellungen gehen" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => + `Der Bucket ${bucket} existiert nicht`, + "bucket does not exist body": "Möchten Sie jetzt versuchen, ihn zu erstellen?", + no: "Nein", + yes: "Ja", + "success title": "Erfolg", + "failed title": "Fehlgeschlagen", + "success body": ({ bucket }) => `Bucket ${bucket} wurde erfolgreich erstellt.`, + "failed body": ({ bucket }) => `Bucket ${bucket} konnte nicht erstellt werden.`, + ok: "Ok" + }, ShareDialog: { title: "Ihre Daten teilen", close: "Schließen", diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 34f99327b..dfa107496 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -341,6 +341,18 @@ export const translations: Translations<"en"> = { cancel: "Cancel", "go to settings": "Go to settings" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => + `The ${bucket} bucket does not exist`, + "bucket does not exist body": "Do you want to attempt creating it now?", + no: "No", + yes: "Yes", + "success title": "Success", + "failed title": "Failed", + "success body": ({ bucket }) => `Bucket ${bucket} successfully created.`, + "failed body": ({ bucket }) => `Failed to create ${bucket}.`, + ok: "Ok" + }, ShareDialog: { title: "Share your data", close: "Close", diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index c414b392c..dafb53464 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -352,6 +352,17 @@ export const translations: Translations<"en"> = { cancel: "Cancelar", "go to settings": "Ir a configuración" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => `El bucket ${bucket} no existe`, + "bucket does not exist body": "¿Quieres intentar crearlo ahora?", + no: "No", + yes: "Sí", + "success title": "Éxito", + "failed title": "Error", + "success body": ({ bucket }) => `Bucket ${bucket} creado correctamente.`, + "failed body": ({ bucket }) => `No se pudo crear ${bucket}.`, + ok: "Ok" + }, ShareDialog: { title: "Compartir tus datos", close: "Cerrar", diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index ddc6cf6b1..5fdf757db 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -346,6 +346,17 @@ export const translations: Translations<"fi"> = { cancel: "Peruuta", "go to settings": "Siirry asetuksiin" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => `Bucket ${bucket} ei ole olemassa`, + "bucket does not exist body": "Haluatko yrittää luoda sen nyt?", + no: "Ei", + yes: "Kyllä", + "success title": "Onnistui", + "failed title": "Epäonnistui", + "success body": ({ bucket }) => `Bucket ${bucket} luotiin onnistuneesti.`, + "failed body": ({ bucket }) => `Kohteen ${bucket} luonti epäonnistui.`, + ok: "Ok" + }, ShareDialog: { title: "Jaa tietosi", close: "Sulje", diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 1fecc74c1..af8dcdd41 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -355,6 +355,17 @@ export const translations: Translations<"fr"> = { cancel: "Annuler", "go to settings": "Aller aux paramètres" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => `Le bucket ${bucket} n'existe pas`, + "bucket does not exist body": "Voulez-vous tenter de le créer maintenant ?", + no: "Non", + yes: "Oui", + "success title": "Succès", + "failed title": "Échec", + "success body": ({ bucket }) => `Bucket ${bucket} créé avec succès.`, + "failed body": ({ bucket }) => `Échec de la création de ${bucket}.`, + ok: "Ok" + }, ShareDialog: { title: "Partager vos données", close: "Fermer", diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index 61d74300e..f969426d5 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -349,6 +349,17 @@ export const translations: Translations<"it"> = { cancel: "Annulla", "go to settings": "Vai alle impostazioni" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => `Il bucket ${bucket} non esiste`, + "bucket does not exist body": "Vuoi provare a crearlo ora?", + no: "No", + yes: "Sì", + "success title": "Successo", + "failed title": "Fallito", + "success body": ({ bucket }) => `Bucket ${bucket} creato con successo.`, + "failed body": ({ bucket }) => `Creazione di ${bucket} non riuscita.`, + ok: "Ok" + }, ShareDialog: { title: "Condividi i tuoi dati", close: "Chiudi", @@ -384,9 +395,8 @@ export const translations: Translations<"it"> = { la nostra documentazione .   - - Configurare il tuo Vault CLI locale - . + Configurare il tuo Vault CLI locale + . ) }, diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index ae8f28ff7..a94850188 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -349,6 +349,17 @@ export const translations: Translations<"nl"> = { cancel: "Annuleren", "go to settings": "Ga naar instellingen" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => `De bucket ${bucket} bestaat niet`, + "bucket does not exist body": "Wil je proberen hem nu aan te maken?", + no: "Nee", + yes: "Ja", + "success title": "Gelukt", + "failed title": "Mislukt", + "success body": ({ bucket }) => `Bucket ${bucket} is succesvol aangemaakt.`, + "failed body": ({ bucket }) => `Aanmaken van ${bucket} is mislukt.`, + ok: "Ok" + }, ShareDialog: { title: "Deel je gegevens", close: "Sluiten", diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 5dc6a75fc..48a84cbb3 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -345,6 +345,17 @@ export const translations: Translations<"no"> = { cancel: "Avbryt", "go to settings": "Gå til innstillinger" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => `Bucket ${bucket} finnes ikke`, + "bucket does not exist body": "Vil du prøve å opprette den nå?", + no: "Nei", + yes: "Ja", + "success title": "Vellykket", + "failed title": "Feilet", + "success body": ({ bucket }) => `Bucket ${bucket} ble opprettet.`, + "failed body": ({ bucket }) => `Kunne ikke opprette ${bucket}.`, + ok: "Ok" + }, ShareDialog: { title: "Del dataene dine", close: "Lukk", diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index f13366950..e570ac182 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -312,6 +312,17 @@ export const translations: Translations<"zh-CN"> = { cancel: "取消", "go to settings": "前往设置" }, + ConfirmBucketCreationAttemptDialog: { + "bucket does not exist title": ({ bucket }) => `存储桶 ${bucket} 不存在`, + "bucket does not exist body": "要立即尝试创建吗?", + no: "否", + yes: "是", + "success title": "成功", + "failed title": "失败", + "success body": ({ bucket }) => `存储桶 ${bucket} 创建成功。`, + "failed body": ({ bucket }) => `创建 ${bucket} 失败。`, + ok: "确定" + }, ShareDialog: { title: "分享您的数据", close: "关闭", diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index e8f8b5498..59b82d93f 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -19,6 +19,7 @@ export type ComponentKey = | import("ui/pages/fileExplorerEntry/Page").I18n | import("ui/pages/fileExplorerEntry/S3Entries/S3EntryCard").I18n | import("ui/pages/fileExplorerEntry/FileExplorerDisabledDialog").I18n + | import("ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog").I18n | import("ui/pages/fileExplorer/Explorer/Explorer").I18n | import("ui/pages/fileExplorer/Explorer/ExplorerButtonBar").I18n | import("ui/pages/fileExplorer/Explorer/ExplorerItems").I18n diff --git a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx b/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx index 9a8cff56d..5ac4cdf01 100644 --- a/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx +++ b/web/src/ui/pages/fileExplorer/Explorer/Explorer.tsx @@ -19,10 +19,7 @@ import type { NonPostableEvt, StatefulReadonlyEvt, UnpackEvt } from "evt"; import { useEvt } from "evt/hooks"; import { ExplorerItems } from "./ExplorerItems"; import { ExplorerButtonBar, type ExplorerButtonBarProps } from "./ExplorerButtonBar"; -//TODO: The margin was set to itself be mindful when replacing by the onyxia-ui version. -import { DirectoryHeader } from "onyxia-ui/DirectoryHeader"; import { useDomRect } from "powerhooks/useDomRect"; -import { ExplorerIcon } from "./ExplorerIcon/ExplorerIcon"; import { Dialog } from "onyxia-ui/Dialog"; import { useCallbackFactory } from "powerhooks/useCallbackFactory"; import { Deferred } from "evt/tools/Deferred"; @@ -47,6 +44,9 @@ import { isDirectory } from "../shared/tools"; import { ShareDialog } from "../ShareFile/ShareDialog"; import type { ShareView } from "core/usecases/fileExplorer"; import { ExplorerDownloadSnackbar } from "./ExplorerDownloadSnackbar"; +import { IconButton } from "onyxia-ui/IconButton"; +import { Icon } from "onyxia-ui/Icon"; +import { getIconUrlByName } from "lazy-icons"; export type ExplorerProps = { /** @@ -99,6 +99,17 @@ export type ExplorerProps = { blob: Blob; }[]; }) => void; + + bookmarkStatus: + | { + isBookmarked: false; + } + | { + isBookmarked: true; + isReadonly: boolean; + } + | undefined; + onToggleIsDirectoryPathBookmarked: (() => void) | undefined; }; assert< @@ -141,7 +152,9 @@ export const Explorer = memo((props: ExplorerProps) => { onShareRequestSignedUrl, onChangeShareSelectedValidityDuration, onDownloadItems, - evtIsDownloadSnackbarOpen + evtIsDownloadSnackbarOpen, + bookmarkStatus, + onToggleIsDirectoryPathBookmarked } = props; const [items] = useMemo( @@ -163,9 +176,13 @@ export const Explorer = memo((props: ExplorerProps) => { ); const onBreadcrumbNavigate = useConstCallback( - ({ upCount }: Param0) => { + ({ path }: Param0) => { + assert(path.length !== 0); + + const [, ...rest] = path; + onNavigate({ - directoryPath: pathJoin(directoryPath, ...new Array(upCount).fill("..")) + directoryPath: rest.join("") }); } ); @@ -207,10 +224,6 @@ export const Explorer = memo((props: ExplorerProps) => { } ); - const onGoBack = useConstCallback(() => { - onNavigate({ directoryPath: pathJoin(directoryPath, "..") }); - }); - const evtExplorerItemsAction = useConst(() => Evt.create>() ); @@ -396,46 +409,17 @@ export const Explorer = memo((props: ExplorerProps) => { /> )} - {(() => { - const title = (() => { - let split = directoryPath.split("/"); - - // remove the last element - split.pop(); - - // remove path min depth elements at the beginning - split = split.slice(pathMinDepth + 1); - - if (split.length === 0) { - return undefined; - } - - return split[split.length - 1]; - })(); - - if (title === undefined) { - return null; - } - - return ( - - } - /> - ); - })()}
part !== "")} + path={[ + "s3://", + ...directoryPath + .split("/") + .filter(segment => segment !== "") + .map(segment => `${segment}/`) + ]} + separatorChar="​" isNavigationDisabled={isNavigating} onNavigate={onBreadcrumbNavigate} evtAction={evtBreadcrumbAction} @@ -447,6 +431,27 @@ export const Explorer = memo((props: ExplorerProps) => { className={classes.circularProgress} /> )} + {(() => { + if (bookmarkStatus === undefined) { + return null; + } + assert(onToggleIsDirectoryPathBookmarked !== undefined); + + const icon = getIconUrlByName( + bookmarkStatus.isBookmarked ? "Star" : "StarBorder" + ); + + if (bookmarkStatus.isBookmarked && bookmarkStatus.isReadonly) { + return ; + } + + return ( + + ); + })()}
+ Evt.create() + ); + + useEvt(ctx => { + evtFileExplorer.pipe(ctx).attach( + data => data.action === "ask confirmation for bucket creation attempt", + ({ bucket, createBucket }) => { + evtConfirmBucketCreationAttemptDialogOpen.post({ + bucket, + createBucket + }); + } + ); + }, []); + + return ( + <> + + + + ); +} + +function FileExplorer_inner() { const route = useRoute(); assert(routeGroup.has(route)); @@ -33,6 +71,7 @@ function FileExplorer() { const { isCurrentWorkingDirectoryLoaded, + navigationError, commandLogsEntries, isNavigationOngoing, uploadProgress, @@ -43,16 +82,16 @@ function FileExplorer() { isDownloadPreparing } = useCoreState("fileExplorer", "main"); + const { + functions: { fileExplorer } + } = getCoreSync(); + const evtIsSnackbarOpen = useConst(() => Evt.create(isDownloadPreparing)); useEffect(() => { evtIsSnackbarOpen.state = isDownloadPreparing; }, [isDownloadPreparing]); - const { - functions: { fileExplorer } - } = getCoreSync(); - useEffect(() => { fileExplorer.initialize({ directoryPath: route.params.path, @@ -97,17 +136,7 @@ function FileExplorer() { } ); - const { classes } = useStyles(); - - const { showSplashScreen, hideSplashScreen } = useSplashScreen(); - - useEffect(() => { - if (currentWorkingDirectoryView === undefined) { - showSplashScreen({ enableTransparency: true }); - } else { - hideSplashScreen(); - } - }, [currentWorkingDirectoryView === undefined]); + const { classes, cx, css } = useStyles(); useEffect(() => { if (currentWorkingDirectoryView === undefined) { @@ -151,8 +180,48 @@ function FileExplorer() { }) ); + const onNavigate = useConstCallback( + ({ directoryPath }) => { + if (directoryPath === "") { + routes.fileExplorerEntry().push(); + return; + } + + fileExplorer.changeCurrentDirectory({ directoryPath }); + } + ); + if (!isCurrentWorkingDirectoryLoaded) { - return null; + return ( +
+ {(() => { + if (navigationError !== undefined) { + return ( +
+ {navigationError.errorCase} + +
+ ); + } + + return ; + })()} +
+ ); } return ( @@ -183,7 +252,7 @@ function FileExplorer() { currentWorkingDirectoryView.isBucketPolicyFeatureEnabled } changePolicy={fileExplorer.changePolicy} - onNavigate={fileExplorer.changeCurrentDirectory} + onNavigate={onNavigate} onRefresh={onRefresh} onDeleteItems={onDeleteItems} onCopyPath={onCopyPath} @@ -200,6 +269,8 @@ function FileExplorer() { } onDownloadItems={onDownloadItems} evtIsDownloadSnackbarOpen={evtIsSnackbarOpen} + bookmarkStatus={undefined} + onToggleIsDirectoryPathBookmarked={undefined} />
); diff --git a/web/src/ui/pages/index.ts b/web/src/ui/pages/index.ts index eb5118f4f..be6cec1ff 100644 --- a/web/src/ui/pages/index.ts +++ b/web/src/ui/pages/index.ts @@ -16,6 +16,8 @@ import * as dataExplorer from "./dataExplorer"; import * as fileExplorer from "./fileExplorerEntry"; import * as dataCollection from "./dataCollection"; +import * as s3Explorer from "./s3Explorer"; + export const pages = { account, catalog, @@ -31,7 +33,8 @@ export const pages = { sqlOlapShell, dataExplorer, fileExplorer, - dataCollection + dataCollection, + s3Explorer }; export const { routeDefs } = mergeRouteDefs({ pages }); diff --git a/web/src/ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog.tsx b/web/src/ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog.tsx new file mode 100644 index 000000000..7bfe04473 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/ConfirmBucketCreationAttemptDialog.tsx @@ -0,0 +1,163 @@ +import { useState, memo } from "react"; +import { Dialog } from "onyxia-ui/Dialog"; +import { Button } from "onyxia-ui/Button"; +import { symToStr } from "tsafe/symToStr"; +import { assert } from "tsafe/assert"; +import type { NonPostableEvt, UnpackEvt } from "evt"; +import { useEvt } from "evt/hooks"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { declareComponentKeys, useTranslation } from "ui/i18n"; + +export type ConfirmBucketCreationAttemptDialogProps = { + evtOpen: NonPostableEvt<{ + bucket: string; + createBucket: () => Promise<{ isSuccess: boolean }>; + }>; +}; + +export const ConfirmBucketCreationAttemptDialog = memo( + (props: ConfirmBucketCreationAttemptDialogProps) => { + const { evtOpen } = props; + + const { t } = useTranslation({ ConfirmBucketCreationAttemptDialog }); + + const [state, setState] = useState< + | (UnpackEvt & { + isBucketCreationSuccess: boolean | undefined; + isCreatingBucket: boolean; + }) + | undefined + >(undefined); + + useEvt( + ctx => { + evtOpen.attach(ctx, eventData => + setState({ + ...eventData, + isBucketCreationSuccess: undefined, + isCreatingBucket: false + }) + ); + }, + [evtOpen] + ); + + return ( + <> + { + if (state === undefined) { + return null; + } + + if (state.isCreatingBucket) { + return ; + } + + return ( + <> + + + + ); + })()} + isOpen={ + state !== undefined && state.isBucketCreationSuccess === undefined + } + onClose={() => { + if (state === undefined) { + return; + } + + if (state.isCreatingBucket) { + return; + } + + setState(undefined); + }} + /> + setState(undefined)}> + {t("ok")} + + } + isOpen={ + state !== undefined && state.isBucketCreationSuccess !== undefined + } + onClose={() => setState(undefined)} + /> + + ); + } +); + +ConfirmBucketCreationAttemptDialog.displayName = symToStr({ + ConfirmBucketCreationAttemptDialog +}); + +const { i18n } = declareComponentKeys< + | { K: "bucket does not exist title"; P: { bucket: string } } + | "bucket does not exist body" + | "no" + | "yes" + | "success title" + | "failed title" + | { K: "success body"; P: { bucket: string } } + | { K: "failed body"; P: { bucket: string } } + | "ok" +>()({ ConfirmBucketCreationAttemptDialog }); + +export type I18n = typeof i18n; diff --git a/web/src/ui/pages/s3Explorer/Explorer.tsx b/web/src/ui/pages/s3Explorer/Explorer.tsx new file mode 100644 index 000000000..416755d14 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/Explorer.tsx @@ -0,0 +1,301 @@ +import { useEffect } from "react"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import { copyToClipboard } from "ui/tools/copyToClipboard"; +import { useCoreState, getCoreSync } from "core"; +import { + Explorer as HeadlessExplorer, + type ExplorerProps as HeadlessExplorerProps +} from "../fileExplorer/Explorer"; +import { routes } from "ui/routes"; +import { Evt } from "evt"; +import type { Param0 } from "tsafe"; +import { useConst } from "powerhooks/useConst"; +import { assert, type Equals } from "tsafe/assert"; +import { triggerBrowserDownload } from "ui/tools/triggerBrowserDonwload"; +import CircularProgress from "@mui/material/CircularProgress"; +import { Text } from "onyxia-ui/Text"; +import { Button } from "onyxia-ui/Button"; +import { useStyles } from "tss"; +import { getIconUrlByName } from "lazy-icons"; +import { + ConfirmBucketCreationAttemptDialog, + type ConfirmBucketCreationAttemptDialogProps +} from "./ConfirmBucketCreationAttemptDialog"; +import { useEvt } from "evt/hooks"; + +type Props = { + className?: string; + directoryPath: string; + changeCurrentDirectory: (params: { directoryPath: string }) => void; + bookmarkStatus: + | { + isBookmarked: false; + } + | { + isBookmarked: true; + isReadonly: boolean; + }; + onToggleIsDirectoryPathBookmarked: () => void; +}; + +export function Explorer(props: Props) { + const { + evts: { evtFileExplorer } + } = getCoreSync(); + + const evtConfirmBucketCreationAttemptDialogOpen = useConst(() => + Evt.create() + ); + + useEvt(ctx => { + evtFileExplorer.pipe(ctx).attach( + data => data.action === "ask confirmation for bucket creation attempt", + ({ bucket, createBucket }) => + evtConfirmBucketCreationAttemptDialogOpen.post({ + bucket, + createBucket + }) + ); + }, []); + + return ( + <> + + + + ); +} + +function Explorer_inner(props: Props) { + const { + className, + directoryPath, + changeCurrentDirectory, + bookmarkStatus, + onToggleIsDirectoryPathBookmarked + } = props; + + const { + isCurrentWorkingDirectoryLoaded, + navigationError, + commandLogsEntries, + isNavigationOngoing, + uploadProgress, + currentWorkingDirectoryView, + pathMinDepth, + viewMode, + shareView, + isDownloadPreparing + } = useCoreState("fileExplorer", "main"); + + const evtIsSnackbarOpen = useConst(() => Evt.create(isDownloadPreparing)); + + useEffect(() => { + evtIsSnackbarOpen.state = isDownloadPreparing; + }, [isDownloadPreparing]); + + const { + functions: { fileExplorer } + } = getCoreSync(); + + useEffect(() => { + fileExplorer.changeCurrentDirectory({ + directoryPath + }); + }, [directoryPath]); + + const onRefresh = useConstCallback(() => fileExplorer.refreshCurrentDirectory()); + + const onCreateNewEmptyDirectory = useConstCallback( + ({ basename }: Param0) => + fileExplorer.createNewEmptyDirectory({ + basename + }) + ); + + const onDownloadItems = useConstCallback( + async (params: Param0) => { + const { items } = params; + + const { url, filename } = await fileExplorer.getBlobUrl({ + s3Objects: items + }); + + triggerBrowserDownload({ url, filename }); + } + ); + + const onDeleteItems = useConstCallback( + (params: Param0) => + fileExplorer.bulkDelete({ + s3Objects: params.items + }) + ); + + const onCopyPath = useConstCallback( + ({ path }: Param0) => { + assert(currentWorkingDirectoryView !== undefined); + return copyToClipboard( + path.split(currentWorkingDirectoryView.directoryPath.split("/")[0])[1] //get the path to object without + ); + } + ); + + const evtExplorerAction = useConst(() => + Evt.create() + ); + + const onOpenFile = useConstCallback( + ({ basename }) => { + //TODO use dataExplorer thunk + if ( + basename.endsWith(".parquet") || + basename.endsWith(".csv") || + basename.endsWith(".json") + ) { + routes + .dataExplorer({ + source: `s3://${directoryPath.replace(/\/$/g, "")}/${basename}` + }) + .push(); + return; + } + + fileExplorer.getFileDownloadUrl({ basename }).then(window.open); + } + ); + + const onRequestFilesUpload = useConstCallback< + HeadlessExplorerProps["onRequestFilesUpload"] + >(({ files }) => + fileExplorer.uploadFiles({ + files + }) + ); + + const { cx, css, theme } = useStyles(); + + if ( + isCurrentWorkingDirectoryLoaded && + currentWorkingDirectoryView.directoryPath !== directoryPath + ) { + return ( +
+ +
+ ); + } + + if (!isCurrentWorkingDirectoryLoaded) { + return ( +
+ {(() => { + if (navigationError !== undefined) { + return ( +
+ + {(() => { + switch (navigationError.errorCase) { + case "access denied": + return [ + "You do not have read permission on s3://", + navigationError.directoryPath, + "with this S3 Profile" + ].join(" "); + case "no such bucket": + return `The bucket ${navigationError.bucket} does not exist`; + default: + assert< + Equals + >(false); + } + })()} + + +
+ ); + } + + return ; + })()} +
+ ); + } + + return ( + <> + + + ); +} diff --git a/web/src/ui/pages/s3Explorer/Page.tsx b/web/src/ui/pages/s3Explorer/Page.tsx new file mode 100644 index 000000000..f2546508b --- /dev/null +++ b/web/src/ui/pages/s3Explorer/Page.tsx @@ -0,0 +1,440 @@ +import { useState, useMemo } from "react"; +import { useEffect } from "react"; +import { routes, getRoute, session } from "ui/routes"; +import { routeGroup } from "./route"; +import { assert } from "tsafe/assert"; +import { withLoader } from "ui/tools/withLoader"; +import { enforceLogin } from "ui/shared/enforceLogin"; +import { getCore, useCoreState, getCoreSync } from "core"; +import InputLabel from "@mui/material/InputLabel"; +import MenuItem from "@mui/material/MenuItem"; +import FormControl from "@mui/material/FormControl"; +import Select from "@mui/material/Select"; +import { Explorer } from "./Explorer"; +import { tss } from "tss"; +import { useEvt } from "evt/hooks"; +import { Text } from "onyxia-ui/Text"; +import MuiLink from "@mui/material/Link"; +import { SearchBar } from "onyxia-ui/SearchBar"; +import { parseS3UriPrefix, stringifyS3UriPrefixObj } from "core/tools/S3Uri"; +import { useResolveLocalizedString } from "ui/i18n"; +import { Icon } from "onyxia-ui/Icon"; +import { getIconUrlByName } from "lazy-icons"; +import { S3ConfigDialogs, type S3ConfigDialogsProps } from "./S3ConfigDialogs"; +import { useConst } from "powerhooks/useConst"; +import { Evt, type UnpackEvt } from "evt"; +import { + MaybeAcknowledgeConfigVolatilityDialog, + type MaybeAcknowledgeConfigVolatilityDialogProps +} from "ui/shared/MaybeAcknowledgeConfigVolatilityDialog"; +import { Deferred } from "evt/tools/Deferred"; +import { Button } from "onyxia-ui/Button"; + +const Page = withLoader({ + loader: async () => { + await enforceLogin(); + + const core = await getCore(); + + const route = getRoute(); + assert(routeGroup.has(route)); + + const { routeParams_toSet } = core.functions.s3ExplorerRootUiController.load({ + routeParams: route.params + }); + + if (routeParams_toSet !== undefined) { + routes.s3Explorer(routeParams_toSet).replace(); + } + }, + Component: S3Explorer +}); +export default Page; + +function S3Explorer() { + const { + functions: { s3ExplorerRootUiController }, + evts: { evtS3ExplorerRootUiController } + } = getCoreSync(); + + const { selectedS3ProfileId, s3UriPrefixObj, bookmarkStatus } = useCoreState( + "s3ExplorerRootUiController", + "view" + ); + + const { classes, css, theme } = useStyles(); + + useEvt(ctx => { + evtS3ExplorerRootUiController + .pipe(ctx) + .pipe(action => action.actionName === "updateRoute") + .attach(({ routeParams, method }) => + routes.s3Explorer(routeParams)[method]() + ); + }, []); + + useEffect( + () => + session.listen(route => { + if (routeGroup.has(route)) { + s3ExplorerRootUiController.notifyRouteParamsExternallyUpdated({ + routeParams: route.params + }); + } + }), + [] + ); + + return ( +
+
+ + +
+ {/* Not conditionally mounted to track state */} + + + {(() => { + if (selectedS3ProfileId === undefined) { + return

Create a profile

; + } + + if (s3UriPrefixObj === undefined) { + return ; + } + + return ( + { + const s3UriPrefixObj = + directoryPath === "" + ? undefined + : parseS3UriPrefix({ + s3UriPrefix: `s3://${directoryPath}`, + strict: false + }); + + s3ExplorerRootUiController.updateS3Url({ + s3UriPrefixObj + }); + }} + directoryPath={stringifyS3UriPrefixObj(s3UriPrefixObj).slice( + "s3://".length + )} + bookmarkStatus={bookmarkStatus} + onToggleIsDirectoryPathBookmarked={ + s3ExplorerRootUiController.toggleIsDirectoryPathBookmarked + } + /> + ); + })()} +
+ ); +} + +function BookmarkPanel(props: { className?: string }) { + const { className } = props; + + const { resolveLocalizedString } = useResolveLocalizedString(); + + const { bookmarks } = useCoreState("s3ExplorerRootUiController", "view"); + + const { + functions: { s3ExplorerRootUiController } + } = getCoreSync(); + + const { cx, css, theme } = useStyles(); + + return ( +
+ + + Bookmarks + + {bookmarks.map((bookmark, i) => ( +
+ { + e.preventDefault(); + s3ExplorerRootUiController.updateS3Url({ + s3UriPrefixObj: bookmark.s3UriPrefixObj + }); + }} + > + {stringifyS3UriPrefixObj(bookmark.s3UriPrefixObj)} + + + {bookmark.displayName !== undefined && ( + + - {resolveLocalizedString(bookmark.displayName)} + + )} +
+ ))} +
+ ); +} + +function DirectNavigation(props: { className?: string }) { + const { className } = props; + + const { + functions: { s3ExplorerRootUiController } + } = getCoreSync(); + + const { s3UriPrefixObj } = useCoreState("s3ExplorerRootUiController", "view"); + + const search_external = + s3UriPrefixObj === undefined ? "s3://" : stringifyS3UriPrefixObj(s3UriPrefixObj); + + const [search, setSearch] = useState(search_external); + + useEffect(() => { + if (search_external !== "s3://") { + setSearch(search_external); + } + }, [search_external]); + + const s3UriPrefixObj_search = useMemo(() => { + try { + return parseS3UriPrefix({ + s3UriPrefix: search, + strict: false + }); + } catch { + return undefined; + } + }, [search]); + + return ( + { + switch (keyId) { + case "Enter": + { + if (s3UriPrefixObj_search === undefined) { + return; + } + + s3ExplorerRootUiController.updateS3Url({ + s3UriPrefixObj: s3UriPrefixObj_search + }); + } + break; + case "Escape": + setSearch(search_external); + break; + } + }} + /> + ); +} + +function BookmarkBar(props: { className?: string }) { + const { className } = props; + + const { cx, css, theme } = useStyles(); + + const { + functions: { s3ExplorerRootUiController } + } = getCoreSync(); + + const { bookmarks } = useCoreState("s3ExplorerRootUiController", "view"); + + return ( +
+ + Bookmarks + + {bookmarks.map((bookmark, i) => ( + { + e.preventDefault(); + s3ExplorerRootUiController.updateS3Url({ + s3UriPrefixObj: bookmark.s3UriPrefixObj + }); + }} + > + {stringifyS3UriPrefixObj(bookmark.s3UriPrefixObj)} + + ))} +
+ ); +} + +function S3ProfileSelect() { + const { + functions: { s3ExplorerRootUiController } + } = getCoreSync(); + + const { selectedS3ProfileId, selectedS3Profile_creationTime, availableS3Profiles } = + useCoreState("s3ExplorerRootUiController", "view"); + + const { css } = useStyles(); + + const { + evtConfirmCustomS3ConfigDeletionDialogOpen, + evtAddCustomS3ConfigDialogOpen, + evtMaybeAcknowledgeConfigVolatilityDialogOpen + } = useConst(() => ({ + evtConfirmCustomS3ConfigDeletionDialogOpen: + Evt.create< + UnpackEvt< + S3ConfigDialogsProps["evtConfirmCustomS3ConfigDeletionDialogOpen"] + > + >(), + evtAddCustomS3ConfigDialogOpen: + Evt.create< + UnpackEvt + >(), + evtMaybeAcknowledgeConfigVolatilityDialogOpen: + Evt.create() + })); + + return ( + <> + + S3 Profile + + + {selectedS3Profile_creationTime !== undefined && ( + + )} + + + + ); +} + +const useStyles = tss.withName({ S3Explorer }).create(({ theme }) => ({ + root: { + height: "100%" + }, + explorer: { + marginTop: theme.spacing(4) + } +})); diff --git a/web/src/ui/pages/s3Explorer/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx new file mode 100644 index 000000000..c4eac135e --- /dev/null +++ b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/AddCustomS3ConfigDialog.tsx @@ -0,0 +1,417 @@ +import { memo } from "react"; +import { Dialog } from "onyxia-ui/Dialog"; +import { Button } from "onyxia-ui/Button"; +import { symToStr } from "tsafe/symToStr"; +import { useCallbackFactory } from "powerhooks/useCallbackFactory"; +import { type NonPostableEvt } from "evt"; +import { useEvt } from "evt/hooks"; +import { TextField } from "onyxia-ui/TextField"; +import Radio from "@mui/material/Radio"; +import RadioGroup from "@mui/material/RadioGroup"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import FormControl from "@mui/material/FormControl"; +import FormLabel from "@mui/material/FormLabel"; +import FormGroup from "@mui/material/FormGroup"; +import { tss } from "tss"; +import { useCoreState, getCoreSync } from "core"; +import { declareComponentKeys, useTranslation } from "ui/i18n"; +import { Text } from "onyxia-ui/Text"; +import FormHelperText from "@mui/material/FormHelperText"; +import Switch from "@mui/material/Switch"; + +export type AddCustomS3ConfigDialogProps = { + evtOpen: NonPostableEvt<{ + creationTimeOfS3ProfileToEdit: number | undefined; + }>; +}; + +export const AddCustomS3ConfigDialog = memo((props: AddCustomS3ConfigDialogProps) => { + const { evtOpen } = props; + + const { t } = useTranslation({ AddCustomS3ConfigDialog }); + + const { + functions: { s3ProfilesCreationUiController } + } = getCoreSync(); + + const { isReady } = useCoreState("s3ProfilesCreationUiController", "main"); + + useEvt( + ctx => + evtOpen.attach(ctx, ({ creationTimeOfS3ProfileToEdit }) => + s3ProfilesCreationUiController.initialize({ + creationTimeOfS3ProfileToEdit + }) + ), + [evtOpen] + ); + + const onCloseFactory = useCallbackFactory(([isSubmit]: [boolean]) => { + if (isSubmit) { + s3ProfilesCreationUiController.submit(); + } else { + s3ProfilesCreationUiController.reset(); + } + }); + + const { classes } = useStyles(); + + return ( + } + buttons={ + + } + onClose={onCloseFactory(false)} + /> + ); +}); + +AddCustomS3ConfigDialog.displayName = symToStr({ + AddCustomS3ConfigDialog +}); + +const useStyles = tss.withName({ AddCustomS3ConfigDialog }).create({ + buttons: { + display: "flex" + } +}); + +type ButtonsProps = { + onCloseCancel: () => void; + onCloseSubmit: () => void; +}; + +const Buttons = memo((props: ButtonsProps) => { + const { onCloseCancel, onCloseSubmit } = props; + + const { isReady, isFormSubmittable, isEditionOfAnExistingConfig } = useCoreState( + "s3ProfilesCreationUiController", + "main" + ); + + const { css } = useButtonsStyles(); + + const { t } = useTranslation({ AddCustomS3ConfigDialog }); + + if (!isReady) { + return null; + } + + return ( + <> +
+ + + + ); +}); + +const useButtonsStyles = tss + .withName(`${symToStr({ AddCustomS3ConfigDialog })}${symToStr({ Buttons })}`) + .create({}); + +const Body = memo(() => { + const { isReady, formValues, formValuesErrors, urlStylesExamples } = useCoreState( + "s3ProfilesCreationUiController", + "main" + ); + + const { + functions: { s3ConfigCreation } + } = getCoreSync(); + + const { classes, css, theme } = useBodyStyles(); + + const { t } = useTranslation({ AddCustomS3ConfigDialog }); + + if (!isReady) { + return null; + } + + return ( + <> + + + s3ConfigCreation.changeValue({ + key: "friendlyName", + value + }) + } + /> + + s3ConfigCreation.changeValue({ + key: "url", + value + }) + } + /> + + s3ConfigCreation.changeValue({ + key: "region", + value + }) + } + /> + + {t("url style")} + + {t("url style helper text")} + + + s3ConfigCreation.changeValue({ + key: "pathStyleAccess", + value: value === "path" + }) + } + > + } + label={t("path style label", { + example: urlStylesExamples?.pathStyle + })} + /> + } + label={t("virtual-hosted style label", { + example: urlStylesExamples?.virtualHostedStyle + })} + /> + + + + + + {t("account credentials")} + + + + + s3ConfigCreation.changeValue({ + key: "isAnonymous", + value: isChecked + }) + } + /> + } + label={t("isAnonymous switch label")} + /> + + {t("isAnonymous switch helper text")} + + {!formValues.isAnonymous && ( + <> + + s3ConfigCreation.changeValue({ + key: "accessKeyId", + value: value || undefined + }) + } + /> + + s3ConfigCreation.changeValue({ + key: "secretAccessKey", + value: value || undefined + }) + } + /> + + s3ConfigCreation.changeValue({ + key: "sessionToken", + value: value || undefined + }) + } + /> + + )} + + + ); +}); + +const useBodyStyles = tss + .withName(`${symToStr({ AddCustomS3ConfigDialog })}${symToStr({ Body })}`) + .create(({ theme }) => ({ + serverConfigFormGroup: { + display: "flex", + flexDirection: "column", + overflow: "visible", + gap: theme.spacing(6), + marginBottom: theme.spacing(4) + }, + accountCredentialsFormGroup: { + borderRadius: 5, + padding: theme.spacing(3), + + backgroundColor: theme.colors.useCases.surfaces.surface1, + boxShadow: theme.shadows[3], + "&:hover": { + boxShadow: theme.shadows[6] + } + } + })); + +const { i18n } = declareComponentKeys< + | "dialog title" + | "dialog subtitle" + | "cancel" + | "save config" + | "update config" + | "is required" + | "must be an url" + | "not a valid access key id" + | "url textField label" + | "url textField helper text" + | "region textField label" + | "region textField helper text" + | "workingDirectoryPath textField label" + | { + K: "workingDirectoryPath textField helper text"; + R: JSX.Element; + } + | "account credentials" + | "friendlyName textField label" + | "friendlyName textField helper text" + | "isAnonymous switch label" + | "isAnonymous switch helper text" + | "accessKeyId textField label" + | "accessKeyId textField helper text" + | "secretAccessKey textField label" + | "sessionToken textField label" + | "sessionToken textField helper text" + | "url style" + | "url style helper text" + | { + K: "path style label"; + P: { example: string | undefined }; + R: JSX.Element; + } + | { + K: "virtual-hosted style label"; + P: { example: string | undefined }; + R: JSX.Element; + } +>()({ AddCustomS3ConfigDialog }); +export type I18n = typeof i18n; diff --git a/web/src/ui/pages/s3Explorer/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog.tsx b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog.tsx new file mode 100644 index 000000000..b3295584b --- /dev/null +++ b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/ConfirmCustomS3ConfigDeletionDialog.tsx @@ -0,0 +1,59 @@ +import { useState, memo } from "react"; +import { Dialog } from "onyxia-ui/Dialog"; +import { Button } from "onyxia-ui/Button"; +import { symToStr } from "tsafe/symToStr"; +import { useCallbackFactory } from "powerhooks/useCallbackFactory"; +import { assert } from "tsafe/assert"; +import type { NonPostableEvt, UnpackEvt } from "evt"; +import { useEvt } from "evt/hooks"; + +export type Props = { + evtOpen: NonPostableEvt<{ + resolveDoProceed: (doProceed: boolean) => void; + }>; +}; + +export const ConfirmCustomS3ConfigDeletionDialog = memo((props: Props) => { + const { evtOpen } = props; + + const [state, setState] = useState | undefined>( + undefined + ); + + useEvt( + ctx => { + evtOpen.attach(ctx, ({ resolveDoProceed }) => setState({ resolveDoProceed })); + }, + [evtOpen] + ); + + const onCloseFactory = useCallbackFactory(([doProceed]: [boolean]) => { + assert(state !== undefined); + + state.resolveDoProceed(doProceed); + + setState(undefined); + }); + + return ( + + + + + } + isOpen={state !== undefined} + onClose={onCloseFactory(false)} + /> + ); +}); + +ConfirmCustomS3ConfigDeletionDialog.displayName = symToStr({ + ConfirmCustomS3ConfigDeletionDialog +}); diff --git a/web/src/ui/pages/s3Explorer/S3ConfigDialogs/S3ConfigDialogs.tsx b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/S3ConfigDialogs.tsx new file mode 100644 index 000000000..6580ca930 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/S3ConfigDialogs.tsx @@ -0,0 +1,27 @@ +import { + ConfirmCustomS3ConfigDeletionDialog, + type Props as ConfirmCustomS3ConfigDeletionDialogProps +} from "./ConfirmCustomS3ConfigDeletionDialog"; +import { + AddCustomS3ConfigDialog, + type AddCustomS3ConfigDialogProps +} from "./AddCustomS3ConfigDialog"; + +export type S3ConfigDialogsProps = { + evtConfirmCustomS3ConfigDeletionDialogOpen: ConfirmCustomS3ConfigDeletionDialogProps["evtOpen"]; + evtAddCustomS3ConfigDialogOpen: AddCustomS3ConfigDialogProps["evtOpen"]; +}; + +export function S3ConfigDialogs(props: S3ConfigDialogsProps) { + const { evtConfirmCustomS3ConfigDeletionDialogOpen, evtAddCustomS3ConfigDialogOpen } = + props; + + return ( + <> + + + + ); +} diff --git a/web/src/ui/pages/s3Explorer/S3ConfigDialogs/index.ts b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/index.ts new file mode 100644 index 000000000..ca3ec4149 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/S3ConfigDialogs/index.ts @@ -0,0 +1 @@ +export * from "./S3ConfigDialogs"; diff --git a/web/src/ui/pages/s3Explorer/index.ts b/web/src/ui/pages/s3Explorer/index.ts new file mode 100644 index 000000000..9cf4bc637 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/index.ts @@ -0,0 +1,3 @@ +import { lazy, memo } from "react"; +export * from "./route"; +export const LazyComponent = memo(lazy(() => import("./Page"))); diff --git a/web/src/ui/pages/s3Explorer/route.ts b/web/src/ui/pages/s3Explorer/route.ts new file mode 100644 index 000000000..920d31076 --- /dev/null +++ b/web/src/ui/pages/s3Explorer/route.ts @@ -0,0 +1,23 @@ +import { defineRoute, createGroup, param } from "type-route"; + +export const routeDefs = { + s3Explorer: defineRoute( + { + path: param.path.trailing.ofType({ + parse: raw => decodeURIComponent(raw), // decode the path + stringify: value => encodeURI(value) // encode when creating URL + }), + profile: param.query.optional.string + }, + ({ path }) => `/s3/${path}` + ), + s3Explorer_root: defineRoute( + { + path: param.query.optional.string.default(""), + profile: param.query.optional.string + }, + () => "/s3" + ) +}; + +export const routeGroup = createGroup(routeDefs); diff --git a/web/src/ui/tools/withLoader.tsx b/web/src/ui/tools/withLoader.tsx index 95c306b5a..e80a59bc6 100644 --- a/web/src/ui/tools/withLoader.tsx +++ b/web/src/ui/tools/withLoader.tsx @@ -3,7 +3,7 @@ import { use } from "./use"; import { assert } from "tsafe"; export function withLoader>(params: { - loader: () => Promise; + loader: (props: NoInfer) => Promise; Component: ComponentType; FallbackComponent?: ComponentType; }): FC { @@ -15,13 +15,13 @@ export function withLoader>(params: { const [isLoaded, setIsLoaded] = useState(false); if (prLoaded === undefined) { - prLoaded = loader(); + prLoaded = loader(props); } useEffect(() => { let isActive = true; - (prLoaded ??= loader()).then(() => { + (prLoaded ??= loader(props)).then(() => { if (!isActive) { return; } @@ -52,7 +52,7 @@ export function withLoader>(params: { use( (prLoaded ??= (async () => { await Promise.resolve(); - await loader(); + await loader(props); await Promise.resolve(); })()) ); diff --git a/web/yarn.lock b/web/yarn.lock index e21bf7c78..dc54c6563 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -4442,10 +4442,10 @@ ci-info@^3.7.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== -clean-architecture@^6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/clean-architecture/-/clean-architecture-6.0.3.tgz#315f957b90fe1a8dcdf6034a5a90147a1794fb3b" - integrity sha512-7PijjDvBpT9a1WeGTCJGbFnMy0a2RiJ8AN4rCXZX7ZtnJGz4505Km0JT1o8eoJggE15CLMtcy9JmAvqA271hNQ== +clean-architecture@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/clean-architecture/-/clean-architecture-6.1.0.tgz#54611a318c8438fc1a3be9cc53e6547a5f77b44c" + integrity sha512-PsBANUzQ+cIjsLD/UXmgsi1017RqvPxh2sJM0PGFhjf0lfNkKioCpZhmm0oLBXnditq3bAa9AVOHxT6oU6qjng== dependencies: "@reduxjs/toolkit" "1.9.7" minimal-polyfills "^2.2.3" @@ -8097,6 +8097,11 @@ tsafe@^1.8.10: resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.8.10.tgz#98e28f0beeca4e6632a8481a300fa85dd92826c0" integrity sha512-2bBiNHk6Ts4LZQ4+6OxF/BtkJ8YWqo1VMbMo6qrRIZoqAwM8xuwWUx9g3C/p6cCdUmNWeOWIaiJzgO5zWy1Cdg== +tsafe@^1.8.12: + version "1.8.12" + resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.8.12.tgz#68a410b4b7687ef497b1c85904b1215532335c3e" + integrity sha512-nFRqW0ttu/2o6XTXsHiVZWJBCOaxhVqZLg7dgs3coZNsCMPXPfwz+zPHAQA+70fNnVJLAPg1EgGIqK9Q84tvAw== + tsafe@^1.8.5: version "1.8.5" resolved "https://registry.yarnpkg.com/tsafe/-/tsafe-1.8.5.tgz#cdf9fa3111974ac480d7ee519f8241815e5d22ea"