diff --git a/src/domain/LoginViewModel.js b/src/domain/LoginViewModel.js deleted file mode 100644 index 1b52e1a5..00000000 --- a/src/domain/LoginViewModel.js +++ /dev/null @@ -1,84 +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"; - -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; - } - - get defaultHomeServer() { return this._defaultHomeServer; } - - get loadViewModel() {return this._loadViewModel; } - - get isBusy() { - if (!this._loadViewModel) { - return false; - } else { - return this._loadViewModel.loading; - } - } - - async login(username, password, 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: () => { - this._sessionContainer = this._createSessionContainer(); - this._sessionContainer.startWithLogin(homeserver, 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"); - } - - 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 fca8d779..d6fdcfa0 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"; @@ -35,12 +35,14 @@ export class RootViewModel extends ViewModel { async load() { this.track(this.navigation.observe("login").subscribe(() => this._applyNavigation())); this.track(this.navigation.observe("session").subscribe(() => this._applyNavigation())); + this.track(this.navigation.observe("sso").subscribe(() => this._applyNavigation())); this._applyNavigation(true); } async _applyNavigation(shouldRestoreLastUrl) { - const isLogin = this.navigation.observe("login").get(); - const sessionId = this.navigation.observe("session").get(); + const isLogin = this.navigation.path.get("login") + const sessionId = this.navigation.path.get("session")?.value; + const loginToken = this.navigation.path.get("sso")?.value; if (isLogin) { if (this.activeSection !== "login") { this._showLogin(); @@ -65,7 +67,13 @@ export class RootViewModel extends ViewModel { this._showSessionLoader(sessionId); } } - } else { + } else if (loginToken) { + this.urlCreator.normalizeUrl(); + if (this.activeSection !== "login") { + this._showLogin(loginToken); + } + } + else { try { if (!(shouldRestoreLastUrl && this.urlCreator.tryRestoreLastUrl())) { const sessionInfos = await this.platform.sessionInfoStorage.getAll(); @@ -94,7 +102,7 @@ export class RootViewModel extends ViewModel { } } - _showLogin() { + _showLogin(loginToken) { this._setSection(() => { this._loginViewModel = new LoginViewModel(this.childOptions({ defaultHomeServer: this.platform.config["defaultHomeServer"], @@ -111,6 +119,7 @@ export class RootViewModel extends ViewModel { this._pendingSessionContainer = sessionContainer; this.navigation.push("session", sessionContainer.sessionId); }, + loginToken })); }); } @@ -123,13 +132,11 @@ export class RootViewModel extends ViewModel { } _showSessionLoader(sessionId) { + const sessionContainer = this._createSessionContainer(); + sessionContainer.startWithExistingSession(sessionId); this._setSection(() => { this._sessionLoadViewModel = new SessionLoadViewModel(this.childOptions({ - createAndStartSessionContainer: () => { - const sessionContainer = this._createSessionContainer(); - sessionContainer.startWithExistingSession(sessionId); - return sessionContainer; - }, + sessionContainer, ready: sessionContainer => this._showSession(sessionContainer) })); this._sessionLoadViewModel.start(); diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js index 0b785e47..10cbb851 100644 --- a/src/domain/SessionLoadViewModel.js +++ b/src/domain/SessionLoadViewModel.js @@ -14,15 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {LoadStatus, LoginFailure} from "../matrix/SessionContainer.js"; +import {LoadStatus} from "../matrix/SessionContainer.js"; import {SyncStatus} from "../matrix/Sync.js"; import {ViewModel} from "./ViewModel.js"; export class SessionLoadViewModel extends ViewModel { constructor(options) { super(options); - const {createAndStartSessionContainer, ready, homeserver, deleteSessionOnCancel} = options; - this._createAndStartSessionContainer = createAndStartSessionContainer; + const {sessionContainer, ready, homeserver, deleteSessionOnCancel} = options; + this._sessionContainer = sessionContainer; this._ready = ready; this._homeserver = homeserver; this._deleteSessionOnCancel = deleteSessionOnCancel; @@ -38,7 +38,6 @@ export class SessionLoadViewModel extends ViewModel { try { this._loading = true; this.emitChange("loading"); - this._sessionContainer = this._createAndStartSessionContainer(); this._waitHandle = this._sessionContainer.loadStatus.waitFor(s => { this.emitChange("loadLabel"); // wait for initial sync, but not catchup sync @@ -109,22 +108,9 @@ export class SessionLoadViewModel extends ViewModel { return `Something went wrong: ${error && error.message}.`; } + // Statuses related to login are handled by respective login view models if (sc) { switch (sc.loadStatus.get()) { - case LoadStatus.NotLoading: - return `Preparing…`; - case LoadStatus.Login: - return `Checking your login and password…`; - case LoadStatus.LoginFailed: - switch (sc.loginFailure) { - case LoginFailure.LoginFailure: - return `Your username and/or password don't seem to be correct.`; - case LoginFailure.Connection: - return `Can't connect to ${this._homeserver}.`; - case LoginFailure.Unknown: - return `Something went wrong while checking your login and password.`; - } - break; case LoadStatus.SessionSetup: return `Setting up your encryption keys…`; case LoadStatus.Loading: diff --git a/src/domain/login/CompleteSSOLoginViewModel.js b/src/domain/login/CompleteSSOLoginViewModel.js new file mode 100644 index 00000000..b7e04cc8 --- /dev/null +++ b/src/domain/login/CompleteSSOLoginViewModel.js @@ -0,0 +1,76 @@ +/* +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 {LoginFailure} from "../../matrix/SessionContainer.js"; + +export class CompleteSSOLoginViewModel extends ViewModel { + constructor(options) { + super(options); + const { + loginToken, + sessionContainer, + attemptLogin, + } = options; + this._loginToken = loginToken; + this._sessionContainer = sessionContainer; + this._attemptLogin = attemptLogin; + this._errorMessage = ""; + this.performSSOLoginCompletion(); + } + + get errorMessage() { return this._errorMessage; } + + _showError(message) { + this._errorMessage = message; + this.emitChange("errorMessage"); + } + + async performSSOLoginCompletion() { + if (!this._loginToken) { + return; + } + const homeserver = await this.platform.settingsStorage.getString("sso_ongoing_login_homeserver"); + let loginOptions; + try { + loginOptions = await this._sessionContainer.queryLogin(homeserver); + } + catch (err) { + this._showError(err.message); + return; + } + if (!loginOptions.token) { + this.navigation.push("session"); + return; + } + const status = await this._attemptLogin(loginOptions.token(this._loginToken)); + let error = ""; + switch (status) { + case LoginFailure.Credentials: + error = this.i18n`Your login token is invalid.`; + break; + case LoginFailure.Connection: + error = this.i18n`Can't connect to ${homeserver}.`; + break; + case LoginFailure.Unknown: + error = this.i18n`Something went wrong while checking your login token.`; + break; + } + if (error) { + this._showError(error); + } + } +} diff --git a/src/domain/login/LoginViewModel.js b/src/domain/login/LoginViewModel.js new file mode 100644 index 00000000..85dddb79 --- /dev/null +++ b/src/domain/login/LoginViewModel.js @@ -0,0 +1,194 @@ +/* +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 {StartSSOLoginViewModel} from "./StartSSOLoginViewModel.js"; +import {CompleteSSOLoginViewModel} from "./CompleteSSOLoginViewModel.js"; +import {LoadStatus} from "../../matrix/SessionContainer.js"; +import {SessionLoadViewModel} from "../SessionLoadViewModel.js"; + +export class LoginViewModel extends ViewModel { + constructor(options) { + super(options); + const {ready, defaultHomeServer, createSessionContainer, loginToken} = options; + this._createSessionContainer = createSessionContainer; + this._ready = ready; + this._loginToken = loginToken; + this._sessionContainer = this._createSessionContainer(); + this._loginOptions = null; + this._passwordLoginViewModel = null; + this._startSSOLoginViewModel = null; + this._completeSSOLoginViewModel = null; + this._loadViewModel = null; + this._loadViewModelSubscription = null; + this._homeserver = defaultHomeServer; + this._errorMessage = ""; + this._hideHomeserver = false; + this._isBusy = false; + this._isFetchingLoginOptions = false; + this._createViewModels(this._homeserver); + } + + get passwordLoginViewModel() { return this._passwordLoginViewModel; } + get startSSOLoginViewModel() { return this._startSSOLoginViewModel; } + get completeSSOLoginViewModel(){ return this._completeSSOLoginViewModel; } + get homeserver() { return this._homeserver; } + get errorMessage() { return this._errorMessage; } + get showHomeserver() { return !this._hideHomeserver; } + get loadViewModel() {return this._loadViewModel; } + get isBusy() { return this._isBusy; } + get isFetchingLoginOptions() { return this._isFetchingLoginOptions; } + + goBack() { + this.navigation.push("session"); + } + + async _createViewModels(homeserver) { + if (this._loginToken) { + this._hideHomeserver = true; + this._completeSSOLoginViewModel = this.track(new CompleteSSOLoginViewModel( + this.childOptions( + { + sessionContainer: this._sessionContainer, + attemptLogin: loginMethod => this.attemptLogin(loginMethod), + loginToken: this._loginToken + }))); + this.emitChange("completeSSOLoginViewModel"); + } + else { + this._errorMessage = ""; + try { + this._isFetchingLoginOptions = true; + this.emitChange("isFetchingLoginOptions"); + this._loginOptions = await this._sessionContainer.queryLogin(homeserver); + } + catch (e) { + this._loginOptions = null; + } + this._isFetchingLoginOptions = false; + this.emitChange("isFetchingLoginOptions"); + if (this._loginOptions) { + if (this._loginOptions.sso) { this._showSSOLogin(); } + if (this._loginOptions.password) { this._showPasswordLogin(); } + if (!this._loginOptions.sso && !this._loginOptions.password) { + this._showError("This homeserver neither supports SSO nor Password based login flows"); + } + } + else { + this._showError("Could not query login methods supported by the homeserver"); + } + } + } + + _showPasswordLogin() { + this._passwordLoginViewModel = this.track(new PasswordLoginViewModel( + this.childOptions({ + loginOptions: this._loginOptions, + attemptLogin: loginMethod => this.attemptLogin(loginMethod) + }))); + this.emitChange("passwordLoginViewModel"); + } + + _showSSOLogin() { + this._startSSOLoginViewModel = this.track( + new StartSSOLoginViewModel(this.childOptions({loginOptions: this._loginOptions})) + ); + this.emitChange("startSSOLoginViewModel"); + } + + _showError(message) { + this._errorMessage = message; + this.emitChange("errorMessage"); + } + + _setBusy(status) { + this._isBusy = status; + this._passwordLoginViewModel?.setBusy(status); + this._startSSOLoginViewModel?.setBusy(status); + this.emitChange("isBusy"); + } + + async attemptLogin(loginMethod) { + this._setBusy(true); + this._sessionContainer.startWithLogin(loginMethod); + const loadStatus = this._sessionContainer.loadStatus; + const handle = loadStatus.waitFor(status => status !== LoadStatus.Login); + await handle.promise; + this._setBusy(false); + const status = loadStatus.get(); + if (status === LoadStatus.LoginFailed) { + return this._sessionContainer.loginFailure; + } + this._hideHomeserver = true; + this.emitChange("hideHomeserver"); + this._disposeViewModels(); + this._createLoadViewModel(); + return null; + } + + _createLoadViewModel() { + this._loadViewModelSubscription = this.disposeTracked(this._loadViewModelSubscription); + this._loadViewModel = this.disposeTracked(this._loadViewModel); + this._loadViewModel = this.track( + new SessionLoadViewModel( + this.childOptions({ + ready: (sessionContainer) => { + // make sure we don't delete the session in dispose when navigating away + this._sessionContainer = null; + this._ready(sessionContainer); + }, + sessionContainer: this._sessionContainer, + homeserver: this._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._setBusy(false); + }) + ); + } + + _disposeViewModels() { + this._startSSOLoginViewModel = this.disposeTracked(this._ssoLoginViewModel); + this._passwordLoginViewModel = this.disposeTracked(this._passwordLoginViewModel); + this._completeSSOLoginViewModel = this.disposeTracked(this._completeSSOLoginViewModel); + this.emitChange("disposeViewModels"); + } + + updateHomeServer(newHomeserver) { + this._errorMessage = ""; + this.emitChange("errorMessage"); + this._homeserver = newHomeserver; + this._disposeViewModels(); + this._createViewModels(newHomeserver); + } + + 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..f8d354a4 --- /dev/null +++ b/src/domain/login/PasswordLoginViewModel.js @@ -0,0 +1,64 @@ +/* +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 {LoginFailure} from "../../matrix/SessionContainer.js"; + +export class PasswordLoginViewModel extends ViewModel { + constructor(options) { + super(options); + const {loginOptions, attemptLogin} = options; + this._loginOptions = loginOptions; + this._attemptLogin = attemptLogin; + this._isBusy = false; + this._errorMessage = ""; + } + + get isBusy() { return this._isBusy; } + get errorMessage() { return this._errorMessage; } + + setBusy(status) { + this._isBusy = status; + this.emitChange("isBusy"); + } + + _showError(message) { + this._errorMessage = message; + this.emitChange("errorMessage"); + } + + async login(username, password) { + this._errorMessage = ""; + this.emitChange("errorMessage"); + const loginMethod = this._loginOptions.password(username, password); + const status = await this._attemptLogin(loginMethod); + let error = ""; + switch (status) { + case LoginFailure.Credentials: + error = this.i18n`Your username and/or password don't seem to be correct.`; + break; + case LoginFailure.Connection: + error = this.i18n`Can't connect to ${loginMethod.homeServer}.`; + break; + case LoginFailure.Unknown: + error = this.i18n`Something went wrong while checking your login and password.`; + break; + } + if (error) { + this._showError(error); + } + } +} diff --git a/src/domain/login/StartSSOLoginViewModel.js b/src/domain/login/StartSSOLoginViewModel.js new file mode 100644 index 00000000..54218d22 --- /dev/null +++ b/src/domain/login/StartSSOLoginViewModel.js @@ -0,0 +1,38 @@ +/* +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 StartSSOLoginViewModel extends ViewModel{ + constructor(options) { + super(options); + this._sso = options.loginOptions.sso; + this._isBusy = false; + } + + get isBusy() { return this._isBusy; } + + setBusy(status) { + this._isBusy = status; + this.emitChange("isBusy"); + } + + async startSSOLogin() { + await this.platform.settingsStorage.setString("sso_ongoing_login_homeserver", this._sso.homeserver); + const link = this._sso.createSSORedirectURL(this.urlCreator.createSSOCallbackURL()); + this.platform.openUrl(link); + } +} diff --git a/src/domain/navigation/URLRouter.js b/src/domain/navigation/URLRouter.js index 28488129..586eec8a 100644 --- a/src/domain/navigation/URLRouter.js +++ b/src/domain/navigation/URLRouter.js @@ -120,4 +120,14 @@ export class URLRouter { const urlPath = `${this._stringifyPath(this._navigation.path.until("session"))}/open-room/${roomId}`; return this._history.pathAsUrl(urlPath); } + + createSSOCallbackURL() { + return window.location.origin; + } + + normalizeUrl() { + // Remove any queryParameters from the URL + // Gets rid of the loginToken after SSO + this._history.replaceUrlSilently(`${window.location.origin}/${window.location.hash}`); + } } diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index dbac16ac..d21bcad4 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -30,7 +30,7 @@ function allowsChild(parent, child) { switch (parent?.type) { case undefined: // allowed root segments - return type === "login" || type === "session"; + return type === "login" || type === "session" || type === "sso"; case "session": return type === "room" || type === "rooms" || type === "settings"; case "rooms": @@ -152,6 +152,10 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) { const userId = iterator.next().value; if (!userId) { break; } pushRightPanelSegment(segments, type, userId); + } else if (type.includes("loginToken")) { + // Special case for SSO-login with query parameter loginToken= + const loginToken = type.split("=").pop(); + segments.push(new Segment("sso", loginToken)); } else { // might be undefined, which will be turned into true by Segment const value = iterator.next().value; @@ -181,7 +185,8 @@ export function stringifyPath(path) { } break; case "right-panel": - // Ignore right-panel in url + case "sso": + // Do not put these segments in URL continue; default: urlPath += `/${segment.type}`; @@ -228,6 +233,12 @@ export function tests() { const urlPath = stringifyPath(path); assert.equal(urlPath, "/session/1/rooms/a,b,c/1/details"); }, + "Parse loginToken query parameter into SSO segment": assert => { + const segments = parseUrlPath("?loginToken=a1232aSD123"); + assert.equal(segments.length, 1); + assert.equal(segments[0].type, "sso"); + assert.equal(segments[0].value, "a1232aSD123"); + }, "parse grid url path with focused empty tile": assert => { const segments = parseUrlPath("/session/1/rooms/a,b,c/3"); assert.equal(segments.length, 3); diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index 07c4a870..27776d37 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -23,6 +23,17 @@ import {MediaRepository} from "./net/MediaRepository.js"; import {RequestScheduler} from "./net/RequestScheduler.js"; import {Sync, SyncStatus} from "./Sync.js"; import {Session} from "./Session.js"; +import {PasswordLoginMethod} from "./login/PasswordLoginMethod.js"; +import {TokenLoginMethod} from "./login/TokenLoginMethod.js"; +import {SSOLoginHelper} from "./login/SSOLoginHelper.js"; + +function normalizeHomeserver(homeServer) { + try { + return new URL(homeServer).origin; + } catch (err) { + return new URL(`https://${homeServer}`).origin; + } +} export const LoadStatus = createEnum( "NotLoading", @@ -42,14 +53,6 @@ export const LoginFailure = createEnum( "Unknown", ); -function normalizeHomeserver(homeServer) { - try { - return new URL(homeServer).origin; - } catch (err) { - return new URL(`https://${homeServer}`).origin; - } -} - export class SessionContainer { constructor({platform, olmPromise, workerPromise}) { this._platform = platform; @@ -97,25 +100,56 @@ export class SessionContainer { }); } - async startWithLogin(homeServer, username, password) { - if (this._status.get() !== LoadStatus.NotLoading) { + _parseLoginOptions(options, homeServer) { + /* + Take server response and return new object which has two props password and sso which + implements LoginMethod + */ + const flows = options.flows; + const result = {}; + for (const flow of flows) { + if (flow.type === "m.login.password") { + result.password = (username, password) => new PasswordLoginMethod({homeServer, username, password}); + } + else if (flow.type === "m.login.sso" && flows.find(flow => flow.type === "m.login.token")) { + result.sso = new SSOLoginHelper(homeServer); + } + else if (flow.type === "m.login.token") { + result.token = loginToken => new TokenLoginMethod({homeServer, loginToken}); + } + } + return result; + } + + async queryLogin(homeServer) { + const normalizedHS = normalizeHomeserver(homeServer); + const hsApi = new HomeServerApi({homeServer: normalizedHS, request: this._platform.request}); + const response = await hsApi.getLoginFlows().response(); + return this._parseLoginOptions(response, normalizedHS); + } + + async startWithLogin(loginMethod) { + const currentStatus = this._status.get(); + if (currentStatus !== LoadStatus.LoginFailed && + currentStatus !== LoadStatus.NotLoading && + currentStatus !== LoadStatus.Error) { return; } + this._resetStatus(); await this._platform.logger.run("login", async log => { this._status.set(LoadStatus.Login); - homeServer = normalizeHomeserver(homeServer); const clock = this._platform.clock; let sessionInfo; try { const request = this._platform.request; - const hsApi = new HomeServerApi({homeServer, request}); - const loginData = await hsApi.passwordLogin(username, password, "Hydrogen", {log}).response(); + const hsApi = new HomeServerApi({homeServer: loginMethod.homeServer, request}); + const loginData = await loginMethod.login(hsApi, "Hydrogen", log); const sessionId = this.createNewSessionId(); sessionInfo = { id: sessionId, deviceId: loginData.device_id, userId: loginData.user_id, - homeServer: homeServer, + homeServer: loginMethod.homeServer, accessToken: loginData.access_token, lastUsed: clock.now() }; @@ -270,6 +304,10 @@ export class SessionContainer { return this._error; } + get loginFailure() { + return this._loginFailure; + } + /** only set at loadStatus InitialSync, CatchupSync or Ready */ get sync() { return this._sync; @@ -319,4 +357,10 @@ export class SessionContainer { this._sessionId = null; } } + + _resetStatus() { + this._status.set(LoadStatus.NotLoading); + this._error = null; + this._loginFailure = null; + } } diff --git a/src/matrix/login/LoginMethod.js b/src/matrix/login/LoginMethod.js new file mode 100644 index 00000000..fee8e845 --- /dev/null +++ b/src/matrix/login/LoginMethod.js @@ -0,0 +1,30 @@ +/* +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 class LoginMethod { + constructor({homeServer}) { + this.homeServer = homeServer; + } + + // eslint-disable-next-line no-unused-vars + async login(hsApi, deviceName, log) { + /* + Regardless of the login method, SessionContainer.startWithLogin() + can do SomeLoginMethod.login() + */ + throw("Not Implemented"); + } +} diff --git a/src/matrix/login/PasswordLoginMethod.js b/src/matrix/login/PasswordLoginMethod.js new file mode 100644 index 00000000..5c90ccf8 --- /dev/null +++ b/src/matrix/login/PasswordLoginMethod.js @@ -0,0 +1,29 @@ +/* +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 {LoginMethod} from "./LoginMethod.js"; + +export class PasswordLoginMethod extends LoginMethod { + constructor(options) { + super(options); + this.username = options.username; + this.password = options.password; + } + + async login(hsApi, deviceName, log) { + return await hsApi.passwordLogin(this.username, this.password, deviceName, {log}).response(); + } +} diff --git a/src/matrix/login/SSOLoginHelper.js b/src/matrix/login/SSOLoginHelper.js new file mode 100644 index 00000000..a15c8ef9 --- /dev/null +++ b/src/matrix/login/SSOLoginHelper.js @@ -0,0 +1,27 @@ +/* +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 class SSOLoginHelper{ + constructor(homeserver) { + this._homeserver = homeserver; + } + + get homeserver() { return this._homeserver; } + + createSSORedirectURL(returnURL) { + return `${this._homeserver}/_matrix/client/r0/login/sso/redirect?redirectUrl=${returnURL}`; + } +} diff --git a/src/matrix/login/TokenLoginMethod.js b/src/matrix/login/TokenLoginMethod.js new file mode 100644 index 00000000..e55cedcf --- /dev/null +++ b/src/matrix/login/TokenLoginMethod.js @@ -0,0 +1,29 @@ +/* +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 {LoginMethod} from "./LoginMethod.js"; +import {makeTxnId} from "../common.js"; + +export class TokenLoginMethod extends LoginMethod { + constructor(options) { + super(options); + this._loginToken = options.loginToken; + } + + async login(hsApi, deviceName, log) { + return await hsApi.tokenLogin(this._loginToken, makeTxnId(), deviceName, {log}).response(); + } +} diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index a9b63f8e..8641e374 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -134,6 +134,10 @@ export class HomeServerApi { return this._get(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, null, options); } + getLoginFlows() { + return this._unauthedRequest("GET", this._url("/login"), null, null, null); + } + passwordLogin(username, password, initialDeviceDisplayName, options = null) { return this._unauthedRequest("POST", this._url("/login"), null, { "type": "m.login.password", @@ -146,6 +150,18 @@ export class HomeServerApi { }, options); } + tokenLogin(loginToken, txnId, initialDeviceDisplayName, options = null) { + return this._unauthedRequest("POST", this._url("/login"), null, { + "type": "m.login.token", + "identifier": { + "type": "m.id.user", + }, + "token": loginToken, + "txn_id": txnId, + "initial_device_display_name": initialDeviceDisplayName + }, options); + } + createFilter(userId, filter, options = null) { return this._post(`/user/${encodeURIComponent(userId)}/filter`, null, filter, options); } diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index f1410106..7e28f36f 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -240,6 +240,10 @@ export class Platform { return promise; } + openUrl(url) { + location.href = url; + } + parseHTML(html) { return parseHTML(html); } diff --git a/src/platform/web/dom/History.js b/src/platform/web/dom/History.js index 92927d3f..68e4ef78 100644 --- a/src/platform/web/dom/History.js +++ b/src/platform/web/dom/History.js @@ -25,6 +25,14 @@ export class History extends BaseObservableValue { } get() { + /* + All URLS in Hydrogen will use /#/segment/value/... + But for SSO, we need to handle /?loginToken= + Handle that as a special case for now. + */ + if (document.location.search.includes("loginToken")) { + return document.location.search; + } return document.location.hash; } diff --git a/src/platform/web/dom/SettingsStorage.js b/src/platform/web/dom/SettingsStorage.js index 4e4c18c7..1590cec5 100644 --- a/src/platform/web/dom/SettingsStorage.js +++ b/src/platform/web/dom/SettingsStorage.js @@ -43,6 +43,14 @@ export class SettingsStorage { return defaultValue; } + async setString(key, value) { + this._set(key, value); + } + + async getString(key) { + return window.localStorage.getItem(`${this._prefix}${key}`); + } + async remove(key) { window.localStorage.removeItem(`${this._prefix}${key}`); } diff --git a/src/platform/web/ui/css/login.css b/src/platform/web/ui/css/login.css index aefdac42..ca376dee 100644 --- a/src/platform/web/ui/css/login.css +++ b/src/platform/web/ui/css/login.css @@ -36,7 +36,7 @@ limitations under the License. align-items: center; } -.SessionPickerView .session-info > :not(:first-child) { +.SessionPickerView .session-info> :not(:first-child) { margin-left: 8px; } @@ -50,19 +50,19 @@ limitations under the License. margin: 0 20px; } -.LoginView { - padding: 0.4em; +.PasswordLoginView { + padding: 0 0.4em 0.4em; } -.SessionLoadStatusView { +.SessionLoadStatusView, .LoginView_query-spinner { display: flex; } -.SessionLoadStatusView > :not(:first-child) { +.SessionLoadStatusView> :not(:first-child), .LoginView_query-spinner> :not(:first-child) { margin-left: 12px; } -.SessionLoadStatusView p { +.SessionLoadStatusView p, .LoginView_query-spinner p { flex: 1; margin: 0; } @@ -70,3 +70,29 @@ limitations under the License. .SessionLoadStatusView .spinner { --size: 20px; } + +.StartSSOLoginView { + display: flex; + flex-direction: column; + padding: 0 0.4em 0; +} + +.StartSSOLoginView_button { + flex: 1; + margin-top: 12px; +} + +.LoginView_separator { + justify-content: center; + display: flex; + margin: 8px; +} + +.CompleteSSOView_title { + display: flex; + justify-content: center; +} + +.LoginView_sso { + padding: 0.4em 0.4em 0; +} diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 08a872b8..08f1df06 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -223,6 +223,25 @@ a.button-action { padding-top: 16px; } +.StartSSOLoginView_button { + border: 1px solid #03B381; + border-radius: 8px; +} + +.LoginView_back { + background-image: url("./icons/chevron-left.svg"); + background-color: transparent; +} + +.LoginView_separator { + font-weight: 500; + font-size: 1.5rem; +} + +.CompleteSSOView_title { + font-weight: 500; +} + @media screen and (min-width: 600px) { .PreSessionScreen { box-shadow: 0px 6px 32px rgba(0, 0, 0, 0.1); diff --git a/src/platform/web/ui/login/CompleteSSOView.js b/src/platform/web/ui/login/CompleteSSOView.js new file mode 100644 index 00000000..63614acf --- /dev/null +++ b/src/platform/web/ui/login/CompleteSSOView.js @@ -0,0 +1,30 @@ +/* +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 CompleteSSOView extends TemplateView { + render(t) { + return t.div({ className: "CompleteSSOView" }, + [ + t.p({ className: "CompleteSSOView_title" }, "Finishing up your SSO Login"), + t.if(vm => vm.errorMessage, (t, vm) => t.p({className: "error"}, vm.i18n(vm.errorMessage))), + 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 683bf42d..3d8e8470 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -16,61 +16,58 @@ limitations under the License. import {TemplateView} from "../general/TemplateView.js"; import {hydrogenGithubLink} from "./common.js"; +import {PasswordLoginView} from "./PasswordLoginView.js"; +import {CompleteSSOView} from "./CompleteSSOView.js"; import {SessionLoadStatusView} from "./SessionLoadStatusView.js"; +import {spinner} from "../common.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, - disabled - }); - + const disabled = vm => vm.isBusy; + return t.div({className: "PreSessionScreen"}, [ + t.button({ + className: "button-utility LoginView_back", + onClick: () => vm.goBack(), + disabled + }), 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`), - ]), - ]), - // use t.mapView rather than t.if to create a new view when the view model changes too - t.p(hydrogenGithubLink(t)) - ]) + t.h1([vm.i18n`Sign In`]), + t.mapView(vm => vm.completeSSOLoginViewModel, vm => vm ? new CompleteSSOView(vm) : null), + t.if(vm => vm.showHomeserver, (t, vm) => t.div({ className: "LoginView_sso form form-row" }, + [ + t.if(vm => vm.errorMessage, (t, vm) => t.p({className: "error"}, vm.i18n(vm.errorMessage))), + t.label({for: "homeserver"}, vm.i18n`Homeserver`), + t.input({ + id: "homeserver", + type: "text", + placeholder: vm.i18n`Your matrix homeserver`, + value: vm.homeserver, + disabled, + onChange: event => vm.updateHomeServer(event.target.value), + }) + ] + )), + t.if(vm => vm.isFetchingLoginOptions, t => t.div({className: "LoginView_query-spinner"}, [spinner(t), t.p("Fetching available login options...")])), + t.mapView(vm => vm.passwordLoginViewModel, vm => vm ? new PasswordLoginView(vm): null), + t.if(vm => vm.passwordLoginViewModel && vm.startSSOLoginViewModel, t => t.p({className: "LoginView_separator"}, vm.i18n`or`)), + t.mapView(vm => vm.startSSOLoginViewModel, vm => vm ? new StartSSOLoginView(vm) : null), + t.mapView(vm => vm.loadViewModel, loadViewModel => loadViewModel ? new SessionLoadStatusView(loadViewModel) : null), + // use t.mapView rather than t.if to create a new view when the view model changes too + t.p(hydrogenGithubLink(t)) ]); } } +class StartSSOLoginView extends TemplateView { + render(t, vm) { + return t.div({ className: "StartSSOLoginView" }, + t.button({ + className: "StartSSOLoginView_button button-action secondary", + type: "button", + onClick: () => vm.startSSOLogin(), + disabled: vm => vm.isBusy + }, vm.i18n`Log in 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..130f30ae --- /dev/null +++ b/src/platform/web/ui/login/PasswordLoginView.js @@ -0,0 +1,57 @@ +/* +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"; + +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 + }); + + return t.div({className: "PasswordLoginView form"}, [ + 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); + } + }, [ + t.if(vm => vm.errorMessage, (t, vm) => t.p({className: "error"}, vm.i18n(vm.errorMessage))), + 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: "button-row" }, [ + t.button({ + className: "button-action primary", + type: "submit", + disabled + }, vm.i18n`Log In`), + ]), + ]) + ]); + } +} +