diff --git a/config.dev.json b/config.dev.json index 52d2e8014..73627a934 100644 --- a/config.dev.json +++ b/config.dev.json @@ -125,6 +125,9 @@ ], "feature_screenshare_call": ["*"] }, + "tchap_sso_flow": { + "isActive": true + }, "map_style_url": "https://openmaptiles.geo.data.gouv.fr/styles/osm-bright/style.json", "element_call": { "url": "https://element-call.tchap.incubateur.net/" diff --git a/config.preprod.json b/config.preprod.json index 439fcbe43..5864003de 100644 --- a/config.preprod.json +++ b/config.preprod.json @@ -111,5 +111,8 @@ "feature_video_call": ["i.tchap.gouv.fr", "e.tchap.gouv.fr"], "feature_screenshare_call": ["*"] }, + "tchap_sso_flow": { + "isActive": false + }, "map_style_url": "https://openmaptiles.geo.data.gouv.fr/styles/osm-bright/style.json" } diff --git a/config.prod.json b/config.prod.json index 5056c3230..a7192ce50 100644 --- a/config.prod.json +++ b/config.prod.json @@ -198,5 +198,8 @@ "feature_video_call": ["agent.dinum.tchap.gouv.fr"], "feature_screenshare_call": ["*"] }, + "tchap_sso_flow": { + "isActive": false + }, "map_style_url": "https://openmaptiles.geo.data.gouv.fr/styles/osm-bright/style.json" } diff --git a/config.prod.lab.json b/config.prod.lab.json index b171b023a..33fa0bdce 100644 --- a/config.prod.lab.json +++ b/config.prod.lab.json @@ -198,5 +198,8 @@ "feature_video_call": ["agent.dinum.tchap.gouv.fr", "education.tchap.gouv.fr"], "feature_screenshare_call": ["*"] }, + "tchap_sso_flow": { + "isActive": false + }, "map_style_url": "https://openmaptiles.geo.data.gouv.fr/styles/osm-bright/style.json" } diff --git a/linked-dependencies/matrix-react-sdk/src/Views.ts b/linked-dependencies/matrix-react-sdk/src/Views.ts index 4c7f002d5..a9c01f646 100644 --- a/linked-dependencies/matrix-react-sdk/src/Views.ts +++ b/linked-dependencies/matrix-react-sdk/src/Views.ts @@ -54,6 +54,9 @@ enum Views { // Another instance of the application has started up. We just show an error page. LOCK_STOLEN, + + // :TCHAP: screen before launching sso + EMAIL_PRECHECK_SSO } export default Views; diff --git a/linked-dependencies/matrix-react-sdk/src/components/structures/MatrixChat.tsx b/linked-dependencies/matrix-react-sdk/src/components/structures/MatrixChat.tsx index c1d1396ce..42e78af6a 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/structures/MatrixChat.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/structures/MatrixChat.tsx @@ -143,9 +143,11 @@ import { checkSessionLockFree, getSessionLock } from "../../utils/SessionLock"; import { SessionLockStolenView } from "./auth/SessionLockStolenView"; import { ConfirmSessionLockTheftView } from "./auth/ConfirmSessionLockTheftView"; import { LoginSplashView } from "./auth/LoginSplashView"; -import TchapUrls from "../../../../../src/tchap/util/TchapUrls"; // :TCHAP: activate-cross-signing-and-secure-storage-react import { cleanUpDraftsIfRequired } from "../../DraftCleaner"; +import TchapUrls from "../../../../../src/tchap/util/TchapUrls"; // :TCHAP: activate-cross-signing-and-secure-storage-react +import EmailVerificationPage from "../../../../../src/tchap/components/views/sso/EmailVerificationPage"; // :TCHAP: sso-agentconnect-flow + // legacy export export { default as Views } from "../../Views"; @@ -946,6 +948,15 @@ export default class MatrixChat extends React.PureComponent { true, ); break; + // :TCHAP: sso-agentconnect-flow + case Action.EmailPrecheckSSO: + if (Lifecycle.isSoftLogout()) { + this.onSoftLogout(); + break; + } + this.viewEmailPrecheckSSO(); + break; + // end :TCHAP: } }; @@ -1104,6 +1115,17 @@ export default class MatrixChat extends React.PureComponent { this.themeWatcher.recheck(); } + // :TCHAP: sso-agentconnect-flow + private viewEmailPrecheckSSO() { + this.setStateForNewView({ + view: Views.EMAIL_PRECHECK_SSO + }); + this.notifyNewScreen("email-precheck-sso"); + ThemeController.isLogin = true; + this.themeWatcher.recheck(); + } + // end :TCHAP: + private viewHome(justRegistered = false): void { // The home page requires the "logged in" view, so we'll set that. this.setStateForNewView({ @@ -1875,6 +1897,13 @@ export default class MatrixChat extends React.PureComponent { userId: userId, subAction: params?.action, }); + // :TCHAP: sso-agentconnect-flow + } else if (screen = "email-precheck-sso") { + dis.dispatch({ + action: "email_precheck_sso", + params + }); + // end :TCHAP: } else { logger.info(`Ignoring showScreen for '${screen}'`); } @@ -2017,7 +2046,9 @@ export default class MatrixChat extends React.PureComponent { if ( initialScreenAfterLogin && // XXX: workaround for https://github.com/vector-im/element-web/issues/11643 causing a login-loop - !["welcome", "login", "register", "start_sso", "start_cas"].includes(initialScreenAfterLogin.screen) + // :TCHAP: sso-agentconnect-flow !["welcome", "login", "register", "start_sso", "start_cas"].includes(initialScreenAfterLogin.screen) + !["welcome", "login", "register", "start_sso", "start_cas", "email-precheck-sso"].includes(initialScreenAfterLogin.screen) + // end :TCHAP: ) { fragmentAfterLogin = `/${initialScreenAfterLogin.screen}`; } @@ -2137,6 +2168,10 @@ export default class MatrixChat extends React.PureComponent { view = => this.onShowPostLoginScreen(useCase)} />; } else if (this.state.view === Views.LOCK_STOLEN) { view = ; + // :TCHAP: sso-agentconnect-flow + } else if (this.state.view === Views.EMAIL_PRECHECK_SSO) { + view = ; + // end :TCHAP: } else { logger.error(`Unknown view ${this.state.view}`); return null; diff --git a/linked-dependencies/matrix-react-sdk/src/components/structures/auth/Login.tsx b/linked-dependencies/matrix-react-sdk/src/components/structures/auth/Login.tsx index 646b70ebd..2680ee202 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/structures/auth/Login.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/structures/auth/Login.tsx @@ -138,7 +138,8 @@ export default class LoginComponent extends React.PureComponent // eslint-disable-next-line @typescript-eslint/naming-convention "m.login.cas": () => this.renderSsoStep("cas"), // eslint-disable-next-line @typescript-eslint/naming-convention - "m.login.sso": () => this.renderSsoStep("sso"), + // :TCHAP: sso-agentconnect-flow + // "m.login.sso": () => this.renderSsoStep("sso"), "oidcNativeFlow": () => this.renderOidcNativeStep(), }; } diff --git a/linked-dependencies/matrix-react-sdk/src/components/structures/auth/Registration.tsx b/linked-dependencies/matrix-react-sdk/src/components/structures/auth/Registration.tsx index 9d72c1d92..0624e8991 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/structures/auth/Registration.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/structures/auth/Registration.tsx @@ -57,7 +57,8 @@ import { Features } from "../../../settings/Settings"; import { startOidcLogin } from "../../../utils/oidc/authorize"; import TchapUtils from '../../../../../../src/tchap/util/TchapUtils'; // :TCHAP: registration-for-mainlining -import TchapUrls from "../../../../../../src/tchap/util/TchapUrls"; +import TchapUIFeature from "../../../../../../src/tchap/util/TchapUIFeature"; // :TCHAP: sso-agentconnect-flow +import ProconnectButton from "../../../../../../src/tchap/components/views/sso/ProconnectButton"; // :TCHAP: sso-agentconnect-flow const debuglog = (...args: any[]): void => { if (SettingsStore.getValue("debug_registration")) { @@ -592,39 +593,47 @@ export default class Registration extends React.Component { ); } else if (this.state.matrixClient && this.state.flows.length) { let ssoSection: JSX.Element | undefined; - if (this.state.ssoFlow) { - let continueWithSection; - const providers = this.state.ssoFlow.identity_providers || []; - // when there is only a single (or 0) providers we show a wide button with `Continue with X` text - if (providers.length > 1) { - // i18n: ssoButtons is a placeholder to help translators understand context - continueWithSection = ( -

- {_t("auth|continue_with_sso", { ssoButtons: "" }).trim()} -

- ); - } - - // i18n: ssoButtons & usernamePassword are placeholders to help translators understand context - ssoSection = ( - - {continueWithSection} - -

- {_t("auth|sso_or_username_password", { - ssoButtons: "", - usernamePassword: "", - }).trim()} -

-
- ); + // :TCHAP: sso-agentconnect-flow + // if (this.state.ssoFlow) { + // let continueWithSection; + // const providers = this.state.ssoFlow.identity_providers || []; + // // when there is only a single (or 0) providers we show a wide button with `Continue with X` text + // if (providers.length > 1) { + // // i18n: ssoButtons is a placeholder to help translators understand context + // continueWithSection = ( + //

+ // {_t("auth|continue_with_sso", { ssoButtons: "" }).trim()} + //

+ // ); + // } + + // // i18n: ssoButtons & usernamePassword are placeholders to help translators understand context + // ssoSection = ( + // + // {continueWithSection} + // + //

+ // {_t("auth|sso_or_username_password", { + // ssoButtons: "", + // usernamePassword: "", + // }).trim()} + //

+ //
+ // ); + if (this.state.ssoFlow && TchapUIFeature.isSSOFlowActive()) { + ssoSection = <> + +

{_t("auth|sso|or")}

+ } + // } + // end :TCHAP: return ( diff --git a/linked-dependencies/matrix-react-sdk/src/components/views/auth/Welcome.tsx b/linked-dependencies/matrix-react-sdk/src/components/views/auth/Welcome.tsx index 29e671156..5d77ac175 100644 --- a/linked-dependencies/matrix-react-sdk/src/components/views/auth/Welcome.tsx +++ b/linked-dependencies/matrix-react-sdk/src/components/views/auth/Welcome.tsx @@ -24,6 +24,7 @@ import { UIFeature } from "../../../settings/UIFeature"; import LanguageSelector from "./LanguageSelector"; import EmbeddedPage from "../../structures/EmbeddedPage"; import { MATRIX_LOGO_HTML } from "../../structures/static-page-vars"; +import TchapUIFeature from "../../../../../../src/tchap/util/TchapUIFeature"; interface IProps {} @@ -47,7 +48,9 @@ export default class Welcome extends React.PureComponent { const brandingConfig = SdkConfig.getObject("branding"); const logoUrl = brandingConfig?.get("auth_header_logo_url") ?? "themes/element/img/logos/element-logo.svg"; replaceMap["$logoUrl"] = logoUrl; - pageUrl = "welcome.html"; + // :TCHAP: sso-agentconnect-flow - pageUrl = "welcome.html"; + pageUrl = TchapUIFeature.isSSOFlowActive() ? "welcome_sso.html" : "welcome.html"; + // end :TCHAP: } return ( diff --git a/linked-dependencies/matrix-react-sdk/src/dispatcher/actions.ts b/linked-dependencies/matrix-react-sdk/src/dispatcher/actions.ts index 7cff1cc85..267b934e9 100644 --- a/linked-dependencies/matrix-react-sdk/src/dispatcher/actions.ts +++ b/linked-dependencies/matrix-react-sdk/src/dispatcher/actions.ts @@ -393,4 +393,9 @@ export enum Action { * Opens right panel room summary and focuses the search input */ FocusMessageSearch = "focus_search", + + /** + * :TCHAP: Open new page to check email instance before launching SSO + */ + EmailPrecheckSSO = "email_precheck_sso" } diff --git a/modules/tchap-translations/tchap_translations.json b/modules/tchap-translations/tchap_translations.json index 8078e1b53..ce82381e3 100644 --- a/modules/tchap-translations/tchap_translations.json +++ b/modules/tchap-translations/tchap_translations.json @@ -837,5 +837,41 @@ "incompatible_browser|continue": { "en": "Continue anyway", "fr": "Continuer tout de même" + }, + "auth|sso|sign_in_password_instead": { + "en": "Login with password", + "fr": "Se connecter par mot de passe" + }, + "auth|sso|email_title": { + "en": "Login with ProConnect", + "fr": "Se connecter avec ProConnect" + }, + "auth|sso|proconnect_continue": { + "en": "Continue with ProConnect", + "fr": "Continuer avec ProConnect" + }, + "auth|sso|email_placeholder": { + "en": "Your professional email", + "fr": "Votre adresse mail professionelle" + }, + "welcome|sso|proconnect_explanation": { + "en": "-> What is ProConnect ?", + "fr": "-> Qu'est-ce que ProConnect ?" + }, + "auth|sso|error": { + "en": "An error occured during SSO login", + "fr": "Une erreur est survenue lors de la connexion" + }, + "auth|sso|error_homeserver": { + "en": "There is an error with the homeserver configuration", + "fr": "Il y a une erreur avec la configuration du serveur" + }, + "auth|sso|error_email": { + "en": "You need to enter your professional email", + "fr": "Vous devez entrer votre adresse professionelle" + }, + "auth|sso|or": { + "en": "or", + "fr": "ou" } } diff --git a/modules/tchap-translations/tchap_translations_removed.json b/modules/tchap-translations/tchap_translations_removed.json index 6305f0de6..03144a63c 100644 --- a/modules/tchap-translations/tchap_translations_removed.json +++ b/modules/tchap-translations/tchap_translations_removed.json @@ -55,5 +55,6 @@ "create_space|public_heading", "common|private", "room_list|add_space_label", - "spaces|error_no_permission_add_space" + "spaces|error_no_permission_add_space", + "auth|continue_with_sso" ] diff --git a/patches/subtree-modifications.json b/patches/subtree-modifications.json index ac5a44887..878d86eb8 100644 --- a/patches/subtree-modifications.json +++ b/patches/subtree-modifications.json @@ -78,5 +78,14 @@ "files": [ "src/components/views/settings/tabs/user/AccountUserSettingsTab.tsx" ] + }, + "sso-agentconnect-flow": { + "issue": "https://github.com/tchapgouv/tchap-web-v4/issues/386", + "files": [ + "src/components/structures/MatrixChat.tsx", + "src/components/structures/auth/Registration.tsx", + "src/components/structures/auth/Login.tsx", + "src/components/views/auth/Welcome.tsx" + ] } } \ No newline at end of file diff --git a/res/css/views/sso/TchapSSO.pcss b/res/css/views/sso/TchapSSO.pcss new file mode 100644 index 000000000..0ac687607 --- /dev/null +++ b/res/css/views/sso/TchapSSO.pcss @@ -0,0 +1,52 @@ +.tc_pronnect { + .tc_ButtonParent { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 14px 20px; + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-box-align: center; + -webkit-align-items: center; + -ms-flex-align: center; + align-items: center; + border-radius: 4px; + background-origin: content-box; + background-repeat: no-repeat; + background-position: 30px center; + text-decoration: none; + color: #2e2f32 !important; + } + + .tc_ButtonProconnect { + background-color: var(--accent); + color: white !important; + } + + .tc_Button_iconPC { + background-image: url("../../../welcome/images/proconnect.svg"); + } + + .tc_bottomButton { + display: flex; + justify-content: center; + } + + .tc_login { + .tc_ButtonParent { + width: 100%; + } + + .tc_ButtonProconnect { + margin-bottom: 40px; + } + } +} \ No newline at end of file diff --git a/res/welcome.html b/res/welcome.html index 199c3afa0..28b93ffd1 100644 --- a/res/welcome.html +++ b/res/welcome.html @@ -105,7 +105,7 @@ } .mx_ButtonLabel { - margin-left: 20px; + margin-left: 30px; } .mx_Header_title { @@ -203,6 +203,73 @@ margin: 0 0 10px 0; } } + + /* :TCHAP: sso-agentconnect-flow*/ + + .tc_ButtonCol { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + } + + .tc_ButtonParent { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + padding: 14px 20px; + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-box-align: center; + -webkit-align-items: center; + -ms-flex-align: center; + align-items: center; + border-radius: 4px; + width: 175px; + background-repeat: no-repeat; + background-position: 10px center; + text-decoration: none; + color: #2e2f32 !important; + } + + .tc_ButtonProconnect { + background-color: #000091; + color: white !important; + margin-bottom: 10px; + } + + .tc_Button { + color: #000091 !important; + font-weight: bold; + } + + .tc_ButtonBorder { + outline: 1px solid #000091; + outline-offset: -2px; + margin-top: 20px; + margin-bottom: 10px; + } + .tc_ButtonProconnect_explanation { + color: #000091 !important; + } + + .tc_Button_iconPC { + background-image: url("welcome/images/proconnect.svg"); + } + + .tc_paragraph a { + color: #2e2f32; + font-weight: bold; + } + /* end :TCHAP: */
@@ -220,6 +287,9 @@

_t("Welcome to Tchap")
_t("Conçue et gérée par l'Administration française") + +
_t("action|learn_more")
+
diff --git a/res/welcome/images/proconnect.svg b/res/welcome/images/proconnect.svg new file mode 100644 index 000000000..41c99b526 --- /dev/null +++ b/res/welcome/images/proconnect.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/welcome_sso.html b/res/welcome_sso.html new file mode 100644 index 000000000..154add723 --- /dev/null +++ b/res/welcome_sso.html @@ -0,0 +1,286 @@ + + +
+ + + +

_t("Welcome to Tchap")
+ _t("la messagerie instantanée de l'Administration") +

+ +
+ _t("Conçue et gérée par l'Administration française") + +
_t("action|learn_more")
+
+
+ +
\ No newline at end of file diff --git a/scripts/copy-res.js b/scripts/copy-res.js index c052e9954..a516134b5 100755 --- a/scripts/copy-res.js +++ b/scripts/copy-res.js @@ -24,6 +24,9 @@ const COPY_LIST = [ ["res/manifest.json", "webapp"], ["res/sw.js", "webapp"], ["res/welcome.html", "webapp"], + // :TCHAP: sso-agentconnect-flow + ["res/welcome_sso.html", "webapp"], + // end :TCHAP: ["res/welcome/**", "webapp/welcome"], ["res/themes/**", "webapp/themes"], ["res/vector-icons/**", "webapp/vector-icons"], diff --git a/src/tchap/components/views/sso/EmailVerificationPage.tsx b/src/tchap/components/views/sso/EmailVerificationPage.tsx new file mode 100644 index 000000000..d45b6fec8 --- /dev/null +++ b/src/tchap/components/views/sso/EmailVerificationPage.tsx @@ -0,0 +1,156 @@ +/* +Copyright 2019 New Vector Ltd + +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. +*/ + +import React, { useState, useRef } from "react"; +import { _t, _td } from "matrix-react-sdk/src/languageHandler"; + +import AuthPage from "matrix-react-sdk/src/components/views/auth/AuthPage"; +import AuthBody from "matrix-react-sdk/src/components/views/auth/AuthBody"; +import AuthHeader from "matrix-react-sdk/src/components/views/auth/AuthHeader"; +import EmailField from "matrix-react-sdk/src/components/views/auth/EmailField"; +import Field from "matrix-react-sdk/src/components/views/elements/Field"; +import Spinner from "matrix-react-sdk/src/components/views/elements/Spinner"; +import AccessibleButton, { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import PlatformPeg from "matrix-react-sdk/src/PlatformPeg"; + +import { ErrorMessage } from "matrix-react-sdk/src/components/structures/ErrorMessage"; +import { SSOAction } from "matrix-js-sdk/src/matrix"; +import Login from "matrix-react-sdk/src/Login"; +import TchapUtils from "../../../util/TchapUtils"; +import { ValidatedServerConfig } from "matrix-react-sdk/src/utils/ValidatedServerConfig"; + +import "../../../../../res/css/views/sso/TchapSSO.pcss"; + +export default function EmailVerificationPage() { + + const [loading, setLoading] = useState(false); + const [email, setEmail] = useState(""); + const [errorText, setErrorText] = useState(""); + + const submitButtonChild = loading ? : _t("auth|sso|proconnect_continue"); + + const emailFieldRef = useRef(null); + + const displayError = (errorString: string): void => { + emailFieldRef.current?.focus(); + emailFieldRef.current?.validate({ allowEmpty: false, focused: true }); + setErrorText(errorString); + setLoading(false); + } + + const setUpCurrentHs = async (hs: Record): Promise => { + try { + const validatedServerConfig: ValidatedServerConfig = await TchapUtils.makeValidatedServerConfig(hs); + return validatedServerConfig; + } catch(err) { + window.location.assign("email-precheck-sso") + return null + } + + } + + const onSubmit = async (event: React.FormEvent): Promise => { + event.preventDefault(); + setLoading(true); + const isFieldCorrect = await emailFieldRef.current?.validate({ allowEmpty: false }); + + if (!isFieldCorrect) { + displayError(_td("auth|sso|error_email")); + return; + } + + // check email domain and start sso with agentconnect + try { + // get user homeserver from his email + const hs: Record | void = await TchapUtils.fetchHomeserverForEmail(email); + if (!hs) { + displayError("This email address cannot be used in Tchap"); + return; + } + + const validatedServerConfig = await setUpCurrentHs(hs); + + if (!validatedServerConfig) { + displayError(_td("auth|sso|error_homeserver")); + return + } + + const login = new Login(hs.base_url, hs.base_url, null, {}); + + const matrixClient= login.createTemporaryClient(); + + // start SSO flow since we got the homeserver + PlatformPeg.get()?.startSingleSignOn(matrixClient, "sso", "/home", "", SSOAction.LOGIN); + + setLoading(false); + + } catch(err) { + displayError(_td("auth|sso|error")); + } + } + + const onInputChanged = (event: React.FormEvent) => { + setEmail(event.currentTarget.value); + } + + const onLoginByPasswordClick = () => { + window.location.assign("#/login"); + } + + return ( + + + +

+ {_t("auth|sso|email_title")} +

+
+
+
+ ) => onInputChanged(event)} + fieldRef={emailFieldRef} + /> +
+ {errorText && } + +
+ { + e.preventDefault(); + onLoginByPasswordClick(); + }} + > + {_t("auth|sso|sign_in_password_instead")} + +
+
+
+
+
+ ); +} diff --git a/src/tchap/components/views/sso/ProconnectButton.tsx b/src/tchap/components/views/sso/ProconnectButton.tsx new file mode 100644 index 000000000..d076ccf4e --- /dev/null +++ b/src/tchap/components/views/sso/ProconnectButton.tsx @@ -0,0 +1,15 @@ +import React from "react"; + +import "../../../../../res/css/views/sso/TchapSSO.pcss"; +import { _t } from "matrix-react-sdk/src/languageHandler"; + +export default function ProconnectButton(): JSX.Element { + + return ( + + ); +} \ No newline at end of file diff --git a/src/tchap/util/TchapUIFeature.ts b/src/tchap/util/TchapUIFeature.ts index e3512dc3a..1a966e6f6 100644 --- a/src/tchap/util/TchapUIFeature.ts +++ b/src/tchap/util/TchapUIFeature.ts @@ -51,5 +51,12 @@ export default class TchapUIFeature { return homeserversWithFeature.includes(userHomeServer!); } + // We separate from previous method, cause in this feature we cannot differenciate between homeserver since it is before the user connexion + public static isSSOFlowActive():boolean { + const ssoFlow : Record = SdkConfig.get("tchap_sso_flow") as Record ?? {isActive: false}; + + return ssoFlow.isActive; + } + } diff --git a/test/unit-tests/tchap/components/views/sso/EmailVerificationPage-test.tsx b/test/unit-tests/tchap/components/views/sso/EmailVerificationPage-test.tsx new file mode 100644 index 000000000..6754080eb --- /dev/null +++ b/test/unit-tests/tchap/components/views/sso/EmailVerificationPage-test.tsx @@ -0,0 +1,210 @@ +import React from "react"; +import { render, cleanup, fireEvent, screen, act } from "@testing-library/react"; +import { mocked, MockedObject } from "jest-mock"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import EmailVerificationPage from "~tchap-web/src/tchap/components/views/sso/EmailVerificationPage"; +import TchapUtils from "~tchap-web/src/tchap/util/TchapUtils"; +import { ValidatedServerConfig } from "~matrix-react-sdk/src/utils/ValidatedServerConfig"; +import { mockPlatformPeg, stubClient } from "~matrix-react-sdk/test/test-utils"; +import BasePlatform from "~matrix-react-sdk/src/BasePlatform"; +import Login from "~matrix-react-sdk/src/Login"; + +jest.mock("~matrix-react-sdk/src/PlatformPeg"); +jest.mock("~tchap-web/src/tchap/util/TchapUtils"); +jest.mock("~matrix-react-sdk/src/Login"); + +describe("", () => { + const userEmail = "marc@tchap.beta.gouv.fr"; + const defaultHsUrl = "https://matrix.agent1.fr"; + const secondHsUrl = "https://matrix.agent2.fr"; + + const PlatformPegMocked: MockedObject = mockPlatformPeg(); + const mockedClient: MatrixClient = stubClient(); + const mockedTchapUtils = mocked(TchapUtils); + + const mockLoginObject = (hs: string = defaultHsUrl) => { + const mockLoginObject = mocked(new Login(hs, hs, null, {})); + mockLoginObject.createTemporaryClient.mockImplementation(() => mockedClient); + return mockLoginObject; + }; + + const mockedFetchHomeserverFromEmail = (hs: string = defaultHsUrl) => { + mockedTchapUtils.fetchHomeserverForEmail.mockImplementation(() => + Promise.resolve({ base_url: hs, server_name: hs }), + ); + }; + + const mockedValidatedServerConfig = (withError: boolean = false, hsUrl: string = defaultHsUrl) => { + if (withError) { + mockedTchapUtils.makeValidatedServerConfig.mockImplementation(() => { + throw new Error(); + }); + } else { + mockedTchapUtils.makeValidatedServerConfig.mockImplementation(() => + Promise.resolve({ + hsUrl: defaultHsUrl, + hsName: "hs", + hsNameIsDifferent: false, + isUrl: "", + isDefault: true, + isNameResolvable: true, + warning: "", + } as ValidatedServerConfig), + ); + } + }; + + const mockedPlatformPegStartSSO = (withError: boolean) => { + if (withError) { + jest.spyOn(PlatformPegMocked, "startSingleSignOn").mockImplementation(() => { + throw new Error(); + }); + } else { + jest.spyOn(PlatformPegMocked, "startSingleSignOn").mockImplementation(() => {}); + } + }; + + const renderEmailVerificationPage = () => render(); + + beforeEach(() => { + mockLoginObject(defaultHsUrl); + }); + + afterEach(() => { + cleanup(); + jest.restoreAllMocks(); + }); + + it("returns error when empty email", async () => { + const { container } = renderEmailVerificationPage(); + + // Put text in email field + const emailField = screen.getByRole("textbox"); + fireEvent.focus(emailField); + fireEvent.change(emailField, { target: { value: "" } }); + + // click on proconnect button + const proconnectButton = screen.getByTestId("proconnect-submit"); + await act(async () => { + await fireEvent.click(proconnectButton); + }); + + // Error classes should not appear + expect(container.getElementsByClassName("mx_ErrorMessage").length).toBe(1); + }); + + it("returns inccorrect email", async () => { + const { container } = renderEmailVerificationPage(); + + // Put text in email field + const emailField = screen.getByRole("textbox"); + fireEvent.focus(emailField); + fireEvent.change(emailField, { target: { value: "falseemail" } }); + + // click on proconnect button + const proconnectButton = screen.getByTestId("proconnect-submit"); + await act(async () => { + await fireEvent.click(proconnectButton); + }); + + // Error classes should not appear + expect(container.getElementsByClassName("mx_ErrorMessage").length).toBe(1); + }); + + it("should throw error when homeserver catch an error", async () => { + const { container } = renderEmailVerificationPage(); + + // mock server returns an errorn, we dont need to mock the other implementation + // since the code should throw an error before accessing them + mockedValidatedServerConfig(true); + + // Put text in email field + const emailField = screen.getByRole("textbox"); + fireEvent.focus(emailField); + fireEvent.change(emailField, { target: { value: userEmail } }); + + // click on proconnect button + const proconnectButton = screen.getByTestId("proconnect-submit"); + await act(async () => { + await fireEvent.click(proconnectButton); + }); + + // Error classes should not appear + expect(container.getElementsByClassName("mx_ErrorMessage").length).toBe(1); + }); + + it("should throw and error when connecting to proconnect error", async () => { + const { container } = renderEmailVerificationPage(); + + mockedValidatedServerConfig(false); + // mock platform page startsso error + mockedPlatformPegStartSSO(true); + + // Put text in email field + const emailField = screen.getByRole("textbox"); + fireEvent.focus(emailField); + fireEvent.change(emailField, { target: { value: userEmail } }); + + // click on proconnect button + const proconnectButton = screen.getByTestId("proconnect-submit"); + await act(async () => { + await fireEvent.click(proconnectButton); + }); + + // Error classes should not appear + expect(container.getElementsByClassName("mx_ErrorMessage").length).toBe(1); + }); + + it("should start sso with correct homeserver 1", async () => { + renderEmailVerificationPage(); + + // Mock the implementation without error, what we want is to be sure they are called with the correct parameters + mockedFetchHomeserverFromEmail(defaultHsUrl); + mockedValidatedServerConfig(false, defaultHsUrl); + mockedPlatformPegStartSSO(false); + + // Put text in email field + const emailField = screen.getByRole("textbox"); + fireEvent.focus(emailField); + fireEvent.change(emailField, { target: { value: userEmail } }); + + // click on proconnect button + const proconnectButton = screen.getByTestId("proconnect-submit"); + await act(async () => { + await fireEvent.click(proconnectButton); + }); + + expect(mockedTchapUtils.makeValidatedServerConfig).toHaveBeenCalledWith({ + base_url: defaultHsUrl, + server_name: defaultHsUrl, + }); + expect(PlatformPegMocked.startSingleSignOn).toHaveBeenCalledTimes(1); + }); + + it("should start sso with correct homeserver 2", async () => { + renderEmailVerificationPage(); + + // Mock the implementation without error, what we want is to be sure they are called with the correct parameters + mockedFetchHomeserverFromEmail(secondHsUrl); + mockedValidatedServerConfig(false, secondHsUrl); + mockedPlatformPegStartSSO(false); + + // Put text in email field + const emailField = screen.getByRole("textbox"); + fireEvent.focus(emailField); + fireEvent.change(emailField, { target: { value: userEmail } }); + + // click on proconnect button + const proconnectButton = screen.getByTestId("proconnect-submit"); + await act(async () => { + await fireEvent.click(proconnectButton); + }); + + expect(mockedTchapUtils.makeValidatedServerConfig).toHaveBeenCalledWith({ + base_url: secondHsUrl, + server_name: secondHsUrl, + }); + expect(PlatformPegMocked.startSingleSignOn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unit-tests/tchap/components/views/sso/Register-test.tsx b/test/unit-tests/tchap/components/views/sso/Register-test.tsx new file mode 100644 index 000000000..53acdf57d --- /dev/null +++ b/test/unit-tests/tchap/components/views/sso/Register-test.tsx @@ -0,0 +1,153 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { mocked, MockedObject } from "jest-mock"; +import { MatrixClient, MatrixError, OidcClientConfig, createClient } from "matrix-js-sdk/src/matrix"; +import fetchMock from "fetch-mock"; + +import SdkConfig, { ConfigOptions, DEFAULTS } from "~matrix-react-sdk/src/SdkConfig"; +import Registration from "~matrix-react-sdk/src/components/structures/auth/Registration"; +import { + getMockClientWithEventEmitter, + mkServerConfig, + mockPlatformPeg, + unmockPlatformPeg, +} from "~matrix-react-sdk/test/test-utils"; +import { makeDelegatedAuthConfig } from "~matrix-react-sdk/test/test-utils/oidc"; +import SettingsStore from "~matrix-react-sdk/src/settings/SettingsStore"; +import AutoDiscoveryUtils from "~matrix-react-sdk/src/utils/AutoDiscoveryUtils"; +import { ValidatedServerConfig } from "~matrix-react-sdk/src/utils/ValidatedServerConfig"; + +jest.mock("~matrix-react-sdk/src/utils/oidc/authorize", () => ({ + startOidcLogin: jest.fn(), +})); + +jest.mock("matrix-js-sdk/src/matrix", () => ({ + ...jest.requireActual("matrix-js-sdk/src/matrix"), + createClient: jest.fn(), +})); + +/** The matrix versions our mock server claims to support */ +const SERVER_SUPPORTED_MATRIX_VERSIONS = ["v1.1", "v1.5", "v1.6", "v1.8", "v1.9"]; + +describe("", () => { + let mockClient!: MockedObject; + + const defaultHsUrl = "https://matrix.org"; + const defaultIsUrl = "https://vector.im"; + + const addSSOFlowToMockConfig = (isActive: boolean = false) => { + // mock SdkConfig.get("tchap_features") + const config: ConfigOptions = { tchap_sso_flow: { isActive } }; + SdkConfig.put(config); + }; + + const defaultProps = { + defaultDeviceDisplayName: "test-device-display-name", + onLoggedIn: jest.fn(), + onLoginClick: jest.fn(), + onServerConfigChange: jest.fn(), + }; + + function getRawComponent(hsUrl = defaultHsUrl, isUrl = defaultIsUrl, authConfig?: OidcClientConfig) { + return ; + } + + beforeEach(async function () { + const authConfig = makeDelegatedAuthConfig(); + // @ts-ignore + authConfig.metadata["prompt_values_supported"] = ["create"]; + + SdkConfig.put({ + ...DEFAULTS, + disable_custom_urls: true, + }); + mockClient = await getMockClientWithEventEmitter({ + registerRequest: jest.fn().mockImplementation( + () => + new MatrixError( + { + flows: [{ stages: [] }], + }, + 401, + ), + ), + loginFlows: jest.fn().mockResolvedValue({ flows: [{ type: "m.login.sso" }, { type: "m.login.password" }] }), + getVersions: jest.fn().mockResolvedValue({ versions: SERVER_SUPPORTED_MATRIX_VERSIONS }), + }); + + // used for registerRequest, but should return a MatrixError instance for the code to work... which is not the case here + fetchMock.catch({ + status: 401, + body: '{"errcode": "M_UNAUTHORIZE", "error": "Unauthorize request"}', + headers: { "content-type": "application/json" }, + }); + + // Doing this line can mock the request we want, but we want it to throw an error 401 which this doesnt do + // fetchMock.post(`${defaultHsUrl}/_matrix/client/v3/register`, { status: 401, type: "error" }); + + await mocked(createClient).mockImplementation((opts) => { + mockClient.idBaseUrl = opts.idBaseUrl; + mockClient.baseUrl = opts.baseUrl; + return mockClient; + }); + + fetchMock.get(`${defaultHsUrl}/_matrix/client/versions`, { + unstable_features: {}, + versions: SERVER_SUPPORTED_MATRIX_VERSIONS, + }); + + fetchMock.get(`${defaultHsUrl}/_matrix/client/unstable/org.matrix.msc2965/auth_issuer`, { + issuer: authConfig.metadata.issuer, + }); + + fetchMock.get("https://auth.org/.well-known/openid-configuration", authConfig.metadata); + + fetchMock.get(authConfig.metadata.jwks_uri!, { keys: [] }); + + fetchMock.get(`${defaultHsUrl}/_matrix/client/v3/login`, { + body: { flows: [{ type: "m.login.sso" }, { type: "m.login.password" }] }, + }); + + jest.spyOn(SettingsStore, "getValue").mockImplementation(() => false); + + jest.spyOn(AutoDiscoveryUtils, "validateServerConfigWithStaticUrls").mockResolvedValue({ + hsName: "example.com", + } as ValidatedServerConfig); + + mockPlatformPeg({ + startSingleSignOn: jest.fn(), + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fetchMock.restore(); + SdkConfig.reset(); // we touch the config, so clean up + unmockPlatformPeg(); + }); + + /** TODO weird behavior of requestregister which the mock is not detected + * So it will al { + // addSSOFlowToMockConfig(true); + + // const { container } = render(getRawComponent()); + + // await waitForElementToBeRemoved(() => screen.queryAllByTestId("spinner")); + + // screen.debug(); + + // expect(container.getElementsByClassName("tc_pronnect").length).toBe(1); + // }); + + it("returns no proconnect button when the config does'nt include sso flow", () => { + addSSOFlowToMockConfig(false); + + const { container } = render(getRawComponent()); + + expect(container.getElementsByClassName("tc_pronnect").length).toBe(0); + }); +}); diff --git a/test/unit-tests/tchap/components/views/sso/Welcome-test.tsx b/test/unit-tests/tchap/components/views/sso/Welcome-test.tsx new file mode 100644 index 000000000..2c08d9ccc --- /dev/null +++ b/test/unit-tests/tchap/components/views/sso/Welcome-test.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { render, cleanup, screen } from "@testing-library/react"; +import fetchMock from "fetch-mock"; + +import SdkConfig, { ConfigOptions } from "~matrix-react-sdk/src/SdkConfig"; +import Welcome from "~matrix-react-sdk/src/components/views/auth/Welcome"; +import { flushPromises } from "~matrix-react-sdk/test/test-utils"; + +describe("", () => { + const addSSOFlowToMockConfig = (isActive: boolean = false) => { + // mock SdkConfig.get("tchap_features") + const config: ConfigOptions = { tchap_sso_flow: { isActive } }; + SdkConfig.put(config); + }; + + const renderWelcomePage = () => render(); + + afterEach(() => { + cleanup(); + }); + + it("returns welcome_sso html when sso_flow is active in config ", async () => { + addSSOFlowToMockConfig(true); + + // we need to mock the call to the correct html page, since it is embeded in the component + // we don't need to mock the other html page since it shouldnt call it, otherwise it will simply throw an error + fetchMock.get("/welcome_sso.html", { body: "

SSO

" }); + + renderWelcomePage(); + await flushPromises(); + + // the component should choose the correct html page based on the sso_flo active value + expect(screen.getByRole("heading", { level: 1 }).textContent).toEqual("SSO"); + }); + + it("returns normal welcome html page without sso flow ", async () => { + addSSOFlowToMockConfig(false); + + // we need to mock the call to the correct html page, since it is embeded in the component + // we don't need to mock the other html page since it shouldnt call it, otherwise it will simply throw an error + fetchMock.get("/welcome.html", { body: "

Welcome

" }); + + renderWelcomePage(); + await flushPromises(); + + // the component should choose the correct html page based on the sso_flo active value + expect(screen.getByRole("heading", { level: 1 }).textContent).toEqual("Welcome"); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 4b637168c..e627c328f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -758,6 +758,9 @@ module.exports = (env, argv) => { "res/jitsi_external_api.min.js.LICENSE.txt", "res/manifest.json", "res/welcome.html", + // :TCHAP: sso-agentconnect-flow + "res/welcome_sso.html", + // end :TCHAP: { from: "welcome/**", context: path.resolve(__dirname, "res") }, { from: "themes/**", context: path.resolve(__dirname, "res") }, { from: "vector-icons/**", context: path.resolve(__dirname, "res") },