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