diff --git a/config/sample-databases/DefaultConfiguration/cloudbeaver.conf b/config/sample-databases/DefaultConfiguration/cloudbeaver.conf index f65b0eee16..4102f85217 100644 --- a/config/sample-databases/DefaultConfiguration/cloudbeaver.conf +++ b/config/sample-databases/DefaultConfiguration/cloudbeaver.conf @@ -23,7 +23,13 @@ enableBruteForceProtection: "${CLOUDBEAVER_BRUTE_FORCE_PROTECTION_ENABLED:true}", maxFailedLogin: "${CLOUDBEAVER_MAX_FAILED_LOGINS:10}", minimumLoginTimeout: "${CLOUDBEAVER_MINIMUM_LOGIN_TIMEOUT:1}", - blockLoginPeriod: "${CLOUDBEAVER_BLOCK_PERIOD:300}" + blockLoginPeriod: "${CLOUDBEAVER_BLOCK_PERIOD:300}", + passwordPolicy: { + minLength: "${CLOUDBEAVER_POLICY_MIN_LENGTH:8}", + requireMixedCase: "${CLOUDBEAVER_POLICY_REQUIRE_MIXED_CASE:true}", + minNumberCount: "${CLOUDBEAVER_POLICY_MIN_NUMBER_COUNT:1}", + minSymbolCount: "${CLOUDBEAVER_POLICY_MIN_SYMBOL_COUNT:0}" + } }, database: { diff --git a/config/sample-databases/SQLiteConfiguration/cloudbeaver.conf b/config/sample-databases/SQLiteConfiguration/cloudbeaver.conf index 67427c38c1..aceb52c885 100644 --- a/config/sample-databases/SQLiteConfiguration/cloudbeaver.conf +++ b/config/sample-databases/SQLiteConfiguration/cloudbeaver.conf @@ -27,6 +27,18 @@ maxConnections: 100, validationQuery: "SELECT 1" } + }, + sm: { + enableBruteForceProtection: "${CLOUDBEAVER_BRUTE_FORCE_PROTECTION_ENABLED:true}", + maxFailedLogin: "${CLOUDBEAVER_MAX_FAILED_LOGINS:10}", + minimumLoginTimeout: "${CLOUDBEAVER_MINIMUM_LOGIN_TIMEOUT:1}", + blockLoginPeriod: "${CLOUDBEAVER_BLOCK_PERIOD:300}", + passwordPolicy: { + minLength: "${CLOUDBEAVER_POLICY_MIN_LENGTH:8}", + requireMixedCase: "${CLOUDBEAVER_POLICY_REQUIRE_MIXED_CASE:true}", + minNumberCount: "${CLOUDBEAVER_POLICY_MIN_NUMBER_COUNT:1}", + minSymbolCount: "${CLOUDBEAVER_POLICY_MIN_SYMBOL_COUNT:0}" + } } }, diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMAuthProviderFederated.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMAuthProviderFederated.java index 1747122729..1ec4d407ed 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMAuthProviderFederated.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/auth/SMAuthProviderFederated.java @@ -19,6 +19,7 @@ import org.jkiss.code.NotNull; import org.jkiss.code.Nullable; import org.jkiss.dbeaver.DBException; +import org.jkiss.dbeaver.model.security.SMAuthProviderCustomConfiguration; import java.util.Map; @@ -28,15 +29,21 @@ */ public interface SMAuthProviderFederated { - /** - * Returns new identifying credentials which can be used to find/create user in database - */ @NotNull String getSignInLink(String id, @NotNull Map providerConfig) throws DBException; - + /** + * @return a common link for logout, not related with the user context + */ @NotNull - String getSignOutLink(String id, @NotNull Map providerConfig) throws DBException; + String getCommonSignOutLink(String id, @NotNull Map providerConfig) throws DBException; + + default String getUserSignOutLink( + @NotNull SMAuthProviderCustomConfiguration providerConfig, + @NotNull Map userCredentials + ) throws DBException { + return getCommonSignOutLink(providerConfig.getId(), providerConfig.getParameters()); + } @Nullable String getMetadataLink(String id, @NotNull Map providerConfig) throws DBException; diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/rm/local/LocalResourceController.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/rm/local/LocalResourceController.java index 366c80c3cb..1a20c860cc 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/rm/local/LocalResourceController.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/rm/local/LocalResourceController.java @@ -1155,6 +1155,11 @@ public static Builder builder( return new Builder(workspace, credentialsProvider, smControllerSupplier); } + @Override + public String ping() { + return "pong (RM)"; + } + public static final class Builder { private final SMCredentialsProvider credentialsProvider; private final Supplier smController; diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebAuthInfo.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebAuthInfo.java index 0d2abe7549..5c178215d9 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebAuthInfo.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebAuthInfo.java @@ -131,8 +131,6 @@ void closeAuth() { authProviderInstance.closeSession(session, authSession); } catch (Exception e) { log.error(e); - } finally { - authSession = null; } } } diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebSession.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebSession.java index fbff2dbbda..d975fbe207 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebSession.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/model/session/WebSession.java @@ -614,7 +614,7 @@ public void close() { super.close(); } - private void clearAuthTokens() throws DBException { + private List clearAuthTokens() throws DBException { ArrayList tokensCopy; synchronized (authTokens) { tokensCopy = new ArrayList<>(this.authTokens); @@ -623,6 +623,7 @@ private void clearAuthTokens() throws DBException { removeAuthInfo(ai); } resetAuthToken(); + return tokensCopy; } public DBRProgressMonitor getProgressMonitor() { @@ -873,18 +874,23 @@ private void removeAuthInfo(WebAuthInfo oldAuthInfo) { } } - public void removeAuthInfo(String providerId) throws DBException { + public List removeAuthInfo(String providerId) throws DBException { + List oldInfo; if (providerId == null) { - clearAuthTokens(); + oldInfo = clearAuthTokens(); } else { WebAuthInfo authInfo = getAuthInfo(providerId); if (authInfo != null) { removeAuthInfo(authInfo); + oldInfo = List.of(authInfo); + } else { + oldInfo = List.of(); } } if (authTokens.isEmpty()) { resetUserState(); } + return oldInfo; } public List getContextCredentialsProviders() { @@ -921,7 +927,7 @@ public boolean provideAuthParameters( .create(); credGson.fromJson(credGson.toJsonTree(configuration.getAuthProperties()), credentials.getClass()); - configuration.getAuthModel().saveCredentials(dataSourceContainer, configuration, credentials); + configuration.getAuthModel().provideCredentials(dataSourceContainer, configuration, credentials); } catch (DBException e) { addSessionError(e); log.error(e); diff --git a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/registry/WebAuthProviderConfiguration.java b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/registry/WebAuthProviderConfiguration.java index 266382b8cc..46712a0a59 100644 --- a/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/registry/WebAuthProviderConfiguration.java +++ b/server/bundles/io.cloudbeaver.model/src/io/cloudbeaver/registry/WebAuthProviderConfiguration.java @@ -86,7 +86,7 @@ private String buildRedirectUrl(String baseUrl) { public String getSignOutLink() throws DBException { SMAuthProvider instance = providerDescriptor.getInstance(); return instance instanceof SMAuthProviderFederated - ? ((SMAuthProviderFederated) instance).getSignOutLink(getId(), config.getParameters()) + ? ((SMAuthProviderFederated) instance).getCommonSignOutLink(getId(), config.getParameters()) : null; } diff --git a/server/bundles/io.cloudbeaver.server/schema/service.core.graphqls b/server/bundles/io.cloudbeaver.server/schema/service.core.graphqls index 0e998720b4..0b2124d5e9 100644 --- a/server/bundles/io.cloudbeaver.server/schema/service.core.graphqls +++ b/server/bundles/io.cloudbeaver.server/schema/service.core.graphqls @@ -99,6 +99,13 @@ type WebServiceConfig { bundleVersion: String! } +type PasswordPolicyConfig @since(version: "23.3.3") { + minLength: Int! + minNumberCount: Int! + minSymbolCount: Int! + requireMixedCase: Boolean! +} + type ProductInfo { id: ID! version: String! @@ -152,6 +159,7 @@ type ServerConfig { defaultNavigatorSettings: NavigatorSettings! disabledDrivers: [ID!]! resourceQuotas: Object! + passwordPolicyConfiguration: PasswordPolicyConfig! @since(version: "23.3.3") } type SessionInfo { diff --git a/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls b/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls index 137cadbdf2..e4db4689e6 100644 --- a/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls +++ b/server/bundles/io.cloudbeaver.server/schema/service.sql.graphqls @@ -331,7 +331,8 @@ extend type Mutation { addedRows: [ SQLResultRow! ], ): String! - #Return BLOB name + # Returns BLOB name + @deprecated(reason: "23.3.3") # use sqlReadLobValue readLobValue( projectId: ID, connectionId: ID!, @@ -341,6 +342,27 @@ extend type Mutation { row: [ SQLResultRow! ]! ): String! + @since(version: "23.3.3") + sqlReadLobValue( + projectId: ID, + connectionId: ID!, + contextId: ID!, + resultsId: ID!, + lobColumnIndex: Int!, + row: SQLResultRow! + ): String! + + # Returns full string value ignoring any limits + @since(version: "23.3.3") + sqlReadStringValue( + projectId: ID, + connectionId: ID!, + contextId: ID!, + resultsId: ID!, + columnIndex: Int!, + row: SQLResultRow! + ): String! + # Returns SQLExecuteInfo asyncSqlExecuteResults(taskId: ID!): SQLExecuteInfo ! diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebServerConfig.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebServerConfig.java index bc2eda8600..ea429a9529 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebServerConfig.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/model/WebServerConfig.java @@ -20,6 +20,7 @@ import io.cloudbeaver.registry.WebServiceRegistry; import io.cloudbeaver.server.CBApplication; import io.cloudbeaver.server.CBPlatform; +import io.cloudbeaver.service.security.PasswordPolicyConfiguration; import org.jkiss.dbeaver.model.meta.Property; import org.jkiss.dbeaver.model.navigator.DBNBrowseSettings; import org.jkiss.dbeaver.registry.language.PlatformLanguageDescriptor; @@ -217,4 +218,9 @@ public String getDefaultAuthRole() { public String getDefaultUserTeam() { return application.getAppConfiguration().getDefaultUserTeam(); } + + @Property + public PasswordPolicyConfiguration getPasswordPolicyConfiguration() { + return application.getSecurityManagerConfiguration().getPasswordPolicyConfiguration(); + } } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBApplication.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBApplication.java index b38bf4deb6..b931441a47 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBApplication.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBApplication.java @@ -22,6 +22,7 @@ import io.cloudbeaver.WebServiceUtils; import io.cloudbeaver.auth.CBAuthConstants; import io.cloudbeaver.auth.NoAuthCredentialsProvider; +import io.cloudbeaver.service.security.PasswordPolicyConfiguration; import io.cloudbeaver.model.app.BaseWebApplication; import io.cloudbeaver.model.app.WebAuthApplication; import io.cloudbeaver.model.app.WebAuthConfiguration; @@ -231,6 +232,10 @@ public Map getProductConfiguration() { return productConfiguration; } + public SMControllerConfiguration getSecurityManagerConfiguration() { + return securityManagerConfiguration; + } + public SMAdminController getSecurityController() { return securityController; } @@ -515,6 +520,7 @@ private void initializeSecurityController() throws DBException { @Nullable @Override protected Path loadServerConfiguration() throws DBException { + initHomeFolder(); Path path = super.loadServerConfiguration(); @@ -548,8 +554,7 @@ private void parseConfiguration(File configFile) throws DBException { } protected void parseConfiguration(Map configProps) throws DBException { - String homeFolder = initHomeFolder(); - + Path homeFolder = getHomeDirectory(); CBAppConfig prevConfig = new CBAppConfig(appConfiguration); Gson gson = getGson(); try { @@ -592,7 +597,7 @@ protected void parseConfiguration(Map configProps) throws DBExce enableSecurityManager); //SM config gson.fromJson( - gson.toJsonTree(JSONUtils.getObject(serverConfig, CBConstants.PARAM_SM_CONFIGURATION)), + gson.toJson(JSONUtils.getObject(serverConfig, CBConstants.PARAM_SM_CONFIGURATION)), SMControllerConfiguration.class ); // App config @@ -602,7 +607,7 @@ protected void parseConfiguration(Map configProps) throws DBExce databaseConfiguration.putAll(JSONUtils.getObject(serverConfig, CBConstants.PARAM_DB_CONFIGURATION)); - readProductConfiguration(serverConfig, gson, homeFolder); + readProductConfiguration(serverConfig, gson, homeFolder.toString()); String staticContentsFile = JSONUtils.getString(serverConfig, CBConstants.PARAM_STATIC_CONTENT); if (!CommonUtils.isEmpty(staticContentsFile)) { @@ -772,11 +777,14 @@ protected GsonBuilder getGsonBuilder() { InstanceCreator appConfigCreator = type -> appConfiguration; InstanceCreator navSettingsCreator = type -> (DataSourceNavigatorSettings) appConfiguration.getDefaultNavigatorSettings(); InstanceCreator smConfigCreator = type -> securityManagerConfiguration; + InstanceCreator smPasswordPoliceConfigCreator = + type -> securityManagerConfiguration.getPasswordPolicyConfiguration(); return new GsonBuilder() .setLenient() .registerTypeAdapter(CBAppConfig.class, appConfigCreator) .registerTypeAdapter(DataSourceNavigatorSettings.class, navSettingsCreator) - .registerTypeAdapter(SMControllerConfiguration.class, smConfigCreator); + .registerTypeAdapter(SMControllerConfiguration.class, smConfigCreator) + .registerTypeAdapter(PasswordPolicyConfiguration.class, smPasswordPoliceConfigCreator); } protected void readAdditionalConfiguration(Map rootConfig) throws DBException { @@ -1042,6 +1050,7 @@ protected Map collectConfigurationProperties( } serverConfigProperties.put(CBConstants.PARAM_DB_CONFIGURATION, databaseConfigProperties); } + savePasswordPolicyConfig(originServerConfig, serverConfigProperties); } { var appConfigProperties = new LinkedHashMap(); @@ -1151,6 +1160,30 @@ protected Map collectConfigurationProperties( return rootConfig; } + private void savePasswordPolicyConfig(Map originServerConfig, LinkedHashMap serverConfigProperties) { + // save password policy configuration + var passwordPolicyProperties = new LinkedHashMap(); + + var oldRuntimePasswordPolicyConfig = JSONUtils.getObject( + JSONUtils.getObject(originServerConfig, CBConstants.PARAM_SM_CONFIGURATION), + CBConstants.PARAM_PASSWORD_POLICY_CONFIGURATION + ); + Gson gson = getGson(); + Map passwordPolicyConfig = gson.fromJson( + gson.toJsonTree(securityManagerConfiguration.getPasswordPolicyConfiguration()), + JSONUtils.MAP_TYPE_TOKEN + ); + if (!CommonUtils.isEmpty(passwordPolicyConfig) && !isDistributed()) { + for (Map.Entry mp : passwordPolicyConfig.entrySet()) { + copyConfigValue(oldRuntimePasswordPolicyConfig, passwordPolicyProperties, mp.getKey(), mp.getValue()); + } + serverConfigProperties.put( + CBConstants.PARAM_SM_CONFIGURATION, + Map.of(CBConstants.PARAM_PASSWORD_POLICY_CONFIGURATION, passwordPolicyProperties) + ); + } + } + //////////////////////////////////////////////////////////////////////// // License management diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBConstants.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBConstants.java index 9eb64d24c0..7e35aba999 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBConstants.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/server/CBConstants.java @@ -50,6 +50,7 @@ public class CBConstants { public static final String PARAM_DEVEL_MODE = "develMode"; public static final String PARAM_SECURITY_MANAGER = "enableSecurityManager"; public static final String PARAM_SM_CONFIGURATION = "sm"; + public static final String PARAM_PASSWORD_POLICY_CONFIGURATION = "passwordPolicy"; public static final int DEFAULT_SERVER_PORT = 8080; //public static final String DEFAULT_SERVER_NAME = "CloudBeaver Web Server"; diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/DBWServiceSQL.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/DBWServiceSQL.java index 37c72fe2d2..3dcb4b5f64 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/DBWServiceSQL.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/DBWServiceSQL.java @@ -119,10 +119,18 @@ WebSQLExecuteInfo updateResultsDataBatch( @WebAction String readLobValue( - @NotNull WebSQLContextInfo contextInfo, - @NotNull String resultsId, - @NotNull Integer lobColumnIndex, - @Nullable List row) throws DBWebException; + @NotNull WebSQLContextInfo contextInfo, + @NotNull String resultsId, + @NotNull Integer lobColumnIndex, + @NotNull WebSQLResultsRow row) throws DBWebException; + + @NotNull + @WebAction + String getCellValue( + @NotNull WebSQLContextInfo contextInfo, + @NotNull String resultsId, + @NotNull Integer lobColumnIndex, + @NotNull WebSQLResultsRow row) throws DBWebException; @WebAction String updateResultsDataBatchScript( diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLCellValueReceiver.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLCellValueReceiver.java new file mode 100644 index 0000000000..8fafb7a38b --- /dev/null +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLCellValueReceiver.java @@ -0,0 +1,85 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.sql; + +import org.jkiss.code.NotNull; +import org.jkiss.dbeaver.model.data.DBDAttributeBindingMeta; +import org.jkiss.dbeaver.model.data.DBDContent; +import org.jkiss.dbeaver.model.data.DBDDataReceiver; +import org.jkiss.dbeaver.model.data.DBDValue; +import org.jkiss.dbeaver.model.exec.*; +import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor; +import org.jkiss.dbeaver.model.struct.DBSDataContainer; +import org.jkiss.dbeaver.utils.ContentUtils; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +public class WebSQLCellValueReceiver implements DBDDataReceiver { + protected final DBSDataContainer dataContainer; + protected int rowIndex; + protected DBDAttributeBindingMeta binding; + protected Object value; + + public WebSQLCellValueReceiver(DBSDataContainer dataContainer, int rowIndex) { + this.dataContainer = dataContainer; + this.rowIndex = rowIndex; + } + + @Override + public void fetchStart(DBCSession session, DBCResultSet resultSet, long offset, long maxRows) throws DBCException { + DBCResultSetMetaData meta = resultSet.getMeta(); + List attributes = meta.getAttributes(); + DBCAttributeMetaData attrMeta = attributes.get(rowIndex); + binding = new DBDAttributeBindingMeta(dataContainer, resultSet.getSession(), attrMeta); + } + + @Override + public void fetchRow(DBCSession session, DBCResultSet resultSet) throws DBCException { + value = binding.getValueHandler().fetchValueObject( + resultSet.getSession(), + resultSet, + binding.getMetaAttribute(), + rowIndex); + } + + @Override + public void fetchEnd(DBCSession session, DBCResultSet resultSet) throws DBCException { + + } + + @Override + public void close() { + + } + + @NotNull + public byte[] getBinaryValue(DBRProgressMonitor monitor) throws DBCException { + byte[] binaryValue; + if (value instanceof DBDContent dbdContent) { + binaryValue = ContentUtils.getContentBinaryValue(monitor, dbdContent); + } else if (value instanceof DBDValue dbdValue) { + binaryValue = dbdValue.getRawValue().toString().getBytes(); + } else { + binaryValue = value.toString().getBytes(StandardCharsets.UTF_8); + } + if (binaryValue == null) { + throw new DBCException("Lob value is null"); + } + return binaryValue; + } +} diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDataLOBReceiver.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDataLOBReceiver.java index f08bb05147..fbfced3597 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDataLOBReceiver.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLDataLOBReceiver.java @@ -20,39 +20,29 @@ import io.cloudbeaver.server.CBConstants; import io.cloudbeaver.server.CBPlatform; import org.jkiss.dbeaver.Log; -import org.jkiss.dbeaver.model.data.*; import org.jkiss.dbeaver.model.exec.*; +import org.jkiss.dbeaver.model.runtime.DBRProgressMonitor; import org.jkiss.dbeaver.model.runtime.VoidProgressMonitor; import org.jkiss.dbeaver.model.sql.DBQuotaException; import org.jkiss.dbeaver.model.struct.DBSDataContainer; -import org.jkiss.dbeaver.utils.ContentUtils; import org.jkiss.utils.CommonUtils; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.sql.Timestamp; import java.text.SimpleDateFormat; -import java.util.List; -public class WebSQLDataLOBReceiver implements DBDDataReceiver { +public class WebSQLDataLOBReceiver extends WebSQLCellValueReceiver { private static final Log log = Log.getLog(WebSQLDataLOBReceiver.class); public static final Path DATA_EXPORT_FOLDER = CBPlatform.getInstance().getTempFolder(new VoidProgressMonitor(), "sql-lob-files"); - private final String tableName; - private final DBSDataContainer dataContainer; - - private DBDAttributeBinding binding; - private Object lobValue; - private int rowIndex; WebSQLDataLOBReceiver(String tableName, DBSDataContainer dataContainer, int rowIndex) { + super(dataContainer, rowIndex); this.tableName = tableName; - this.dataContainer = dataContainer; - this.rowIndex = rowIndex; - if (!Files.exists(DATA_EXPORT_FOLDER)){ + if (!Files.exists(DATA_EXPORT_FOLDER)) { try { Files.createDirectories(DATA_EXPORT_FOLDER); } catch (IOException e) { @@ -62,64 +52,28 @@ public class WebSQLDataLOBReceiver implements DBDDataReceiver { } - public String createLobFile(DBCSession session) throws DBCException, IOException { + public String createLobFile(DBRProgressMonitor monitor) throws DBCException, IOException { String exportFileName = CommonUtils.truncateString(tableName, 32); StringBuilder fileName = new StringBuilder(exportFileName); fileName.append("_") - .append(binding.getName()) - .append("_"); + .append(binding.getName()) + .append("_"); Timestamp ts = new Timestamp(System.currentTimeMillis()); String s = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss").format(ts); fileName.append(s); exportFileName = CommonUtils.escapeFileName(fileName.toString()); - byte[] binaryValue; + byte[] binaryValue = getBinaryValue(monitor); Number fileSizeLimit = CBApplication.getInstance().getAppConfiguration().getResourceQuota(CBConstants.QUOTA_PROP_FILE_LIMIT); - if (lobValue instanceof DBDContent) { - binaryValue = ContentUtils.getContentBinaryValue(session.getProgressMonitor(), (DBDContent) lobValue); - } else if (lobValue instanceof DBDValue) { - binaryValue = ((DBDValue) lobValue).getRawValue().toString().getBytes(); - } else { - binaryValue = lobValue.toString().getBytes(StandardCharsets.UTF_8); - } - if (binaryValue == null) { - throw new DBCException("Lob value is null"); - } if (binaryValue.length > fileSizeLimit.longValue()) { throw new DBQuotaException( - "Data export quota exceeded \n Please increase the resourceQuotas parameter in configuration", + "Data export quota exceeded \n Please increase the resourceQuotas parameter in configuration", CBConstants.QUOTA_PROP_FILE_LIMIT, fileSizeLimit.longValue(), binaryValue.length ); } - Path file = DATA_EXPORT_FOLDER.resolve(exportFileName); + Path file = WebSQLDataLOBReceiver.DATA_EXPORT_FOLDER.resolve(exportFileName); Files.write(file, binaryValue); return exportFileName; } - - @Override - public void fetchStart(DBCSession session, DBCResultSet resultSet, long offset, long maxRows) throws DBCException { - DBCResultSetMetaData meta = resultSet.getMeta(); - List attributes = meta.getAttributes(); - DBCAttributeMetaData attrMeta = attributes.get(rowIndex); - binding = new DBDAttributeBindingMeta(dataContainer, resultSet.getSession(), attrMeta); - } - @Override - public void fetchRow(DBCSession session, DBCResultSet resultSet) throws DBCException { - lobValue = binding.getValueHandler().fetchValueObject( - resultSet.getSession(), - resultSet, - binding.getMetaAttribute(), - rowIndex); - } - - @Override - public void fetchEnd(DBCSession session, DBCResultSet resultSet) throws DBCException { - - } - - @Override - public void close() { - - } } diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java index 65750fc58c..be9715377c 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java @@ -54,6 +54,7 @@ import java.io.IOException; import java.lang.reflect.InvocationTargetException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; @@ -797,14 +798,27 @@ public String readLobValue( DBDRowIdentifier rowIdentifier = resultsInfo.getDefaultRowIdentifier(); checkRowIdentifier(resultsInfo, rowIdentifier); + String tableName = rowIdentifier.getEntity().getName(); + WebSQLDataLOBReceiver dataReceiver = new WebSQLDataLOBReceiver(tableName, resultsInfo.getDataContainer(), lobColumnIndex); + readCellDataValue(monitor, resultsInfo, row, dataReceiver); + try { + return dataReceiver.createLobFile(monitor); + } catch (Exception e) { + throw new DBWebException("Error creating temporary lob file ", e); + } + } + + private void readCellDataValue( + @NotNull DBRProgressMonitor monitor, + @NotNull WebSQLResultsInfo resultsInfo, + @Nullable WebSQLResultsRow row, + @NotNull WebSQLCellValueReceiver dataReceiver) throws DBException { DBSDataContainer dataContainer = resultsInfo.getDataContainer(); DBCExecutionContext executionContext = getExecutionContext(dataContainer); - String tableName = rowIdentifier.getEntity().getName(); - WebSQLDataLOBReceiver dataReceiver = new WebSQLDataLOBReceiver(tableName, dataContainer, lobColumnIndex); try (DBCSession session = executionContext.openSession(monitor, DBCExecutionPurpose.USER, "Generate data update batches")) { WebExecutionSource executionSource = new WebExecutionSource(dataContainer, executionContext, this); DBDDataFilter dataFilter = new DBDDataFilter(); - DBDAttributeBinding[] keyAttributes = rowIdentifier.getAttributes().toArray(new DBDAttributeBinding[0]); + DBDAttributeBinding[] keyAttributes = resultsInfo.getDefaultRowIdentifier().getAttributes().toArray(new DBDAttributeBinding[0]); Object[] rowValues = new Object[keyAttributes.length]; List constraints = new ArrayList<>(); for (int i = 0; i < keyAttributes.length; i++) { @@ -833,14 +847,25 @@ public String readLobValue( DBCStatistics statistics = dataContainer.readData( executionSource, session, dataReceiver, dataFilter, 0, 1, DBSDataContainer.FLAG_NONE, 1); - try { - return dataReceiver.createLobFile(session); - } catch (Exception e) { - throw new DBWebException("Error creating temporary lob file ", e); - } } } + @NotNull + public String readStringValue( + @NotNull DBRProgressMonitor monitor, + @NotNull WebSQLContextInfo contextInfo, + @NotNull String resultsId, + @NotNull Integer columnIndex, + @Nullable WebSQLResultsRow row + ) throws DBException { + WebSQLResultsInfo resultsInfo = contextInfo.getResults(resultsId); + DBDRowIdentifier rowIdentifier = resultsInfo.getDefaultRowIdentifier(); + checkRowIdentifier(resultsInfo, rowIdentifier); + WebSQLCellValueReceiver dataReceiver = new WebSQLCellValueReceiver(resultsInfo.getDataContainer(), columnIndex); + readCellDataValue(monitor, resultsInfo, row, dataReceiver); + return new String(dataReceiver.getBinaryValue(monitor), StandardCharsets.UTF_8); + } + //////////////////////////////////////////////// // Misc diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebServiceBindingSQL.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebServiceBindingSQL.java index e24cf433b6..cc266b8265 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebServiceBindingSQL.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebServiceBindingSQL.java @@ -131,12 +131,24 @@ public void bindWiring(DBWBindingContext model) throws DBWebException { getSQLContext(env), env.getArgument("resultId")); }) - .dataFetcher("readLobValue", env -> - getService(env).readLobValue( - getSQLContext(env), - env.getArgument("resultsId"), - env.getArgument("lobColumnIndex"), - getResultsRow(env, "row"))) + .dataFetcher("readLobValue", env -> // deprecated + getService(env).readLobValue( + getSQLContext(env), + env.getArgument("resultsId"), + env.getArgument("lobColumnIndex"), + getResultsRow(env, "row").get(0))) + .dataFetcher("sqlReadLobValue", env -> + getService(env).readLobValue( + getSQLContext(env), + env.getArgument("resultsId"), + env.getArgument("lobColumnIndex"), + new WebSQLResultsRow(env.getArgument("row")))) + .dataFetcher("sqlReadStringValue", env -> + getService(env).getCellValue( + getSQLContext(env), + env.getArgument("resultsId"), + env.getArgument("columnIndex"), + new WebSQLResultsRow(env.getArgument("row")))) .dataFetcher("updateResultsDataBatch", env -> getService(env).updateResultsDataBatch( getSQLContext(env), diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/impl/WebServiceSQL.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/impl/WebServiceSQL.java index 1302be8e7d..dc4aef0309 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/impl/WebServiceSQL.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/impl/WebServiceSQL.java @@ -60,6 +60,9 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; import java.util.stream.Collectors; /** @@ -319,31 +322,62 @@ public WebSQLExecuteInfo updateResultsDataBatch( } } + @FunctionalInterface + private interface ThrowableFunction { + R apply(T obj) throws Exception; + } + @Override public String readLobValue( @NotNull WebSQLContextInfo contextInfo, @NotNull String resultsId, @NotNull Integer lobColumnIndex, - @Nullable List row) throws DBWebException + @NotNull WebSQLResultsRow row) throws DBWebException { + ThrowableFunction function = monitor -> contextInfo.getProcessor().readLobValue( + monitor, contextInfo, resultsId, lobColumnIndex, row); + return readValue(function, contextInfo.getProcessor()); + } + + @NotNull + @Override + public String getCellValue( + @NotNull WebSQLContextInfo contextInfo, + @NotNull String resultsId, + @NotNull Integer lobColumnIndex, + @NotNull WebSQLResultsRow row + ) throws DBWebException { + if (row == null) { + throw new DBWebException("Results row is not found"); + } + WebSQLProcessor processor = contextInfo.getProcessor(); + ThrowableFunction function = monitor -> processor.readStringValue( + monitor, contextInfo, resultsId, lobColumnIndex, row); + return readValue(function, processor); + } + + @NotNull + private String readValue( + @NotNull ThrowableFunction function, + @NotNull WebSQLProcessor processor + ) throws DBWebException { try { var result = new StringBuilder(); DBExecUtils.tryExecuteRecover( - contextInfo.getProcessor().getWebSession().getProgressMonitor(), - contextInfo.getProcessor().getConnection().getDataSource(), - monitor -> { - try { - result.append(contextInfo.getProcessor().readLobValue( - monitor, contextInfo, resultsId, lobColumnIndex, row.get(0))); - } catch (Exception e) { - throw new InvocationTargetException(e); - } + processor.getWebSession().getProgressMonitor(), + processor.getConnection().getDataSource(), + monitor -> { + try { + result.append(function.apply(monitor)); + } catch (Exception e) { + throw new InvocationTargetException(e); } + } ); return result.toString(); } catch (DBException e) { - throw new DBWebException("Error reading LOB value ", e); + throw new DBWebException("Error reading value ", e); } } diff --git a/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls b/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls index 82530a3bc7..0f4fca656f 100644 --- a/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls +++ b/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls @@ -86,6 +86,11 @@ type AuthInfo { userTokens: [UserAuthToken!] } + +type LogoutInfo @since(version: "23.3.3") { + redirectLinks: [String!]! +} + type UserAuthToken { # Auth provider used for authorization authProvider: ID! @@ -139,8 +144,13 @@ extend type Query { authUpdateStatus(authId: ID!, linkUser: Boolean): AuthInfo! # Logouts user. If provider not specified then all authorizations are revoked from session. + @deprecated authLogout(provider: ID, configuration: ID): Boolean + # Same as #authLogout, but returns additional information + @since(version: "23.3.3") + authLogoutExtended(provider: ID, configuration: ID): LogoutInfo! + # Active user information. null is no user was authorized within session activeUser: UserInfo diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java index 777b0dc7b4..c0c9c22c24 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java @@ -45,7 +45,11 @@ WebAuthStatus authLogin( WebAuthStatus authUpdateStatus(@NotNull WebSession webSession, @NotNull String authId, boolean linkWithActiveUser) throws DBWebException; @WebAction(authRequired = false) - void authLogout(@NotNull WebSession webSession, @Nullable String providerId, @Nullable String configurationId) throws DBWebException; + WebLogoutInfo authLogout( + @NotNull WebSession webSession, + @Nullable String providerId, + @Nullable String configurationId + ) throws DBWebException; @WebAction(authRequired = false) WebUserInfo activeUser(@NotNull WebSession webSession) throws DBWebException; diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebLogoutInfo.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebLogoutInfo.java new file mode 100644 index 0000000000..9e8aaefb3d --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebLogoutInfo.java @@ -0,0 +1,24 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.auth; + +import org.jkiss.code.NotNull; + +import java.util.List; + +public record WebLogoutInfo(@NotNull List redirectLinks) { +} diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java index 37d82cc772..9ecc77b00a 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java @@ -42,12 +42,15 @@ public void bindWiring(DBWBindingContext model) throws DBWebException { env.getArgument("configuration"), env.getArgument("credentials"), CommonUtils.toBoolean(env.getArgument("linkUser")))) + .dataFetcher("authLogoutExtended", env -> getService(env).authLogout( + getWebSession(env, false), + env.getArgument("provider"), + env.getArgument("configuration") + )) .dataFetcher("authLogout", env -> { - getService(env).authLogout( - getWebSession(env, false), + getService(env).authLogout(getWebSession(env, false), env.getArgument("provider"), - env.getArgument("configuration") - ); + env.getArgument("configuration")); return true; }) .dataFetcher("authUpdateStatus", env -> getService(env).authUpdateStatus( diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java index e16245f8dd..0d1f3db208 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java @@ -18,6 +18,7 @@ import io.cloudbeaver.DBWebException; import io.cloudbeaver.WebServiceUtils; +import io.cloudbeaver.auth.SMAuthProviderFederated; import io.cloudbeaver.auth.provider.local.LocalAuthProvider; import io.cloudbeaver.model.WebPropertyInfo; import io.cloudbeaver.model.session.WebAuthInfo; @@ -31,6 +32,7 @@ import io.cloudbeaver.server.CBApplication; import io.cloudbeaver.service.auth.DBWServiceAuth; import io.cloudbeaver.service.auth.WebAuthStatus; +import io.cloudbeaver.service.auth.WebLogoutInfo; import io.cloudbeaver.service.auth.WebUserInfo; import io.cloudbeaver.service.security.SMUtils; import org.jkiss.code.NotNull; @@ -39,6 +41,7 @@ import org.jkiss.dbeaver.Log; import org.jkiss.dbeaver.model.auth.SMAuthInfo; import org.jkiss.dbeaver.model.auth.SMAuthStatus; +import org.jkiss.dbeaver.model.auth.SMSessionExternal; import org.jkiss.dbeaver.model.preferences.DBPPropertyDescriptor; import org.jkiss.dbeaver.model.security.SMController; import org.jkiss.dbeaver.model.security.SMSubjectType; @@ -131,7 +134,7 @@ public WebAuthStatus authUpdateStatus(@NotNull WebSession webSession, @NotNull S } @Override - public void authLogout( + public WebLogoutInfo authLogout( @NotNull WebSession webSession, @Nullable String providerId, @Nullable String configurationId @@ -140,7 +143,26 @@ public void authLogout( throw new DBWebException("Not logged in"); } try { - webSession.removeAuthInfo(providerId); + List removedInfos = webSession.removeAuthInfo(providerId); + List logoutUrls = new ArrayList<>(); + var cbApp = CBApplication.getInstance(); + for (WebAuthInfo removedInfo : removedInfos) { + if (removedInfo.getAuthProviderDescriptor() + .getInstance() instanceof SMAuthProviderFederated federatedProvider + && removedInfo.getAuthSession() instanceof SMSessionExternal externalSession + ) { + var providerConfig = + cbApp.getAuthConfiguration().getAuthProviderConfiguration(removedInfo.getAuthConfiguration()); + if (providerConfig == null) { + log.warn(removedInfo.getAuthConfiguration() + " provider configuration wasn't found"); + continue; + } + String logoutUrl = federatedProvider.getUserSignOutLink(providerConfig, + externalSession.getAuthParameters()); + logoutUrls.add(logoutUrl); + } + } + return new WebLogoutInfo(logoutUrls); } catch (DBException e) { throw new DBWebException("User logout failed", e); } diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java index e451dff1c0..c7ab132345 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/CBEmbeddedSecurityController.java @@ -1348,7 +1348,8 @@ public SMAuthInfo authenticate( var authProviderFederated = (SMAuthProviderFederated) authProviderInstance; String signInLink = buildRedirectLink(authProviderFederated.getSignInLink(authProviderConfigurationId, Map.of()), authAttemptId); - String signOutLink = authProviderFederated.getSignOutLink(authProviderConfigurationId, Map.of()); + String signOutLink = authProviderFederated.getCommonSignOutLink(authProviderConfigurationId, + Map.of()); Map authData = Map.of(new SMAuthConfigurationReference(authProviderId, authProviderConfigurationId), filteredUserCreds); return SMAuthInfo.inProgress(authAttemptId, signInLink, signOutLink, authData, isMainSession); @@ -1621,9 +1622,12 @@ private SMAuthInfo getAuthStatus(@NotNull String authId, boolean readExpiredData signInLink = buildRedirectLink(((SMAuthProviderFederated) authProviderInstance).getRedirectLink( authProviderConfiguration, Map.of()), authId); - signOutLink = buildRedirectLink(((SMAuthProviderFederated) authProviderInstance).getSignOutLink( - authProviderConfiguration, - Map.of()), authId); + var userCustomSignOutLink = + ((SMAuthProviderFederated) authProviderInstance).getUserSignOutLink( + application.getAuthConfiguration() + .getAuthProviderConfiguration(authProviderConfiguration), + authProviderData); + signOutLink = userCustomSignOutLink; } } diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/PasswordPolicyConfiguration.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/PasswordPolicyConfiguration.java new file mode 100644 index 0000000000..28076d7174 --- /dev/null +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/PasswordPolicyConfiguration.java @@ -0,0 +1,50 @@ +/* + * DBeaver - Universal Database Manager + * Copyright (C) 2010-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.cloudbeaver.service.security; + +import org.jkiss.dbeaver.model.meta.Property; + +public class PasswordPolicyConfiguration { + private static final int DEFAULT_MIN_LENGTH = 8; + private static final int DEFAULT_MIN_DIGITS = 1; + private static final int DEFAULT_MIN_SPECIAL_CHARACTERS = 0; + private static final boolean DEFAULT_REQUIRES_UPPER_LOWER_CASE = true; + private int minLength = DEFAULT_MIN_LENGTH; + private int minNumberCount = DEFAULT_MIN_DIGITS; + private int minSymbolCount = DEFAULT_MIN_SPECIAL_CHARACTERS; + private boolean requireMixedCase = DEFAULT_REQUIRES_UPPER_LOWER_CASE; + + @Property + public int getMinLength() { + return minLength; + } + + @Property + public int getMinNumberCount() { + return minNumberCount; + } + + @Property + public int getMinSymbolCount() { + return minSymbolCount; + } + + @Property + public boolean isRequireMixedCase() { + return requireMixedCase; + } +} diff --git a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/SMControllerConfiguration.java b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/SMControllerConfiguration.java index 26fb64585e..70a704faf1 100644 --- a/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/SMControllerConfiguration.java +++ b/server/bundles/io.cloudbeaver.service.security/src/io/cloudbeaver/service/security/SMControllerConfiguration.java @@ -36,6 +36,7 @@ public class SMControllerConfiguration { private int maxFailedLogin = DEFAULT_MAX_FAILED_LOGIN; private int minimumLoginTimeout = DEFAULT_MINIMUM_LOGIN_TIMEOUT; private int blockLoginPeriod = DEFAULT_BLOCK_LOGIN_PERIOD; + private final PasswordPolicyConfiguration passwordPolicy = new PasswordPolicyConfiguration(); public int getAccessTokenTtl() { return accessTokenTtl; @@ -92,4 +93,8 @@ public void setMinimumLoginTimeout(int minimumTimeout) { public void setBlockLoginPeriod(int blockPeriod) { this.blockLoginPeriod = blockPeriod; } + + public PasswordPolicyConfiguration getPasswordPolicyConfiguration() { + return passwordPolicy; + } } diff --git a/webapp/packages/core-authentication/src/AuthInfoService.ts b/webapp/packages/core-authentication/src/AuthInfoService.ts index 23ba0d8d66..ca8b6c48c2 100644 --- a/webapp/packages/core-authentication/src/AuthInfoService.ts +++ b/webapp/packages/core-authentication/src/AuthInfoService.ts @@ -25,31 +25,6 @@ export class AuthInfoService { return this.userInfoResource.data; } - get userAuthConfigurations(): IUserAuthConfiguration[] { - const tokens = this.userInfo?.authTokens; - const result: IUserAuthConfiguration[] = []; - - if (!tokens) { - return result; - } - - for (const token of tokens) { - if (token.authConfiguration) { - const provider = this.authProvidersResource.values.find(provider => provider.id === token.authProvider); - - if (provider) { - const configuration = provider.configurations?.find(configuration => configuration.id === token.authConfiguration); - - if (configuration) { - result.push({ providerId: provider.id, configuration }); - } - } - } - } - - return result; - } - constructor( private readonly userInfoResource: UserInfoResource, private readonly authProvidersResource: AuthProvidersResource, diff --git a/webapp/packages/core-authentication/src/PasswordPolicyService.ts b/webapp/packages/core-authentication/src/PasswordPolicyService.ts new file mode 100644 index 0000000000..4c19107734 --- /dev/null +++ b/webapp/packages/core-authentication/src/PasswordPolicyService.ts @@ -0,0 +1,75 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { computed, makeObservable } from 'mobx'; + +import { injectable } from '@cloudbeaver/core-di'; +import { LocalizationService } from '@cloudbeaver/core-localization'; +import { ServerConfigResource } from '@cloudbeaver/core-root'; +import type { PasswordPolicyConfig } from '@cloudbeaver/core-sdk'; + +const DEFAULT_PASSWORD_POLICY: PasswordPolicyConfig = { + minLength: 8, + minNumberCount: 0, + minSymbolCount: 0, + requireMixedCase: false, +}; + +type ValidationResult = { isValid: true; errorMessage: null } | { isValid: false; errorMessage: string }; + +@injectable() +export class PasswordPolicyService { + get config(): PasswordPolicyConfig { + return { + minLength: this.serverConfigResource.data?.passwordPolicyConfiguration?.minLength || DEFAULT_PASSWORD_POLICY.minLength, + minNumberCount: this.serverConfigResource.data?.passwordPolicyConfiguration?.minNumberCount || DEFAULT_PASSWORD_POLICY.minNumberCount, + minSymbolCount: this.serverConfigResource.data?.passwordPolicyConfiguration?.minSymbolCount || DEFAULT_PASSWORD_POLICY.minSymbolCount, + requireMixedCase: this.serverConfigResource.data?.passwordPolicyConfiguration?.requireMixedCase || DEFAULT_PASSWORD_POLICY.requireMixedCase, + }; + } + + constructor(private readonly serverConfigResource: ServerConfigResource, private readonly localizationService: LocalizationService) { + makeObservable(this, { + config: computed, + }); + } + + validatePassword(password: string): ValidationResult { + const trimmedPassword = password.trim(); + + if (trimmedPassword.length < this.config.minLength) { + return { + isValid: false, + errorMessage: this.localizationService.translate('core_authentication_password_policy_min_length', undefined, { min: this.config.minLength }), + }; + } + + if (this.config.requireMixedCase && !(/\p{Ll}/u.test(trimmedPassword) && /\p{Lu}/u.test(trimmedPassword))) { + return { isValid: false, errorMessage: this.localizationService.translate('core_authentication_password_policy_upper_lower_case') }; + } + + if ((trimmedPassword.match(/\d/g) || []).length < this.config.minNumberCount) { + return { + isValid: false, + errorMessage: this.localizationService.translate('core_authentication_password_policy_min_digits', undefined, { + min: this.config.minNumberCount, + }), + }; + } + + if ((trimmedPassword.match(/[!@#$%^&*(),.?":{}|<>]/g) || []).length < this.config.minSymbolCount) { + return { + isValid: false, + errorMessage: this.localizationService.translate('core_authentication_password_policy_min_special_characters', undefined, { + min: this.config.minSymbolCount, + }), + }; + } + + return { isValid: true, errorMessage: null }; + } +} diff --git a/webapp/packages/core-authentication/src/UserInfoResource.ts b/webapp/packages/core-authentication/src/UserInfoResource.ts index aa268c7f70..e11390d484 100644 --- a/webapp/packages/core-authentication/src/UserInfoResource.ts +++ b/webapp/packages/core-authentication/src/UserInfoResource.ts @@ -11,7 +11,7 @@ import { injectable } from '@cloudbeaver/core-di'; import { AutoRunningTask, ISyncExecutor, ITask, SyncExecutor, whileTask } from '@cloudbeaver/core-executor'; import { CachedDataResource, type ResourceKeySimple, ResourceKeyUtils } from '@cloudbeaver/core-resource'; import { SessionDataResource, SessionResource } from '@cloudbeaver/core-root'; -import { AuthInfo, AuthStatus, GetActiveUserQueryVariables, GraphQLService, UserInfo } from '@cloudbeaver/core-sdk'; +import { AuthInfo, AuthLogoutQuery, AuthStatus, GetActiveUserQueryVariables, GraphQLService, UserInfo } from '@cloudbeaver/core-sdk'; import { AUTH_PROVIDER_LOCAL_ID } from './AUTH_PROVIDER_LOCAL_ID'; import { AuthProviderService } from './AuthProviderService'; @@ -20,6 +20,8 @@ import type { IAuthCredentials } from './IAuthCredentials'; export type UserInfoIncludes = GetActiveUserQueryVariables; +export type UserLogoutInfo = AuthLogoutQuery['result']; + export interface ILoginOptions { credentials?: IAuthCredentials; configurationId?: string; @@ -151,8 +153,8 @@ export class UserInfoResource extends CachedDataResource { - await this.graphQLService.sdk.authLogout({ + async logout(provider?: string, configuration?: string): Promise { + const result = await this.graphQLService.sdk.authLogout({ provider, configuration, }); @@ -160,6 +162,8 @@ export class UserInfoResource extends CachedDataResource { diff --git a/webapp/packages/core-authentication/src/index.ts b/webapp/packages/core-authentication/src/index.ts index b2b7c7af17..53b32aef3c 100644 --- a/webapp/packages/core-authentication/src/index.ts +++ b/webapp/packages/core-authentication/src/index.ts @@ -20,3 +20,4 @@ export * from './UsersResource'; export * from './TeamMetaParametersResource'; export * from './EAdminPermission'; export * from './AUTH_SETTINGS_GROUP'; +export * from './PasswordPolicyService'; diff --git a/webapp/packages/core-authentication/src/locales/en.ts b/webapp/packages/core-authentication/src/locales/en.ts index fd933c546c..4554669df2 100644 --- a/webapp/packages/core-authentication/src/locales/en.ts +++ b/webapp/packages/core-authentication/src/locales/en.ts @@ -2,4 +2,9 @@ export default [ ['settings_authentication', 'Authentication'], ['settings_authentication_disable_anonymous_access_name', 'Disable anonymous access'], ['settings_authentication_disable_anonymous_access_description', 'Disable anonymous access function'], + + ['core_authentication_password_policy_min_length', 'Password must be at least {arg:min} characters long'], + ['core_authentication_password_policy_upper_lower_case', 'Password must contain both upper and lower case letters'], + ['core_authentication_password_policy_min_digits', 'Password must contain at least {arg:min} digits'], + ['core_authentication_password_policy_min_special_characters', 'Password must contain at least {arg:min} special characters'], ]; diff --git a/webapp/packages/core-authentication/src/locales/it.ts b/webapp/packages/core-authentication/src/locales/it.ts index fd933c546c..4554669df2 100644 --- a/webapp/packages/core-authentication/src/locales/it.ts +++ b/webapp/packages/core-authentication/src/locales/it.ts @@ -2,4 +2,9 @@ export default [ ['settings_authentication', 'Authentication'], ['settings_authentication_disable_anonymous_access_name', 'Disable anonymous access'], ['settings_authentication_disable_anonymous_access_description', 'Disable anonymous access function'], + + ['core_authentication_password_policy_min_length', 'Password must be at least {arg:min} characters long'], + ['core_authentication_password_policy_upper_lower_case', 'Password must contain both upper and lower case letters'], + ['core_authentication_password_policy_min_digits', 'Password must contain at least {arg:min} digits'], + ['core_authentication_password_policy_min_special_characters', 'Password must contain at least {arg:min} special characters'], ]; diff --git a/webapp/packages/core-authentication/src/locales/ru.ts b/webapp/packages/core-authentication/src/locales/ru.ts index 7edfa853fa..51fffc3dbd 100644 --- a/webapp/packages/core-authentication/src/locales/ru.ts +++ b/webapp/packages/core-authentication/src/locales/ru.ts @@ -2,4 +2,9 @@ export default [ ['settings_authentication', 'Аутентификация'], ['settings_authentication_disable_anonymous_access_name', 'Отключить анонимный доступ'], ['settings_authentication_disable_anonymous_access_description', 'Отключить функцию анонимного доступа'], + + ['core_authentication_password_policy_min_length', 'Пароль должен быть не менее {arg:min} символов'], + ['core_authentication_password_policy_upper_lower_case', 'Пароль должен содержать как заглавные, так и строчные буквы'], + ['core_authentication_password_policy_min_digits', 'Пароль должен содержать не менее {arg:min} цифр'], + ['core_authentication_password_policy_min_special_characters', 'Пароль должен содержать не менее {arg:min} специальных символов'], ]; diff --git a/webapp/packages/core-authentication/src/locales/zh.ts b/webapp/packages/core-authentication/src/locales/zh.ts index fd933c546c..4554669df2 100644 --- a/webapp/packages/core-authentication/src/locales/zh.ts +++ b/webapp/packages/core-authentication/src/locales/zh.ts @@ -2,4 +2,9 @@ export default [ ['settings_authentication', 'Authentication'], ['settings_authentication_disable_anonymous_access_name', 'Disable anonymous access'], ['settings_authentication_disable_anonymous_access_description', 'Disable anonymous access function'], + + ['core_authentication_password_policy_min_length', 'Password must be at least {arg:min} characters long'], + ['core_authentication_password_policy_upper_lower_case', 'Password must contain both upper and lower case letters'], + ['core_authentication_password_policy_min_digits', 'Password must contain at least {arg:min} digits'], + ['core_authentication_password_policy_min_special_characters', 'Password must contain at least {arg:min} special characters'], ]; diff --git a/webapp/packages/core-authentication/src/manifest.ts b/webapp/packages/core-authentication/src/manifest.ts index b6ae2c3fc1..d61ab7a50e 100644 --- a/webapp/packages/core-authentication/src/manifest.ts +++ b/webapp/packages/core-authentication/src/manifest.ts @@ -16,6 +16,7 @@ import { AuthProvidersResource } from './AuthProvidersResource'; import { AuthRolesResource } from './AuthRolesResource'; import { AuthSettingsService } from './AuthSettingsService'; import { LocaleService } from './LocaleService'; +import { PasswordPolicyService } from './PasswordPolicyService'; import { TeamMetaParametersResource } from './TeamMetaParametersResource'; import { TeamsManagerService } from './TeamsManagerService'; import { TeamsResource } from './TeamsResource'; @@ -47,6 +48,7 @@ export const coreAuthenticationManifest: PluginManifest = { UserConfigurationBootstrap, AuthRolesResource, TeamMetaParametersResource, + PasswordPolicyService, LocaleService, ], }; diff --git a/webapp/packages/core-blocks/src/index.ts b/webapp/packages/core-blocks/src/index.ts index 143404aeea..dae9b93393 100644 --- a/webapp/packages/core-blocks/src/index.ts +++ b/webapp/packages/core-blocks/src/index.ts @@ -227,4 +227,5 @@ export * from './Snackbars/ActionSnackbar'; export * from './Snackbars/ProcessSnackbar'; export * from './useUserData'; export * from './useMergeRefs'; +export * from './usePasswordValidation'; export * from './manifest'; diff --git a/webapp/packages/core-blocks/src/usePasswordValidation.ts b/webapp/packages/core-blocks/src/usePasswordValidation.ts new file mode 100644 index 0000000000..1dc464a524 --- /dev/null +++ b/webapp/packages/core-blocks/src/usePasswordValidation.ts @@ -0,0 +1,26 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2024 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { PasswordPolicyService } from '@cloudbeaver/core-authentication'; +import { useService } from '@cloudbeaver/core-di'; + +import { useCustomInputValidation } from './FormControls/useCustomInputValidation'; + +export function usePasswordValidation() { + const passwordPolicyService = useService(PasswordPolicyService); + + const ref = useCustomInputValidation(value => { + if (!value) { + return null; + } + + const validation = passwordPolicyService.validatePassword(value); + return validation.isValid ? null : validation.errorMessage; + }); + + return ref; +} diff --git a/webapp/packages/core-root/src/ServerConfigResource.ts b/webapp/packages/core-root/src/ServerConfigResource.ts index 168c131608..b3be79531e 100644 --- a/webapp/packages/core-root/src/ServerConfigResource.ts +++ b/webapp/packages/core-root/src/ServerConfigResource.ts @@ -149,6 +149,10 @@ export class ServerConfigResource extends CachedDataResource) = releaseTime: 'July 11, 2022', licenseInfo: '', }, + passwordPolicyConfiguration: { + minLength: 8, + minNumberCount: 0, + minSymbolCount: 0, + requireMixedCase: false, + }, }, }); diff --git a/webapp/packages/core-sdk/src/queries/authentication/authLogout.gql b/webapp/packages/core-sdk/src/queries/authentication/authLogout.gql index 86ec3b1489..4997dbbd56 100644 --- a/webapp/packages/core-sdk/src/queries/authentication/authLogout.gql +++ b/webapp/packages/core-sdk/src/queries/authentication/authLogout.gql @@ -1,9 +1,5 @@ -query authLogout( - $provider: ID - $configuration: ID -) { - authLogout( - provider: $provider - configuration: $configuration - ) -} \ No newline at end of file +query authLogout($provider: ID, $configuration: ID) { + result: authLogoutExtended(provider: $provider, configuration: $configuration) { + redirectLinks + } +} diff --git a/webapp/packages/core-sdk/src/queries/grid/getResultsetDataURL.gql b/webapp/packages/core-sdk/src/queries/grid/getResultsetDataURL.gql index 91aa5c3ca2..6eec6ae8e7 100644 --- a/webapp/packages/core-sdk/src/queries/grid/getResultsetDataURL.gql +++ b/webapp/packages/core-sdk/src/queries/grid/getResultsetDataURL.gql @@ -1,9 +1,3 @@ -mutation getResultsetDataURL($connectionId: ID!, $contextId: ID!, $resultsId: ID!, $lobColumnIndex: Int!, $row: [ SQLResultRow! ]!) { - url: readLobValue( - connectionId: $connectionId, - contextId: $contextId, - resultsId: $resultsId, - lobColumnIndex: $lobColumnIndex, - row: $row - ) -} \ No newline at end of file +mutation getResultsetDataURL($connectionId: ID!, $contextId: ID!, $resultsId: ID!, $lobColumnIndex: Int!, $row: SQLResultRow!) { + url: sqlReadLobValue(connectionId: $connectionId, contextId: $contextId, resultsId: $resultsId, lobColumnIndex: $lobColumnIndex, row: $row) +} diff --git a/webapp/packages/core-sdk/src/queries/session/serverConfig.gql b/webapp/packages/core-sdk/src/queries/session/serverConfig.gql index da4ec7912d..b88cfc9032 100644 --- a/webapp/packages/core-sdk/src/queries/session/serverConfig.gql +++ b/webapp/packages/core-sdk/src/queries/session/serverConfig.gql @@ -52,5 +52,11 @@ query serverConfig { releaseTime licenseInfo } + passwordPolicyConfiguration { + minLength + minNumberCount + minSymbolCount + requireMixedCase + } } } diff --git a/webapp/packages/core-sdk/src/queries/sqlReadStringValue.gql b/webapp/packages/core-sdk/src/queries/sqlReadStringValue.gql new file mode 100644 index 0000000000..ce9b225e37 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/sqlReadStringValue.gql @@ -0,0 +1,3 @@ +mutation sqlReadStringValue($connectionId: ID!, $contextId: ID!, $resultsId: ID!, $columnIndex: Int!, $row: SQLResultRow!) { + text: sqlReadStringValue(connectionId: $connectionId, contextId: $contextId, resultsId: $resultsId, columnIndex: $columnIndex, row: $row) +} diff --git a/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/ServerConfigurationService.ts b/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/ServerConfigurationService.ts index 345f7ddfe4..090ab61d5f 100644 --- a/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/ServerConfigurationService.ts +++ b/webapp/packages/plugin-administration/src/ConfigurationWizard/ServerConfiguration/ServerConfigurationService.ts @@ -153,7 +153,7 @@ export class ServerConfigurationService { const validation = contexts.getContext(serverConfigValidationContext); - return validation.getState(); + return validation.valid; } private readonly loadServerConfig: IExecutorHandler = async (data, contexts) => { @@ -200,7 +200,7 @@ export class ServerConfigurationService { private readonly save: IExecutorHandler = async (data, contexts) => { const validation = contexts.getContext(serverConfigValidationContext); - if (!validation.getState()) { + if (!validation.valid) { return; } @@ -221,8 +221,18 @@ export class ServerConfigurationService { private readonly ensureValidation: IExecutorHandler = (data, contexts) => { const validation = contexts.getContext(serverConfigValidationContext); - if (!validation.getState()) { + if (!validation.valid) { ExecutorInterrupter.interrupt(contexts); + + if (validation.messages.length > 0) { + this.notificationService.notify( + { + title: 'administration_configuration_wizard_step_validation_message', + message: validation.messages.join('\n'), + }, + validation.valid ? ENotificationType.Info : ENotificationType.Error, + ); + } this.done = false; } else { this.done = true; @@ -295,21 +305,27 @@ export class ServerConfigurationService { } export interface IValidationStatusContext { - getState: () => boolean; + valid: boolean; + messages: string[]; invalidate: () => void; + info: (message: string) => void; + error: (message: string) => void; } export function serverConfigValidationContext(): IValidationStatusContext { - let state = true; - - const invalidate = () => { - state = false; - }; - const getState = () => state; - return { - getState, - invalidate, + valid: true, + messages: [], + invalidate() { + this.valid = false; + }, + info(message: string) { + this.messages.push(message); + }, + error(message: string) { + this.messages.push(message); + this.valid = false; + }, }; } diff --git a/webapp/packages/plugin-administration/src/locales/en.ts b/webapp/packages/plugin-administration/src/locales/en.ts index 10887d7624..b2ec88b42a 100644 --- a/webapp/packages/plugin-administration/src/locales/en.ts +++ b/webapp/packages/plugin-administration/src/locales/en.ts @@ -65,6 +65,8 @@ export default [ ['administration_configuration_wizard_configuration_navigator_show_system_objects', 'System Objects'], ['administration_configuration_wizard_configuration_navigator_show_utility_objects', 'Utility Objects'], + ['administration_configuration_wizard_step_validation_message', 'Failed to proceed to the next step'], + ['administration_configuration_wizard_finish', 'Confirmation'], ['administration_configuration_wizard_finish_step_description', 'Confirmation'], ['administration_configuration_wizard_finish_title', 'That is almost it.'], diff --git a/webapp/packages/plugin-administration/src/locales/it.ts b/webapp/packages/plugin-administration/src/locales/it.ts index 2fcc1d55e8..1f70630398 100644 --- a/webapp/packages/plugin-administration/src/locales/it.ts +++ b/webapp/packages/plugin-administration/src/locales/it.ts @@ -70,6 +70,8 @@ export default [ ['administration_configuration_wizard_configuration_navigator_show_system_objects', 'Oggetti di Sistema'], ['administration_configuration_wizard_configuration_navigator_show_utility_objects', 'Oggetti di UtilitàUtility Objects'], + ['administration_configuration_wizard_step_validation_message', 'Failed to proceed to the next step'], + ['administration_configuration_wizard_finish', 'Conferma'], ['administration_configuration_wizard_finish_step_description', 'Conferma'], ['administration_configuration_wizard_finish_title', 'Ci siamo quasi.'], diff --git a/webapp/packages/plugin-administration/src/locales/ru.ts b/webapp/packages/plugin-administration/src/locales/ru.ts index aaed836b51..1a506514bd 100644 --- a/webapp/packages/plugin-administration/src/locales/ru.ts +++ b/webapp/packages/plugin-administration/src/locales/ru.ts @@ -26,6 +26,8 @@ export default [ 'Все новые подключения, созданные пользователем, будут иметь только базовую информацию в дереве навигации', ], + ['administration_configuration_wizard_step_validation_message', 'Не удалось перейти к следующему шагу'], + ['administration_configuration_wizard_configuration_security', 'Безопасность'], ['administration_configuration_wizard_configuration_security_admin_credentials', 'Позволить сохранять приватные данные'], ['administration_configuration_wizard_configuration_security_public_credentials', 'Позволить сохранять приватные данные для пользователей'], diff --git a/webapp/packages/plugin-administration/src/locales/zh.ts b/webapp/packages/plugin-administration/src/locales/zh.ts index 1dc16e6cb9..a815b51f49 100644 --- a/webapp/packages/plugin-administration/src/locales/zh.ts +++ b/webapp/packages/plugin-administration/src/locales/zh.ts @@ -52,6 +52,8 @@ export default [ ['administration_configuration_wizard_configuration_navigator_show_system_objects', '系统对象'], ['administration_configuration_wizard_configuration_navigator_show_utility_objects', '实用程序对象'], + ['administration_configuration_wizard_step_validation_message', 'Failed to proceed to the next step'], + ['administration_configuration_wizard_finish', '确认'], ['administration_configuration_wizard_finish_step_description', '确认'], ['administration_configuration_wizard_finish_title', '差不多就这样。'], diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/ServerConfiguration/ServerConfigurationAuthenticationBootstrap.ts b/webapp/packages/plugin-authentication-administration/src/Administration/ServerConfiguration/ServerConfigurationAuthenticationBootstrap.ts index 293767a2a2..7df18decf2 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/ServerConfiguration/ServerConfigurationAuthenticationBootstrap.ts +++ b/webapp/packages/plugin-authentication-administration/src/Administration/ServerConfiguration/ServerConfigurationAuthenticationBootstrap.ts @@ -6,7 +6,7 @@ * you may not use this file except in compliance with the License. */ import { AdministrationScreenService } from '@cloudbeaver/core-administration'; -import { AUTH_PROVIDER_LOCAL_ID, AuthProvidersResource } from '@cloudbeaver/core-authentication'; +import { AUTH_PROVIDER_LOCAL_ID, AuthProvidersResource, PasswordPolicyService } from '@cloudbeaver/core-authentication'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; import { ExecutorInterrupter, IExecutorHandler } from '@cloudbeaver/core-executor'; @@ -27,6 +27,7 @@ export class ServerConfigurationAuthenticationBootstrap extends Bootstrap { private readonly authProvidersResource: AuthProvidersResource, private readonly serverConfigResource: ServerConfigResource, private readonly notificationService: NotificationService, + private readonly passwordPolicyService: PasswordPolicyService, ) { super(); } @@ -81,7 +82,12 @@ export class ServerConfigurationAuthenticationBootstrap extends Bootstrap { const validation = contexts.getContext(serverConfigValidationContext); if (!data.state.serverConfig.adminName || data.state.serverConfig.adminName.length < 6 || !data.state.serverConfig.adminPassword) { - validation.invalidate(); + return validation.invalidate(); + } + + const passwordValidation = this.passwordPolicyService.validatePassword(data.state.serverConfig.adminPassword); + if (!passwordValidation.isValid) { + validation.error(passwordValidation.errorMessage); } }; } diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UserForm/Info/UserFormInfoCredentials.tsx b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UserForm/Info/UserFormInfoCredentials.tsx index aade7c157c..5a7458ab63 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/Users/UserForm/Info/UserFormInfoCredentials.tsx +++ b/webapp/packages/plugin-authentication-administration/src/Administration/Users/UserForm/Info/UserFormInfoCredentials.tsx @@ -8,7 +8,15 @@ import { observer } from 'mobx-react-lite'; import { AUTH_PROVIDER_LOCAL_ID, AuthProvidersResource, isLocalUser, UsersResource } from '@cloudbeaver/core-authentication'; -import { Container, GroupTitle, InputField, useCustomInputValidation, useResource, useTranslate } from '@cloudbeaver/core-blocks'; +import { + Container, + GroupTitle, + InputField, + useCustomInputValidation, + usePasswordValidation, + useResource, + useTranslate, +} from '@cloudbeaver/core-blocks'; import { FormMode } from '@cloudbeaver/core-ui'; import { isValuesEqual } from '@cloudbeaver/core-utils'; @@ -33,6 +41,7 @@ export const UserFormInfoCredentials = observer(function UserFormInfoCred { active: tabSelected && editing }, ); const authProvidersResource = useResource(UserFormInfoCredentials, AuthProvidersResource, null); + const passwordValidationRef = usePasswordValidation(); let local = authProvidersResource.resource.isEnabled(AUTH_PROVIDER_LOCAL_ID); @@ -56,6 +65,7 @@ export const UserFormInfoCredentials = observer(function UserFormInfoCred {local && ( <> c.providerId === providerId && c.configuration.id === configurationId, - ); - } else if (this.authInfoService.userAuthConfigurations.length > 0) { - userAuthConfiguration = this.authInfoService.userAuthConfigurations[0]; - } - - if (userAuthConfiguration?.configuration.signOutLink) { - this.logoutConfiguration(userAuthConfiguration); - } - try { - await this.userInfoResource.logout(providerId, configurationId); + const logoutResult = await this.userInfoResource.logout(providerId, configurationId); + + this.handleRedirectLinks(logoutResult.result); if (!this.administrationScreenService.isConfigurationMode && !providerId) { this.screenService.navigateToRoot(); @@ -114,15 +101,20 @@ export class AuthenticationService extends Bootstrap { await this.onLogout.execute('after'); } catch (exception: any) { - this.notificationService.logException(exception, "Can't logout"); + this.notificationService.logException(exception, 'authentication_logout_error'); } } - private async logoutConfiguration(configuration: IUserAuthConfiguration): Promise { - if (configuration.configuration.signOutLink) { - const id = `${configuration.configuration.id}-sign-out`; + // TODO handle all redirect links once we know what to do with multiple popups issue + private handleRedirectLinks(userLogoutInfo: UserLogoutInfo) { + const redirectLinks = userLogoutInfo.redirectLinks; + + if (redirectLinks.length) { + const url = redirectLinks[0]; + const id = `okta-logout-id-${uuid()}`; + const popup = this.windowsService.open(id, { - url: configuration.configuration.signOutLink, + url, target: id, width: 600, height: 700, diff --git a/webapp/packages/plugin-authentication/src/locales/en.ts b/webapp/packages/plugin-authentication/src/locales/en.ts index 935fdc3318..a1c94e382f 100644 --- a/webapp/packages/plugin-authentication/src/locales/en.ts +++ b/webapp/packages/plugin-authentication/src/locales/en.ts @@ -2,6 +2,7 @@ export default [ ['authentication_login_dialog_title', 'Authentication'], ['authentication_login', 'Login'], ['authentication_logout', 'Logout'], + ['authentication_logout_error', "Can't logout"], ['authentication_authenticate', 'Authenticate'], ['authentication_authorizing', 'Authorizing...'], ['authentication_auth_federated', 'Federated'], diff --git a/webapp/packages/plugin-authentication/src/locales/it.ts b/webapp/packages/plugin-authentication/src/locales/it.ts index 00115b741c..d3be9e9de5 100644 --- a/webapp/packages/plugin-authentication/src/locales/it.ts +++ b/webapp/packages/plugin-authentication/src/locales/it.ts @@ -2,6 +2,7 @@ export default [ ['authentication_login_dialog_title', 'Autenticazione'], ['authentication_login', 'Login'], ['authentication_logout', 'Logout'], + ['authentication_logout_error', "Can't logout"], ['authentication_authenticate', 'Autentica'], ['authentication_authorizing', 'Authorizing...'], ['authentication_auth_federated', 'Federated'], diff --git a/webapp/packages/plugin-authentication/src/locales/ru.ts b/webapp/packages/plugin-authentication/src/locales/ru.ts index 228e105af9..576bb05cd1 100644 --- a/webapp/packages/plugin-authentication/src/locales/ru.ts +++ b/webapp/packages/plugin-authentication/src/locales/ru.ts @@ -2,6 +2,7 @@ export default [ ['authentication_login_dialog_title', 'Аутентификация'], ['authentication_login', 'Войти'], ['authentication_logout', 'Выйти'], + ['authentication_logout_error', 'Не удалось выйти'], ['authentication_authenticate', 'Аутентифицироваться'], ['authentication_authorizing', 'Авторизация...'], ['authentication_auth_federated', 'Федеративная'], diff --git a/webapp/packages/plugin-authentication/src/locales/zh.ts b/webapp/packages/plugin-authentication/src/locales/zh.ts index 97476a707d..4af3f890b7 100644 --- a/webapp/packages/plugin-authentication/src/locales/zh.ts +++ b/webapp/packages/plugin-authentication/src/locales/zh.ts @@ -2,6 +2,7 @@ export default [ ['authentication_login_dialog_title', '认证'], ['authentication_login', '登录'], ['authentication_logout', '登出'], + ['authentication_logout_error', "Can't logout"], ['authentication_authenticate', '认证'], ['authentication_authorizing', 'Authorizing...'], ['authentication_auth_federated', '联合认证'], diff --git a/webapp/packages/plugin-data-export/src/ExportNotification/ExportNotification.m.css b/webapp/packages/plugin-data-export/src/ExportNotification/ExportNotification.m.css index 2deaf37a8d..cba44a6d0d 100644 --- a/webapp/packages/plugin-data-export/src/ExportNotification/ExportNotification.m.css +++ b/webapp/packages/plugin-data-export/src/ExportNotification/ExportNotification.m.css @@ -18,4 +18,5 @@ margin: 0; text-overflow: ellipsis; overflow: hidden; -} \ No newline at end of file + white-space: nowrap; +} diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts index aa7b3900bc..3d254d940e 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction.ts @@ -26,11 +26,16 @@ import { ResultSetViewAction } from './ResultSetViewAction'; const RESULT_VALUE_PATH = 'sql-result-value'; +interface ICacheEntry { + url?: string; + fullText?: string; +} + @databaseDataAction() export class ResultSetDataContentAction extends DatabaseDataAction implements IResultSetDataContentAction { static dataFormat = [ResultDataFormat.Resultset]; - private readonly cache: Map; + private readonly cache: Map>; activeElement: IResultSetElementKey | null; constructor( @@ -60,6 +65,52 @@ export class ResultSetDataContentAction extends DatabaseDataAction { + try { + this.activeElement = element; + return await this.loadFileFullText(this.result, column.position, row); + } finally { + this.activeElement = null; + } + }); + + this.updateCache(element, { fullText }); + + return fullText; + } + async getFileDataUrl(element: IResultSetElementKey) { const column = this.data.getColumn(element.column); const row = this.data.getRowValue(element.row); @@ -82,21 +133,32 @@ export class ResultSetDataContentAction extends DatabaseDataAction) { + const hash = this.getHash(element); + const cachedElement = this.cache.get(hash) ?? {}; + this.cache.set(hash, { ...cachedElement, ...partialCache }); + } + + retrieveFileFullTextFromCache(element: IResultSetElementKey) { + const hash = this.getHash(element); + return this.cache.get(hash)?.fullText; + } + retrieveFileDataUrlFromCache(element: IResultSetElementKey) { const hash = this.getHash(element); - return this.cache.get(hash); + return this.cache.get(hash)?.url; } async downloadFileData(element: IResultSetElementKey) { diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/useResultActions.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/useResultActions.ts new file mode 100644 index 0000000000..0126441793 --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/useResultActions.ts @@ -0,0 +1,58 @@ +import { computed, observable } from 'mobx'; + +import { useObservableRef } from '@cloudbeaver/core-blocks'; + +import type { IDatabaseDataModel } from '../../IDatabaseDataModel'; +import type { IDatabaseResultSet } from '../../IDatabaseResultSet'; +import { ResultSetConstraintAction } from './ResultSetConstraintAction'; +import { ResultSetDataAction } from './ResultSetDataAction'; +import { ResultSetDataContentAction } from './ResultSetDataContentAction'; +import { ResultSetEditAction } from './ResultSetEditAction'; +import { ResultSetFormatAction } from './ResultSetFormatAction'; +import { ResultSetSelectAction } from './ResultSetSelectAction'; +import { ResultSetViewAction } from './ResultSetViewAction'; + +interface IResultActionsArgs { + resultIndex: number; + model: IDatabaseDataModel; +} + +export function useResultActions({ model, resultIndex }: IResultActionsArgs) { + return useObservableRef( + () => ({ + get dataAction(): ResultSetDataAction { + return this.model.source.getAction(this.resultIndex, ResultSetDataAction); + }, + get selectAction(): ResultSetSelectAction { + return this.model.source.getAction(this.resultIndex, ResultSetSelectAction); + }, + get editAction(): ResultSetEditAction { + return this.model.source.getAction(this.resultIndex, ResultSetEditAction); + }, + get contentAction(): ResultSetDataContentAction { + return this.model.source.getAction(this.resultIndex, ResultSetDataContentAction); + }, + get formatAction(): ResultSetFormatAction { + return this.model.source.getAction(this.resultIndex, ResultSetFormatAction); + }, + get constraintAction(): ResultSetConstraintAction { + return this.model.source.getAction(this.resultIndex, ResultSetConstraintAction); + }, + get viewAction(): ResultSetViewAction { + return this.model.source.getAction(this.resultIndex, ResultSetViewAction); + }, + }), + { + dataAction: computed, + selectAction: computed, + editAction: computed, + contentAction: computed, + formatAction: computed, + constraintAction: computed, + viewAction: computed, + model: observable.ref, + resultIndex: observable.ref, + }, + { model, resultIndex }, + ); +} diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.m.css b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.m.css new file mode 100644 index 0000000000..e909fb879a --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.m.css @@ -0,0 +1,6 @@ +.toolsContainer { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +} diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx index c09c2daa82..6e96115529 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx @@ -10,25 +10,23 @@ import { observer } from 'mobx-react-lite'; import { useMemo } from 'react'; import styled, { css } from 'reshadow'; -import { Button, useStyles, useTranslate } from '@cloudbeaver/core-blocks'; +import { Button, s, useS, useStyles, useTranslate } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; import { QuotasService } from '@cloudbeaver/core-root'; import { BASE_TAB_STYLES, TabContainerPanelComponent, TabList, TabsState, UNDERLINE_TAB_STYLES, useTabLocalState } from '@cloudbeaver/core-ui'; -import { bytesToSize } from '@cloudbeaver/core-utils'; +import { bytesToSize, isNotNullDefined } from '@cloudbeaver/core-utils'; import { EditorLoader, useCodemirrorExtensions } from '@cloudbeaver/plugin-codemirror6'; -import type { IResultSetElementKey } from '../../DatabaseDataModel/Actions/ResultSet/IResultSetDataKey'; import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue'; -import { ResultSetDataContentAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetDataContentAction'; -import { ResultSetEditAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetEditAction'; -import { ResultSetFormatAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction'; import { ResultSetSelectAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction'; +import { useResultActions } from '../../DatabaseDataModel/Actions/ResultSet/useResultActions'; import type { IDatabaseResultSet } from '../../DatabaseDataModel/IDatabaseResultSet'; import type { IDataValuePanelProps } from '../../TableViewer/ValuePanel/DataValuePanelService'; import { QuotaPlaceholder } from '../QuotaPlaceholder'; import { VALUE_PANEL_TOOLS_STYLES } from '../ValuePanelTools/VALUE_PANEL_TOOLS_STYLES'; import { getTypeExtension } from './getTypeExtension'; +import moduleStyles from './TextValuePresentation.m.css'; import { TextValuePresentationService } from './TextValuePresentationService'; import { useTextValue } from './useTextValue'; @@ -70,6 +68,10 @@ const styles = css` } `; +const TEXT_PLAIN_TYPE = 'text/plain'; +const TEXT_JSON_TYPE = 'text/json'; +const APPLICATION_JSON_TYPE = 'application/json'; + export const TextValuePresentation: TabContainerPanelComponent> = observer( function TextValuePresentation({ model, resultIndex, dataFormat }) { const translate = useTranslate(); @@ -77,75 +79,58 @@ export const TextValuePresentation: TabContainerPanelComponent observable({ - currentContentType: 'text/plain', - lastContentType: 'text/plain', + currentContentType: TEXT_PLAIN_TYPE, + + setContentType(contentType: string) { + if (contentType === TEXT_JSON_TYPE) { + contentType = APPLICATION_JSON_TYPE; + } - setContentType(type: string) { - this.currentContentType = type; + this.currentContentType = contentType; }, - setDefaultContentType(type: string) { - this.currentContentType = type; - this.lastContentType = type; + handleTabOpen(tabId: string) { + // currentContentType may be selected automatically we don't want to change state in this case + if (tabId !== this.currentContentType) { + this.setContentType(tabId); + } }, }), ); - - const selection = model.source.getAction(resultIndex, ResultSetSelectAction); - const editor = model.source.getAction(resultIndex, ResultSetEditAction); - const content = model.source.getAction(resultIndex, ResultSetDataContentAction); - - const activeElements = selection.getActiveElements(); - const activeTabs = textValuePresentationService.tabs.getDisplayed({ dataFormat, model, resultIndex }); - - let contentType = 'text/plain'; - let firstSelectedCell: IResultSetElementKey | undefined; - let readonly = true; - let valueTruncated = false; - let limit: string | undefined; - let valueSize: string | undefined; - - if (activeElements.length > 0) { - const format = model.source.getAction(resultIndex, ResultSetFormatAction); - - firstSelectedCell = activeElements[0]; - - const value = format.get(firstSelectedCell); - readonly = format.isReadOnly(firstSelectedCell) || format.isBinary(firstSelectedCell); - - if (isResultSetContentValue(value)) { - valueTruncated = content.isContentTruncated(value); - - if (valueTruncated) { - limit = bytesToSize(quotasService.getQuota('sqlBinaryPreviewMaxLength')); - valueSize = bytesToSize(value.contentLength ?? 0); - } - - if (value.contentType) { - contentType = value.contentType; - - if (contentType === 'text/json') { - contentType = 'application/json'; - } - - if (!activeTabs.some(tab => tab.key === contentType)) { - contentType = 'text/plain'; - } - } - } - } - - readonly = model.isReadonly(resultIndex) || model.isDisabled(resultIndex) || readonly; - - if (contentType !== state.lastContentType) { - state.setDefaultContentType(contentType); - } + const { textValue, isTruncated, isTextColumn, pasteFullText } = useTextValue({ + model, + resultIndex, + currentContentType: state.currentContentType, + }); + const isSelectedCellReadonly = firstSelectedCell && (formatAction.isReadOnly(firstSelectedCell) || formatAction.isBinary(firstSelectedCell)); + const isReadonlyByResultIndex = model.isReadonly(resultIndex) || model.isDisabled(resultIndex) || !firstSelectedCell; + const isReadonly = isSelectedCellReadonly || isReadonlyByResultIndex; + const valueSize = + isResultSetContentValue(contentValue) && isNotNullDefined(contentValue.contentLength) ? bytesToSize(contentValue.contentLength) : undefined; + const limit = bytesToSize(quotasService.getQuota('sqlBinaryPreviewMaxLength')); + const canSave = firstSelectedCell && contentAction.isDownloadable(firstSelectedCell); + const shouldShowPasteButton = isTextColumn && isTruncated; + const typeExtension = useMemo(() => getTypeExtension(state.currentContentType) ?? [], [state.currentContentType]); + const extensions = useCodemirrorExtensions(undefined, typeExtension); function handleChange(newValue: string) { - if (firstSelectedCell && !readonly) { - editor.set(firstSelectedCell, newValue); + if (firstSelectedCell && !isReadonly) { + editAction.set(firstSelectedCell, newValue); } } @@ -155,33 +140,15 @@ export const TextValuePresentation: TabContainerPanelComponent 0 && !activeTabs.some(tab => tab.key === currentContentType)) { - currentContentType = activeTabs[0].key; - } - - const canSave = !!firstSelectedCell && content.isDownloadable(firstSelectedCell); - const typeExtension = useMemo(() => getTypeExtension(currentContentType) ?? [], [currentContentType]); - const extensions = useCodemirrorExtensions(undefined, typeExtension); - - const value = useTextValue({ - model, - resultIndex, - currentContentType, - }); - - function handleTabOpen(tabId: string) { - // currentContentType may be selected automatically we don't want to change state in this case - if (tabId !== currentContentType) { - state.setContentType(tabId); - } + if (!activeTabs.some(tab => tab.key === state.currentContentType)) { + const contentType = activeTabs.length > 0 && activeTabs[0].key ? activeTabs[0].key : TEXT_PLAIN_TYPE; + state.setContentType(contentType); } return styled(style)( @@ -191,23 +158,28 @@ export const TextValuePresentation: TabContainerPanelComponent handleTabOpen(tab.tabId)} + onChange={tab => state.handleTabOpen(tab.tabId)} > - handleChange(value)} /> - {valueTruncated && } - {canSave && ( - + + {isTruncated && } +
+ {canSave && ( - - )} + )} + {shouldShowPasteButton && ( + + )} +
, ); }, diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useTextValue.ts b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useTextValue.ts index 47997dff6d..001a8cf581 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useTextValue.ts +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/useTextValue.ts @@ -5,12 +5,14 @@ * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ +import { useService } from '@cloudbeaver/core-di'; +import { NotificationService } from '@cloudbeaver/core-events'; import { isNotNullDefined } from '@cloudbeaver/core-utils'; import { isResultSetBinaryFileValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetBinaryFileValue'; -import { ResultSetEditAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetEditAction'; -import { ResultSetFormatAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction'; +import { isResultSetContentValue } from '../../DatabaseDataModel/Actions/ResultSet/isResultSetContentValue'; import { ResultSetSelectAction } from '../../DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction'; +import { useResultActions } from '../../DatabaseDataModel/Actions/ResultSet/useResultActions'; import type { IDatabaseDataModel } from '../../DatabaseDataModel/IDatabaseDataModel'; import type { IDatabaseResultSet } from '../../DatabaseDataModel/IDatabaseResultSet'; import { useAutoFormat } from './useAutoFormat'; @@ -21,31 +23,71 @@ interface IUseTextValueArgs { currentContentType: string; } -export function useTextValue({ model, resultIndex, currentContentType }: IUseTextValueArgs) { - const format = model.source.getAction(resultIndex, ResultSetFormatAction); - const editor = model.source.getAction(resultIndex, ResultSetEditAction); +interface IUseTextValue { + textValue: string; + isTruncated: boolean; + isTextColumn: boolean; + pasteFullText(): Promise; +} + +export function useTextValue({ model, resultIndex, currentContentType }: IUseTextValueArgs): IUseTextValue { + const { formatAction, editAction, contentAction, dataAction } = useResultActions({ model, resultIndex }); const selection = model.source.getAction(resultIndex, ResultSetSelectAction); const activeElements = selection.getActiveElements(); const firstSelectedCell = activeElements?.[0]; const formatter = useAutoFormat(); + const columnType = firstSelectedCell ? dataAction.getColumn(firstSelectedCell.column)?.dataKind : ''; + const isTextColumn = columnType?.toLocaleLowerCase() === 'string'; + const contentValue = firstSelectedCell ? formatAction.get(firstSelectedCell) : null; + const cachedFullText = firstSelectedCell ? contentAction.retrieveFileFullTextFromCache(firstSelectedCell) : ''; + const blob = firstSelectedCell ? formatAction.get(firstSelectedCell) : null; + const notificationService = useService(NotificationService); + + const result: IUseTextValue = { + textValue: '', + isTruncated: false, + isTextColumn, + async pasteFullText() { + if (!firstSelectedCell) { + return; + } + + try { + await contentAction.getFileFullText(firstSelectedCell); + } catch (exception) { + notificationService.logException(exception as any, 'data_viewer_presentation_value_content_paste_error'); + } + }, + }; if (!isNotNullDefined(firstSelectedCell)) { - return ''; + return result; } - if (editor.isElementEdited(firstSelectedCell)) { - return format.getText(firstSelectedCell); + if (isResultSetContentValue(contentValue)) { + result.isTruncated = contentAction.isContentTruncated(contentValue); } - const blob = format.get(firstSelectedCell); + if (isTextColumn && cachedFullText) { + result.textValue = cachedFullText; + result.isTruncated = false; + } + + if (editAction.isElementEdited(firstSelectedCell)) { + result.textValue = formatAction.getText(firstSelectedCell); + } if (isResultSetBinaryFileValue(blob)) { const value = formatter.formatBlob(currentContentType, blob); if (value) { - return value; + result.textValue = value; } } - return formatter.format(currentContentType, format.getText(firstSelectedCell)); + if (!result.textValue) { + result.textValue = formatter.format(currentContentType, formatAction.getText(firstSelectedCell)); + } + + return result; } diff --git a/webapp/packages/plugin-data-viewer/src/locales/en.ts b/webapp/packages/plugin-data-viewer/src/locales/en.ts index fec9e03994..a6aad623d4 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/en.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/en.ts @@ -38,6 +38,8 @@ export default [ ['data_viewer_presentation_value_content_was_truncated', 'The value was truncated'], ['data_viewer_presentation_value_content_value_size', 'Value size'], ['data_viewer_presentation_value_content_download_error', 'Download failed'], + ['data_viewer_presentation_value_content_paste_error', 'Cannot load full text'], + ['data_viewer_presentation_value_content_full_text_button', 'View full text'], ['data_viewer_script_preview', 'Script'], ['data_viewer_script_preview_dialog_title', 'Preview changes'], ['data_viewer_script_preview_error_title', "Can't get the script"], diff --git a/webapp/packages/plugin-data-viewer/src/locales/it.ts b/webapp/packages/plugin-data-viewer/src/locales/it.ts index c4ea762cc5..1e4176ad7e 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/it.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/it.ts @@ -34,6 +34,8 @@ export default [ ['data_viewer_presentation_value_content_was_truncated', 'The value was truncated'], ['data_viewer_presentation_value_content_value_size', 'Value size'], ['data_viewer_presentation_value_content_download_error', 'Download failed'], + ['data_viewer_presentation_value_content_paste_error', 'Cannot load full text'], + ['data_viewer_presentation_value_content_full_text_button', 'View full text'], ['data_viewer_refresh_result_set', 'Refresh result set'], ['data_viewer_total_count_tooltip', 'Get total count'], ['data_viewer_total_count_failed', 'Failed to get total count'], diff --git a/webapp/packages/plugin-data-viewer/src/locales/ru.ts b/webapp/packages/plugin-data-viewer/src/locales/ru.ts index 74e1d0cd72..82624eea1a 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/ru.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/ru.ts @@ -32,6 +32,8 @@ export default [ ['data_viewer_presentation_value_content_was_truncated', 'Значение было обрезано'], ['data_viewer_presentation_value_content_value_size', 'Размер значения'], ['data_viewer_presentation_value_content_download_error', 'Не удалось загрузить файл'], + ['data_viewer_presentation_value_content_paste_error', 'Не удалось загрузить весь текст'], + ['data_viewer_presentation_value_content_full_text_button', 'Посмотреть весь текст'], ['data_viewer_script_preview', 'Скрипт'], ['data_viewer_script_preview_dialog_title', 'Предпросмотр изменений'], ['data_viewer_script_preview_error_title', 'Не удалось получить скрипт'], diff --git a/webapp/packages/plugin-data-viewer/src/locales/zh.ts b/webapp/packages/plugin-data-viewer/src/locales/zh.ts index 2d03d261e8..9d4da4d6ac 100644 --- a/webapp/packages/plugin-data-viewer/src/locales/zh.ts +++ b/webapp/packages/plugin-data-viewer/src/locales/zh.ts @@ -38,6 +38,8 @@ export default [ ['data_viewer_presentation_value_content_was_truncated', 'The value was truncated'], ['data_viewer_presentation_value_content_value_size', 'Value size'], ['data_viewer_presentation_value_content_download_error', 'Download failed'], + ['data_viewer_presentation_value_content_paste_error', 'Cannot load full text'], + ['data_viewer_presentation_value_content_full_text_button', 'View full text'], ['data_viewer_script_preview', '脚本'], ['data_viewer_script_preview_dialog_title', '预览更改'], ['data_viewer_script_preview_error_title', '无法获取脚本'], diff --git a/webapp/packages/plugin-user-profile/src/UserProfileForm/Authentication/ChangePassword/useChangePassword.ts b/webapp/packages/plugin-user-profile/src/UserProfileForm/Authentication/ChangePassword/useChangePassword.ts index 114fe33846..1486d6935a 100644 --- a/webapp/packages/plugin-user-profile/src/UserProfileForm/Authentication/ChangePassword/useChangePassword.ts +++ b/webapp/packages/plugin-user-profile/src/UserProfileForm/Authentication/ChangePassword/useChangePassword.ts @@ -7,7 +7,7 @@ */ import { action, computed, observable } from 'mobx'; -import { UsersResource } from '@cloudbeaver/core-authentication'; +import { PasswordPolicyService, UsersResource } from '@cloudbeaver/core-authentication'; import { useObservableRef } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; @@ -29,6 +29,7 @@ interface IState { export function useChangePassword(): IState { const usersResource = useService(UsersResource); const notificationService = useService(NotificationService); + const passwordPolicyService = useService(PasswordPolicyService); return useObservableRef( () => ({ @@ -42,6 +43,16 @@ export function useChangePassword(): IState { return this.config.password.length > 0 && this.config.oldPassword.length > 0 && this.config.repeatedPassword.length > 0; }, async changePassword() { + const validation = this.passwordPolicyService.validatePassword(this.config.password); + + if (!validation.isValid) { + this.notificationService.logError({ + title: 'plugin_user_profile_authentication_change_password_password_validation_error', + message: validation.errorMessage, + }); + return; + } + if (this.config.password !== this.config.repeatedPassword) { this.notificationService.logError({ title: 'plugin_user_profile_authentication_change_password_passwords_not_match' }); return; @@ -71,6 +82,6 @@ export function useChangePassword(): IState { changePassword: action.bound, resetConfig: action, }, - { usersResource, notificationService }, + { usersResource, notificationService, passwordPolicyService }, ); } diff --git a/webapp/packages/plugin-user-profile/src/locales/en.ts b/webapp/packages/plugin-user-profile/src/locales/en.ts index 0317590459..c0a5a26775 100644 --- a/webapp/packages/plugin-user-profile/src/locales/en.ts +++ b/webapp/packages/plugin-user-profile/src/locales/en.ts @@ -15,4 +15,5 @@ export default [ ['plugin_user_profile_authentication_change_password_success', 'Password was changed successfully'], ['plugin_user_profile_authentication_change_password_submit_label', 'Change'], ['plugin_user_profile_authentication_change_password_passwords_not_match', "Passwords don't match"], + ['plugin_user_profile_authentication_change_password_password_validation_error', 'Password validation failed'], ]; diff --git a/webapp/packages/plugin-user-profile/src/locales/it.ts b/webapp/packages/plugin-user-profile/src/locales/it.ts index b6e2d18a81..2c796e7aeb 100644 --- a/webapp/packages/plugin-user-profile/src/locales/it.ts +++ b/webapp/packages/plugin-user-profile/src/locales/it.ts @@ -15,4 +15,5 @@ export default [ ['plugin_user_profile_authentication_change_password_success', 'Password modificata con successo'], ['plugin_user_profile_authentication_change_password_submit_label', 'Modifica'], ['plugin_user_profile_authentication_change_password_passwords_not_match', 'Le Passwords non coincidono'], + ['plugin_user_profile_authentication_change_password_password_validation_error', 'Password validation failed'], ]; diff --git a/webapp/packages/plugin-user-profile/src/locales/ru.ts b/webapp/packages/plugin-user-profile/src/locales/ru.ts index e6c3f0dce4..ac5bc52bc8 100644 --- a/webapp/packages/plugin-user-profile/src/locales/ru.ts +++ b/webapp/packages/plugin-user-profile/src/locales/ru.ts @@ -15,4 +15,5 @@ export default [ ['plugin_user_profile_authentication_change_password_success', 'Пароль был успешно изменен'], ['plugin_user_profile_authentication_change_password_submit_label', 'Сменить'], ['plugin_user_profile_authentication_change_password_passwords_not_match', 'Пароли не совпадают'], + ['plugin_user_profile_authentication_change_password_password_validation_error', 'Валидация пароля не удалась'], ]; diff --git a/webapp/packages/plugin-user-profile/src/locales/zh.ts b/webapp/packages/plugin-user-profile/src/locales/zh.ts index e29fb5c32d..f1abcfe504 100644 --- a/webapp/packages/plugin-user-profile/src/locales/zh.ts +++ b/webapp/packages/plugin-user-profile/src/locales/zh.ts @@ -15,4 +15,5 @@ export default [ ['plugin_user_profile_authentication_change_password_success', '密码更改成功'], ['plugin_user_profile_authentication_change_password_submit_label', '更改'], ['plugin_user_profile_authentication_change_password_passwords_not_match', '密码不匹配'], + ['plugin_user_profile_authentication_change_password_password_validation_error', 'Password validation failed'], ];