From b8f0361157a255b0525714b64f7d0dd3aedaddc2 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Sun, 15 Aug 2021 16:21:00 +0530 Subject: [PATCH] Split login view into password and sso components Signed-off-by: RMidhunSuresh --- src/domain/LoginViewModel.js | 120 ------------------ src/domain/RootViewModel.js | 17 ++- src/domain/login/LoginViewModel.js | 105 +++++++++++++++ src/domain/login/PasswordLoginViewModel.js | 77 +++++++++++ src/domain/login/SSOLoginViewModel.js | 46 +++++++ src/domain/login/common.js | 23 ++++ src/platform/web/ui/RootView.js | 3 - src/platform/web/ui/login/CompleteSSOView.js | 8 +- src/platform/web/ui/login/LoginView.js | 76 ++++------- .../web/ui/login/PasswordLoginView.js | 71 +++++++++++ 10 files changed, 361 insertions(+), 185 deletions(-) delete mode 100644 src/domain/LoginViewModel.js create mode 100644 src/domain/login/LoginViewModel.js create mode 100644 src/domain/login/PasswordLoginViewModel.js create mode 100644 src/domain/login/SSOLoginViewModel.js create mode 100644 src/domain/login/common.js create mode 100644 src/platform/web/ui/login/PasswordLoginView.js diff --git a/src/domain/LoginViewModel.js b/src/domain/LoginViewModel.js deleted file mode 100644 index 5e0702da..00000000 --- a/src/domain/LoginViewModel.js +++ /dev/null @@ -1,120 +0,0 @@ -/* -Copyright 2020 Bruno Windels - -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 {ViewModel} from "./ViewModel.js"; -import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; - -function normalizeHomeserver(homeServer) { - try { - return new URL(homeServer).origin; - } catch (err) { - return new URL(`https://${homeServer}`).origin; - } -} - -export class LoginViewModel extends ViewModel { - constructor(options) { - super(options); - const {ready, defaultHomeServer, createSessionContainer} = options; - this._createSessionContainer = createSessionContainer; - this._ready = ready; - this._defaultHomeServer = defaultHomeServer; - this._sessionContainer = null; - this._loadViewModel = null; - this._loadViewModelSubscription = null; - this._supportsSSOLogin = false; - this.queryLogin(); - } - - get defaultHomeServer() { return this._defaultHomeServer; } - - get loadViewModel() {return this._loadViewModel; } - - get isBusy() { - if (!this._loadViewModel) { - return false; - } else { - return this._loadViewModel.loading; - } - } - - async queryLogin(homeServer = this.defaultHomeServer) { - // See if we support SSO, if so shows SSO link - /* For this, we'd need to poll queryLogin before we do login() - */ - if (!this._sessionContainer) { - this._sessionContainer = this._createSessionContainer(); - } - const normalizedHS = normalizeHomeserver(homeServer); - try { - this.loginOptions = await this._sessionContainer.queryLogin(normalizedHS); - this._supportsSSOLogin = !!this.loginOptions.sso; - } - catch (e) { - // Something went wrong, assume SSO is not supported - this._supportsSSOLogin = false; - console.error("Could not query login methods supported by the homeserver"); - } - this.emitChange("supportsSSOLogin"); - } - - async login(username, password, homeserver) { - homeserver = normalizeHomeserver(homeserver); - this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); - if (this._loadViewModel) { - this._loadViewModel = this.disposeTracked(this._loadViewModel); - } - this._loadViewModel = this.track(new SessionLoadViewModel(this.childOptions({ - createAndStartSessionContainer: async () => { - if (this.loginOptions.password) { - this._sessionContainer.startWithLogin(this.loginOptions.password(username, password)); - } - return this._sessionContainer; - }, - ready: sessionContainer => { - // make sure we don't delete the session in dispose when navigating away - this._sessionContainer = null; - this._ready(sessionContainer); - }, - homeserver, - }))); - this._loadViewModel.start(); - this.emitChange("loadViewModel"); - this._loadViewModelSubscription = this.track(this._loadViewModel.disposableOn("change", () => { - if (!this._loadViewModel.loading) { - this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); - } - this.emitChange("isBusy"); - })); - } - - get cancelUrl() { - return this.urlCreator.urlForSegment("session"); - } - - get supportsSSOLogin() { - return this._supportsSSOLogin; - } - - dispose() { - super.dispose(); - if (this._sessionContainer) { - // if we move away before we're done with initial sync - // delete the session - this._sessionContainer.deleteSession(); - } - } -} diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index 643655dc..c47762c5 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -16,7 +16,7 @@ limitations under the License. import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; -import {LoginViewModel} from "./LoginViewModel.js"; +import {LoginViewModel} from "./login/LoginViewModel.js"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {ViewModel} from "./ViewModel.js"; @@ -42,7 +42,8 @@ export class RootViewModel extends ViewModel { async _applyNavigation(shouldRestoreLastUrl) { const isLogin = this.navigation.observe("login").get(); const sessionId = this.navigation.observe("session").get(); - const SSOSegment = this.navigation.path.get("sso"); + // TODO: why not observe? + const ssoSegment = this.navigation.path.get("sso"); if (isLogin) { if (this.activeSection !== "login") { this._showLogin(); @@ -67,8 +68,11 @@ export class RootViewModel extends ViewModel { this._showSessionLoader(sessionId); } } - } else if (SSOSegment) { - this._setSection(() => this.showCompletionView = true); + } else if (ssoSegment) { + this.urlCreator.normalizeUrl(); + if (this.activeSection !== "login") { + this._showLogin({loginToken: ssoSegment.value}); + } } else { try { @@ -99,7 +103,7 @@ export class RootViewModel extends ViewModel { } } - _showLogin() { + _showLogin(options) { this._setSection(() => { this._loginViewModel = new LoginViewModel(this.childOptions({ defaultHomeServer: this.platform.config["defaultHomeServer"], @@ -116,6 +120,7 @@ export class RootViewModel extends ViewModel { this._pendingSessionContainer = sessionContainer; this.navigation.push("session", sessionContainer.sessionId); }, + ...options })); }); } @@ -152,8 +157,6 @@ export class RootViewModel extends ViewModel { return "picker"; } else if (this._sessionLoadViewModel) { return "loading"; - } else if (this.showCompletionView) { - return "sso"; } else { return "redirecting"; } diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.js new file mode 100644 index 00000000..3d747024 --- /dev/null +++ b/src/domain/login/LoginViewModel.js @@ -0,0 +1,105 @@ +/* +Copyright 2020 Bruno Windels + +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 {ViewModel} from "../ViewModel.js"; +import {PasswordLoginViewModel} from "./PasswordLoginViewModel.js"; +import {SSOLoginViewModel} from "./SSOLoginViewModel.js"; +import {normalizeHomeserver} from "./common.js"; + +export class LoginViewModel extends ViewModel { + constructor(options) { + super(options); + const {ready, defaultHomeServer, createSessionContainer, loginToken} = options; + this._createSessionContainer = createSessionContainer; + this._ready = ready; + this._defaultHomeServer = defaultHomeServer; + this._loginToken = loginToken; + this._sessionContainer = this._createSessionContainer(); + this._loginOptions = null; + this._start(); + } + + get passwordLoginViewModel() { return this._passwordLoginViewModel; } + get ssoLoginViewModel() { return this._ssoLoginViewModel; } + get loadViewModel() {return this._loadViewModel; } + + async _start() { + if (this._loginToken) { + this._ssoLoginViewModel = this.track(new SSOLoginViewModel(this.childOptions({loginToken: this._loginToken}))); + this.emitChange("ssoLoginViewModel"); + } + else { + const defaultHomeServer = normalizeHomeserver(this._defaultHomeServer); + await this.queryLogin(defaultHomeServer); + this._showPasswordLogin(); + this._showSSOLogin(defaultHomeServer); + } + } + + _showPasswordLogin() { + this._passwordLoginViewModel = new PasswordLoginViewModel(this.childOptions({defaultHomeServer: this._defaultHomeServer})); + const observable = this._passwordLoginViewModel.homeserverObservable; + this.track(observable.subscribe(newHomeServer => this._onHomeServerChange(newHomeServer))); + this.emitChange("passwordLoginViewModel"); + } + + _showSSOLogin(homeserver) { + this._ssoLoginViewModel = this.disposeTracked(this._ssoLoginViewModel); + this.emitChange("ssoLoginViewModel"); + if (this._loginOptions?.sso && !this._loginToken) { + this._ssoLoginViewModel = this.track(new SSOLoginViewModel(this.childOptions({homeserver}))); + this.emitChange("ssoLoginViewModel"); + } + } + + async queryLogin(homeserver) { + try { + this._loginOptions = await this._sessionContainer.queryLogin(homeserver); + } + catch (e) { + this._loginOptions = null; + console.error("Could not query login methods supported by the homeserver"); + } + } + + async _onHomeServerChange(homeserver) { + const normalizedHS = normalizeHomeserver(homeserver); + await this.queryLogin(normalizedHS); + this._showSSOLogin(normalizedHS); + } + + childOptions(options) { + return { + ...super.childOptions(options), + ready: sessionContainer => { + // make sure we don't delete the session in dispose when navigating away + this._sessionContainer = null; + this._ready(sessionContainer); + }, + sessionContainer: this._sessionContainer, + loginOptions: this._loginOptions + } + } + + dispose() { + super.dispose(); + if (this._sessionContainer) { + // if we move away before we're done with initial sync + // delete the session + this._sessionContainer.deleteSession(); + } + } +} diff --git a/src/domain/login/PasswordLoginViewModel.js b/src/domain/login/PasswordLoginViewModel.js new file mode 100644 index 00000000..2b4700d3 --- /dev/null +++ b/src/domain/login/PasswordLoginViewModel.js @@ -0,0 +1,77 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +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 {ViewModel} from "../ViewModel.js"; +import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; +import {ObservableValue} from "../../observable/ObservableValue.js"; +import {normalizeHomeserver} from "./common.js"; + +export class PasswordLoginViewModel extends ViewModel { + constructor(options) { + super(options); + const {ready, defaultHomeServer, loginOptions, sessionContainer} = options; + this._ready = ready; + this._defaultHomeServer = defaultHomeServer; + this._sessionContainer = sessionContainer; + this._loadViewModel = null; + this._loadViewModelSubscription = null; + this._loginOptions = loginOptions; + this._homeserverObservable = new ObservableValue(this._defaultHomeServer); + } + + get defaultHomeServer() { return this._defaultHomeServer; } + get loadViewModel() {return this._loadViewModel; } + get homeserverObservable() { return this._homeserverObservable; } + get cancelUrl() { return this.urlCreator.urlForSegment("session"); } + + updateHomeServer(homeserver) { + this._homeserverObservable.set(homeserver); + } + + get isBusy() { + if (!this._loadViewModel) { + return false; + } else { + return this._loadViewModel.loading; + } + } + + async login(username, password, homeserver) { + homeserver = normalizeHomeserver(homeserver); + this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); + if (this._loadViewModel) { + this._loadViewModel = this.disposeTracked(this._loadViewModel); + } + this._loadViewModel = this.track(new SessionLoadViewModel(this.childOptions({ + createAndStartSessionContainer: async () => { + if (this._loginOptions.password) { + this._sessionContainer.startWithLogin(this._loginOptions.password(username, password)); + } + return this._sessionContainer; + }, + ready: this._ready, + homeserver, + }))); + this._loadViewModel.start(); + this.emitChange("loadViewModel"); + this._loadViewModelSubscription = this.track(this._loadViewModel.disposableOn("change", () => { + if (!this._loadViewModel.loading) { + this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); + } + this.emitChange("isBusy"); + })); + } +} diff --git a/src/domain/login/SSOLoginViewModel.js b/src/domain/login/SSOLoginViewModel.js new file mode 100644 index 00000000..10691a37 --- /dev/null +++ b/src/domain/login/SSOLoginViewModel.js @@ -0,0 +1,46 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +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 {ViewModel} from "../ViewModel.js"; + +export class SSOLoginViewModel extends ViewModel{ + constructor(options) { + super(options); + const { + loginToken, + sessionContainer, + loginOptions, + ready, + homeserver + } = options; + this._loginToken = loginToken; + this._ready = ready; + this._sessionContainer = sessionContainer; + this._homeserver = homeserver; + this._loadViewModelSubscription = null; + this._loadViewModel = null; + this._loginOptions = loginOptions; + } + + get loadViewModel() { return this._loadViewModel; } + get supportsSSOLogin() { return this._supportsSSOLogin; } + get isSSOCompletion() { return !!this._loginToken; } + + + async startSSOLogin() { + console.log("Next PR"); + } +} diff --git a/src/domain/login/common.js b/src/domain/login/common.js new file mode 100644 index 00000000..8bc162e9 --- /dev/null +++ b/src/domain/login/common.js @@ -0,0 +1,23 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +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. +*/ + +export function normalizeHomeserver(homeServer) { + try { + return new URL(homeServer).origin; + } catch (err) { + return new URL(`https://${homeServer}`).origin; + } +} diff --git a/src/platform/web/ui/RootView.js b/src/platform/web/ui/RootView.js index 02fd4d7d..f60bb984 100644 --- a/src/platform/web/ui/RootView.js +++ b/src/platform/web/ui/RootView.js @@ -20,7 +20,6 @@ import {SessionLoadView} from "./login/SessionLoadView.js"; import {SessionPickerView} from "./login/SessionPickerView.js"; import {TemplateView} from "./general/TemplateView.js"; import {StaticView} from "./general/StaticView.js"; -import {CompleteSSOView} from "./login/CompleteSSOView.js"; export class RootView extends TemplateView { render(t, vm) { @@ -43,8 +42,6 @@ export class RootView extends TemplateView { return new StaticView(t => t.p("Redirecting...")); case "loading": return new SessionLoadView(vm.sessionLoadViewModel); - case "sso": - return new CompleteSSOView(); default: throw new Error(`Unknown section: ${vm.activeSection}`); } diff --git a/src/platform/web/ui/login/CompleteSSOView.js b/src/platform/web/ui/login/CompleteSSOView.js index 2b6e9d02..a31072bb 100644 --- a/src/platform/web/ui/login/CompleteSSOView.js +++ b/src/platform/web/ui/login/CompleteSSOView.js @@ -15,9 +15,15 @@ limitations under the License. */ import {TemplateView} from "../general/TemplateView.js"; +import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; export class CompleteSSOView extends TemplateView { render(t) { - return t.div({ className: "CompleteSSOView" }, "Finishing up SSO Login ..."); + return t.div({ className: "CompleteSSOView" }, + [ + "Finishing up SSO Login ...", + t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null) + ] + ); } } diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index 6ba9a575..3e7a4fa6 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -16,63 +16,31 @@ limitations under the License. import {TemplateView} from "../general/TemplateView.js"; import {hydrogenGithubLink} from "./common.js"; -import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; +import {PasswordLoginView} from "./PasswordLoginView.js"; +import {CompleteSSOView} from "./CompleteSSOView.js"; export class LoginView extends TemplateView { - render(t, vm) { - const disabled = vm => !!vm.isBusy; - const username = t.input({ - id: "username", - type: "text", - placeholder: vm.i18n`Username`, - disabled - }); - const password = t.input({ - id: "password", - type: "password", - placeholder: vm.i18n`Password`, - disabled - }); - const homeserver = t.input({ - id: "homeserver", - type: "text", - placeholder: vm.i18n`Your matrix homeserver`, - value: vm.defaultHomeServer, - onChange: () => vm.queryLogin(homeserver.value), - disabled - }); - - return t.div({className: "PreSessionScreen"}, [ - t.div({className: "logo"}), - t.div({className: "LoginView form"}, [ - t.h1([vm.i18n`Sign In`]), - t.if(vm => vm.error, t => t.div({className: "error"}, vm => vm.error)), - t.form({ - onSubmit: evnt => { - evnt.preventDefault(); - vm.login(username.value, password.value, homeserver.value); - } - }, [ - t.div({className: "form-row"}, [t.label({for: "username"}, vm.i18n`Username`), username]), - t.div({className: "form-row"}, [t.label({for: "password"}, vm.i18n`Password`), password]), - t.div({className: "form-row"}, [t.label({for: "homeserver"}, vm.i18n`Homeserver`), homeserver]), - t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null), - t.div({className: "button-row"}, [ - t.a({ - className: "button-action secondary", - href: vm.cancelUrl - }, [vm.i18n`Go Back`]), - t.button({ - className: "button-action primary", - type: "submit" - }, vm.i18n`Log In`), - ]), - ]), - t.if(vm => vm.supportsSSOLogin, () => t.button({className: "SSO"}, "Login with SSO")), - // use t.mapView rather than t.if to create a new view when the view model changes too - t.p(hydrogenGithubLink(t)) - ]) + render(t) { + return t.div({ className: "PreSessionScreen" }, [ + t.div({ className: "logo" }), + t.mapView(vm => vm.passwordLoginViewModel, vm => vm? new PasswordLoginView(vm): null), + t.mapView(vm => vm.ssoLoginViewModel, vm => { + if (vm?.isSSOCompletion) { + return new CompleteSSOView(vm); + } + else if (vm) { + return new SSOLoginView(vm); + } + return null; + } ), + // use t.mapView rather than t.if to create a new view when the view model changes too + t.p(hydrogenGithubLink(t)) ]); } } +class SSOLoginView extends TemplateView { + render(t, vm) { + return t.button({className: "SSO", type: "button", onClick: () => vm.startSSOLogin()}, "Login with SSO"); + } +} diff --git a/src/platform/web/ui/login/PasswordLoginView.js b/src/platform/web/ui/login/PasswordLoginView.js new file mode 100644 index 00000000..7a41f2b5 --- /dev/null +++ b/src/platform/web/ui/login/PasswordLoginView.js @@ -0,0 +1,71 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +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 {TemplateView} from "../general/TemplateView.js"; +import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; + +export class PasswordLoginView extends TemplateView { + render(t, vm) { + const disabled = vm => !!vm.isBusy; + const username = t.input({ + id: "username", + type: "text", + placeholder: vm.i18n`Username`, + disabled + }); + const password = t.input({ + id: "password", + type: "password", + placeholder: vm.i18n`Password`, + disabled + }); + const homeserver = t.input({ + id: "homeserver", + type: "text", + placeholder: vm.i18n`Your matrix homeserver`, + value: vm.defaultHomeServer, + onChange: () => vm.updateHomeServer(homeserver.value), + disabled + }); + + return t.div({className: "LoginView form"}, [ + t.h1([vm.i18n`Sign In`]), + t.if(vm => vm.error, t => t.div({ className: "error" }, vm => vm.error)), + t.form({ + onSubmit: evnt => { + evnt.preventDefault(); + vm.login(username.value, password.value, homeserver.value); + } + }, [ + t.div({ className: "form-row" }, [t.label({ for: "username" }, vm.i18n`Username`), username]), + t.div({ className: "form-row" }, [t.label({ for: "password" }, vm.i18n`Password`), password]), + t.div({ className: "form-row" }, [t.label({ for: "homeserver" }, vm.i18n`Homeserver`), homeserver]), + t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null), + t.div({ className: "button-row" }, [ + t.a({ + className: "button-action secondary", + href: vm.cancelUrl + }, [vm.i18n`Go Back`]), + t.button({ + className: "button-action primary", + type: "submit" + }, vm.i18n`Log In`), + ]), + ]) + ]); + } +} +