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/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 8152e14049..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() { 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.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/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/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-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/plugin-authentication/src/AuthenticationService.ts b/webapp/packages/plugin-authentication/src/AuthenticationService.ts index c22e53f9b6..91ec32a7f3 100644 --- a/webapp/packages/plugin-authentication/src/AuthenticationService.ts +++ b/webapp/packages/plugin-authentication/src/AuthenticationService.ts @@ -11,13 +11,12 @@ import { AdministrationScreenService } from '@cloudbeaver/core-administration'; import { AppAuthService, AUTH_PROVIDER_LOCAL_ID, - AuthInfoService, AuthProviderContext, AuthProviderService, AuthProvidersResource, - IUserAuthConfiguration, RequestedProvider, UserInfoResource, + UserLogoutInfo, } from '@cloudbeaver/core-authentication'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; import type { DialogueStateResult } from '@cloudbeaver/core-dialogs'; @@ -27,6 +26,7 @@ import { CachedMapAllKey } from '@cloudbeaver/core-resource'; import { ISessionAction, ServerConfigResource, sessionActionContext, SessionActionService, SessionDataResource } from '@cloudbeaver/core-root'; import { ScreenService, WindowsService } from '@cloudbeaver/core-routing'; import { NavigationService } from '@cloudbeaver/core-ui'; +import { uuid } from '@cloudbeaver/core-utils'; import { AuthDialogService } from './Dialog/AuthDialogService'; import type { IAuthOptions } from './IAuthOptions'; @@ -54,7 +54,6 @@ export class AuthenticationService extends Bootstrap { private readonly authProviderService: AuthProviderService, private readonly authProvidersResource: AuthProvidersResource, private readonly sessionDataResource: SessionDataResource, - private readonly authInfoService: AuthInfoService, private readonly serverConfigResource: ServerConfigResource, private readonly windowsService: WindowsService, private readonly sessionActionService: SessionActionService, @@ -91,22 +90,10 @@ export class AuthenticationService extends Bootstrap { return; } - let userAuthConfiguration: IUserAuthConfiguration | undefined = undefined; - - if (providerId) { - userAuthConfiguration = this.authInfoService.userAuthConfigurations.find( - c => 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', '联合认证'],