Split login view into password and sso components

Signed-off-by: RMidhunSuresh <rmidhunsuresh@gmail.com>
This commit is contained in:
RMidhunSuresh 2021-08-15 16:21:00 +05:30
parent cabffd5e3f
commit b8f0361157
10 changed files with 361 additions and 185 deletions

View file

@ -1,120 +0,0 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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();
}
}
}

View file

@ -16,7 +16,7 @@ limitations under the License.
import {SessionViewModel} from "./session/SessionViewModel.js"; import {SessionViewModel} from "./session/SessionViewModel.js";
import {SessionLoadViewModel} from "./SessionLoadViewModel.js"; import {SessionLoadViewModel} from "./SessionLoadViewModel.js";
import {LoginViewModel} from "./LoginViewModel.js"; import {LoginViewModel} from "./login/LoginViewModel.js";
import {SessionPickerViewModel} from "./SessionPickerViewModel.js"; import {SessionPickerViewModel} from "./SessionPickerViewModel.js";
import {ViewModel} from "./ViewModel.js"; import {ViewModel} from "./ViewModel.js";
@ -42,7 +42,8 @@ export class RootViewModel extends ViewModel {
async _applyNavigation(shouldRestoreLastUrl) { async _applyNavigation(shouldRestoreLastUrl) {
const isLogin = this.navigation.observe("login").get(); const isLogin = this.navigation.observe("login").get();
const sessionId = this.navigation.observe("session").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 (isLogin) {
if (this.activeSection !== "login") { if (this.activeSection !== "login") {
this._showLogin(); this._showLogin();
@ -67,8 +68,11 @@ export class RootViewModel extends ViewModel {
this._showSessionLoader(sessionId); this._showSessionLoader(sessionId);
} }
} }
} else if (SSOSegment) { } else if (ssoSegment) {
this._setSection(() => this.showCompletionView = true); this.urlCreator.normalizeUrl();
if (this.activeSection !== "login") {
this._showLogin({loginToken: ssoSegment.value});
}
} }
else { else {
try { try {
@ -99,7 +103,7 @@ export class RootViewModel extends ViewModel {
} }
} }
_showLogin() { _showLogin(options) {
this._setSection(() => { this._setSection(() => {
this._loginViewModel = new LoginViewModel(this.childOptions({ this._loginViewModel = new LoginViewModel(this.childOptions({
defaultHomeServer: this.platform.config["defaultHomeServer"], defaultHomeServer: this.platform.config["defaultHomeServer"],
@ -116,6 +120,7 @@ export class RootViewModel extends ViewModel {
this._pendingSessionContainer = sessionContainer; this._pendingSessionContainer = sessionContainer;
this.navigation.push("session", sessionContainer.sessionId); this.navigation.push("session", sessionContainer.sessionId);
}, },
...options
})); }));
}); });
} }
@ -152,8 +157,6 @@ export class RootViewModel extends ViewModel {
return "picker"; return "picker";
} else if (this._sessionLoadViewModel) { } else if (this._sessionLoadViewModel) {
return "loading"; return "loading";
} else if (this.showCompletionView) {
return "sso";
} else { } else {
return "redirecting"; return "redirecting";
} }

View file

@ -0,0 +1,105 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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();
}
}
}

View file

@ -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");
}));
}
}

View file

@ -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");
}
}

View file

@ -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;
}
}

View file

@ -20,7 +20,6 @@ import {SessionLoadView} from "./login/SessionLoadView.js";
import {SessionPickerView} from "./login/SessionPickerView.js"; import {SessionPickerView} from "./login/SessionPickerView.js";
import {TemplateView} from "./general/TemplateView.js"; import {TemplateView} from "./general/TemplateView.js";
import {StaticView} from "./general/StaticView.js"; import {StaticView} from "./general/StaticView.js";
import {CompleteSSOView} from "./login/CompleteSSOView.js";
export class RootView extends TemplateView { export class RootView extends TemplateView {
render(t, vm) { render(t, vm) {
@ -43,8 +42,6 @@ export class RootView extends TemplateView {
return new StaticView(t => t.p("Redirecting...")); return new StaticView(t => t.p("Redirecting..."));
case "loading": case "loading":
return new SessionLoadView(vm.sessionLoadViewModel); return new SessionLoadView(vm.sessionLoadViewModel);
case "sso":
return new CompleteSSOView();
default: default:
throw new Error(`Unknown section: ${vm.activeSection}`); throw new Error(`Unknown section: ${vm.activeSection}`);
} }

View file

@ -15,9 +15,15 @@ limitations under the License.
*/ */
import {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView.js";
import {SessionLoadStatusView} from "./SessionLoadStatusView.js";
export class CompleteSSOView extends TemplateView { export class CompleteSSOView extends TemplateView {
render(t) { 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)
]
);
} }
} }

View file

@ -16,63 +16,31 @@ limitations under the License.
import {TemplateView} from "../general/TemplateView.js"; import {TemplateView} from "../general/TemplateView.js";
import {hydrogenGithubLink} from "./common.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 { export class LoginView extends TemplateView {
render(t, vm) { render(t) {
const disabled = vm => !!vm.isBusy; return t.div({ className: "PreSessionScreen" }, [
const username = t.input({ t.div({ className: "logo" }),
id: "username", t.mapView(vm => vm.passwordLoginViewModel, vm => vm? new PasswordLoginView(vm): null),
type: "text", t.mapView(vm => vm.ssoLoginViewModel, vm => {
placeholder: vm.i18n`Username`, if (vm?.isSSOCompletion) {
disabled return new CompleteSSOView(vm);
}); }
const password = t.input({ else if (vm) {
id: "password", return new SSOLoginView(vm);
type: "password", }
placeholder: vm.i18n`Password`, return null;
disabled } ),
}); // use t.mapView rather than t.if to create a new view when the view model changes too
const homeserver = t.input({ t.p(hydrogenGithubLink(t))
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))
])
]); ]);
} }
} }
class SSOLoginView extends TemplateView {
render(t, vm) {
return t.button({className: "SSO", type: "button", onClick: () => vm.startSSOLogin()}, "Login with SSO");
}
}

View file

@ -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`),
]),
])
]);
}
}