Wire up all the dialog parts for SSO, using device deletion as a POC

This commit is contained in:
Travis Ralston 2020-03-30 20:03:46 -06:00
parent b35356833f
commit ffa75ef48c
7 changed files with 224 additions and 29 deletions

View file

@ -60,3 +60,14 @@ limitations under the License.
.mx_InteractiveAuthEntryComponents_passwordSection {
width: 300px;
}
.mx_InteractiveAuthEntryComponents_sso_buttons {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: 20px;
.mx_AccessibleButton {
margin-left: 5px;
}
}

View file

@ -36,6 +36,13 @@ limitations under the License.
font-weight: 600;
}
.mx_AccessibleButton_kind_primary_outline {
color: $button-primary-bg-color;
background-color: $button-secondary-bg-color;
border: 1px solid $button-primary-bg-color;
font-weight: 600;
}
.mx_AccessibleButton_kind_secondary {
color: $accent-color;
font-weight: 600;
@ -60,7 +67,15 @@ limitations under the License.
background-color: $button-danger-bg-color;
}
.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled {
.mx_AccessibleButton_kind_danger_outline {
color: $button-danger-bg-color;
background-color: $button-secondary-bg-color;
border: 1px solid $button-danger-bg-color;
}
.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled,
mx_AccessibleButton_kind_danger_outline.mx_AccessibleButton_disabled
{
color: $button-danger-disabled-fg-color;
background-color: $button-danger-disabled-bg-color;
}

View file

@ -1,6 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 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.
@ -24,6 +24,8 @@ import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryCom
import * as sdk from '../../index';
export const ERROR_USER_CANCELLED = new Error("User cancelled auth session");
export default createReactClass({
displayName: 'InteractiveAuth',
@ -47,7 +49,7 @@ export default createReactClass({
// @param {bool} status True if the operation requiring
// auth was completed sucessfully, false if canceled.
// @param {object} result The result of the authenticated call
// if successful, otherwise the error object
// if successful, otherwise the error object.
// @param {object} extra Additional information about the UI Auth
// process:
// * emailSid {string} If email auth was performed, the sid of
@ -75,6 +77,15 @@ export default createReactClass({
// is managed by some other party and should not be managed by
// the component itself.
continueIsManaged: PropTypes.bool,
// Called when the stage changes, or the stage's phase changes. First
// argument is the stage, second is the phase. Some stages do not have
// phases and will be counted as 0 (numeric).
onStagePhaseChange: PropTypes.func,
// continueText and continueKind are passed straight through to the AuthEntryComponent.
continueText: PropTypes.string,
continueKind: PropTypes.string,
},
getInitialState: function() {
@ -204,6 +215,16 @@ export default createReactClass({
this._authLogic.submitAuthDict(authData);
},
_onPhaseChange: function(newPhase) {
if (this.props.onStagePhaseChange) {
this.props.onStagePhaseChange(this.state.authStage, newPhase || 0);
}
},
_onStageCancel: function() {
this.props.onAuthFinished(false, ERROR_USER_CANCELLED);
},
_renderCurrentStage: function() {
const stage = this.state.authStage;
if (!stage) {
@ -232,6 +253,10 @@ export default createReactClass({
fail={this._onAuthStageFailed}
setEmailSid={this._setEmailSid}
showContinue={!this.props.continueIsManaged}
onPhaseChange={this._onPhaseChange}
continueText={this.props.continueText}
continueKind={this.props.continueKind}
onCancel={this._onStageCancel}
/>
);
},

View file

@ -25,6 +25,7 @@ import classnames from 'classnames';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton";
/* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed
@ -59,11 +60,21 @@ import SettingsStore from "../../../settings/SettingsStore";
* session to be failed and the process to go back to the start.
* setEmailSid: m.login.email.identity only: a function to be called with the
* email sid after a token is requested.
* onPhaseChange: A function which is called when the stage's phase changes. If
* the stage has no phases, call this with DEFAULT_PHASE. Takes
* one argument, the phase, and is always defined/required.
* continueText: For stages which have a continue button, the text to use.
* continueKind: For stages which have a continue button, the style of button to
* use. For example, 'danger' or 'primary'.
* onCancel A function with no arguments which is called by the stage if the
* user knowingly cancelled/dismissed the authentication attempt.
*
* Each component may also provide the following functions (beyond the standard React ones):
* focus: set the input focus appropriately in the form.
*/
export const DEFAULT_PHASE = 0;
export const PasswordAuthEntry = createReactClass({
displayName: 'PasswordAuthEntry',
@ -78,6 +89,11 @@ export const PasswordAuthEntry = createReactClass({
// is the auth logic currently waiting for something to
// happen?
busy: PropTypes.bool,
onPhaseChange: PropTypes.func.isRequired,
},
componentDidMount: function() {
this.props.onPhaseChange(DEFAULT_PHASE);
},
getInitialState: function() {
@ -175,6 +191,11 @@ export const RecaptchaAuthEntry = createReactClass({
stageParams: PropTypes.object.isRequired,
errorText: PropTypes.string,
busy: PropTypes.bool,
onPhaseChange: PropTypes.func.isRequired,
},
componentDidMount: function() {
this.props.onPhaseChange(DEFAULT_PHASE);
},
_onCaptchaResponse: function(response) {
@ -236,6 +257,11 @@ export const TermsAuthEntry = createReactClass({
errorText: PropTypes.string,
busy: PropTypes.bool,
showContinue: PropTypes.bool,
onPhaseChange: PropTypes.func.isRequired,
},
componentDidMount: function() {
this.props.onPhaseChange(DEFAULT_PHASE);
},
componentWillMount: function() {
@ -378,6 +404,11 @@ export const EmailIdentityAuthEntry = createReactClass({
stageState: PropTypes.object.isRequired,
fail: PropTypes.func.isRequired,
setEmailSid: PropTypes.func.isRequired,
onPhaseChange: PropTypes.func.isRequired,
},
componentDidMount: function() {
this.props.onPhaseChange(DEFAULT_PHASE);
},
getInitialState: function() {
@ -420,6 +451,11 @@ export const MsisdnAuthEntry = createReactClass({
clientSecret: PropTypes.func,
submitAuthDict: PropTypes.func.isRequired,
matrixClient: PropTypes.object,
onPhaseChange: PropTypes.func.isRequired,
},
componentDidMount: function() {
this.props.onPhaseChange(DEFAULT_PHASE);
},
getInitialState: function() {
@ -571,13 +607,17 @@ export class SSOAuthEntry extends React.Component {
loginType: PropTypes.string.isRequired,
submitAuthDict: PropTypes.func.isRequired,
errorText: PropTypes.string,
onPhaseChange: PropTypes.func.isRequired,
continueText: PropTypes.string,
continueKind: PropTypes.string,
onCancel: PropTypes.func,
};
static LOGIN_TYPE = "m.login.sso";
static UNSTABLE_LOGIN_TYPE = "org.matrix.login.sso";
static STAGE_PREAUTH = 1; // button to start SSO
static STAGE_POSTAUTH = 2; // button to confirm SSO completed
static PHASE_PREAUTH = 1; // button to start SSO
static PHASE_POSTAUTH = 2; // button to confirm SSO completed
constructor(props) {
super(props);
@ -589,39 +629,56 @@ export class SSOAuthEntry extends React.Component {
this.props.loginType,
this.props.authSessionId,
),
stage: SSOAuthEntry.STAGE_PREAUTH,
phase: SSOAuthEntry.PHASE_PREAUTH,
};
}
onStartAuthClick = (e) => {
e.preventDefault();
e.stopPropagation();
componentDidMount(): void {
this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH);
}
onStartAuthClick = () => {
// Note: We don't use PlatformPeg's startSsoAuth functions because we almost
// certainly will need to open the thing in a new tab to avoid loosing application
// context.
window.open(e.target.href, '_blank');
this.setState({stage: SSOAuthEntry.STAGE_POSTAUTH});
window.open(this.state.ssoUrl, '_blank');
this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH});
this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH);
};
onConfirmClick = (e) => {
e.preventDefault();
e.stopPropagation();
onConfirmClick = () => {
this.props.submitAuthDict({});
};
render () {
if (this.state.stage === SSOAuthEntry.STAGE_PREAUTH) {
return <a href={this.state.ssoUrl} target='_blank' rel='noopener' onClick={this.onStartAuthClick}>
{_t("Single Sign On")}
</a>;
let continueButton = null;
const cancelButton = (
<AccessibleButton
onClick={this.props.onCancel}
kind={this.props.continueKind ? (this.props.continueKind + '_outline') : 'primary_outline'}
>{_t("Cancel")}</AccessibleButton>
);
if (this.state.phase === SSOAuthEntry.PHASE_PREAUTH) {
continueButton = (
<AccessibleButton
onClick={this.onStartAuthClick}
kind={this.props.continueKind || 'primary'}
>{this.props.continueText || _t("Single Sign On")}</AccessibleButton>
);
} else {
return <a href='' target='_blank' rel='noopener' onClick={this.onConfirmClick}>
{_t("Continue")}
</a>;
continueButton = (
<AccessibleButton
onClick={this.onConfirmClick}
kind={this.props.continueKind || 'primary'}
>{this.props.continueText || _t("Confirm")}</AccessibleButton>
);
}
return <div className='mx_InteractiveAuthEntryComponents_sso_buttons'>
{cancelButton}
{continueButton}
</div>;
}
}
@ -634,6 +691,11 @@ export const FallbackAuthEntry = createReactClass({
loginType: PropTypes.string.isRequired,
submitAuthDict: PropTypes.func.isRequired,
errorText: PropTypes.string,
onPhaseChange: PropTypes.func.isRequired,
},
componentDidMount: function() {
this.props.onPhaseChange(DEFAULT_PHASE);
},
componentWillMount: function() {

View file

@ -1,6 +1,7 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2020 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.
@ -23,6 +24,7 @@ import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
import {ERROR_USER_CANCELLED} from "../../structures/InteractiveAuth";
export default createReactClass({
displayName: 'InteractiveAuthDialog',
@ -44,12 +46,36 @@ export default createReactClass({
onFinished: PropTypes.func.isRequired,
// Optional title and body to show when not showing a particular stage
title: PropTypes.string,
body: PropTypes.string,
// Optional title and body pairs for particular stages and phases within
// those stages. Object structure/example is:
// {
// "org.example.stage_type": {
// 1: {
// "body": "This is a body for phase 1" of org.example.stage_type,
// "title": "Title for phase 1 of org.example.stage_type"
// },
// 2: {
// "body": "This is a body for phase 2 of org.example.stage_type",
// "title": "Title for phase 2 of org.example.stage_type"
// "continueText": "Confirm identity with Example Auth",
// "continueKind": "danger"
// }
// }
// }
aestheticsForStagePhases: PropTypes.object,
},
getInitialState: function() {
return {
authError: null,
// See _onUpdateStagePhase()
uiaStage: null,
uiaStagePhase: null,
};
},
@ -57,12 +83,22 @@ export default createReactClass({
if (success) {
this.props.onFinished(true, result);
} else {
this.setState({
authError: result,
});
if (result === ERROR_USER_CANCELLED) {
this.props.onFinished(false, null);
} else {
this.setState({
authError: result,
});
}
}
},
_onUpdateStagePhase: function(newStage, newPhase) {
console.log({newStage, newPhase});
// We copy the stage and stage phase params into state for title selection in render()
this.setState({uiaStage: newStage, uiaStagePhase: newPhase});
},
_onDismissClick: function() {
this.props.onFinished(false);
},
@ -71,6 +107,23 @@ export default createReactClass({
const InteractiveAuth = sdk.getComponent("structures.InteractiveAuth");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
// Let's pick a title, body, and other params text that we'll show to the user. The order
// is most specific first, so stagePhase > our props > defaults.
let title = this.state.authError ? 'Error' : (this.props.title || _t('Authentication'));
let body = this.state.authError ? null : this.props.body;
let continueText = null;
let continueKind = null;
if (!this.state.authError && this.props.aestheticsForStagePhases) {
if (this.props.aestheticsForStagePhases[this.state.uiaStage]) {
const aesthetics = this.props.aestheticsForStagePhases[this.state.uiaStage][this.state.uiaStagePhase];
if (aesthetics && aesthetics.title) title = aesthetics.title;
if (aesthetics && aesthetics.body) body = aesthetics.body;
if (aesthetics && aesthetics.continueText) continueText = aesthetics.continueText;
if (aesthetics && aesthetics.continueKind) continueKind = aesthetics.continueKind;
}
}
let content;
if (this.state.authError) {
content = (
@ -88,11 +141,16 @@ export default createReactClass({
} else {
content = (
<div id='mx_Dialog_content'>
<InteractiveAuth ref={this._collectInteractiveAuth}
{body}
<InteractiveAuth
ref={this._collectInteractiveAuth}
matrixClient={this.props.matrixClient}
authData={this.props.authData}
makeRequest={this.props.makeRequest}
onAuthFinished={this._onAuthFinished}
onStagePhaseChange={this._onUpdateStagePhase}
continueText={continueText}
continueKind={continueKind}
/>
</div>
);
@ -101,7 +159,7 @@ export default createReactClass({
return (
<BaseDialog className="mx_InteractiveAuthDialog"
onFinished={this.props.onFinished}
title={this.state.authError ? 'Error' : (this.props.title || _t('Authentication'))}
title={title}
contentId='mx_Dialog_content'
>
{ content }

View file

@ -23,6 +23,7 @@ import * as sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler';
import Modal from '../../../Modal';
import {SSOAuthEntry} from "../auth/InteractiveAuthEntryComponents";
export default class DevicesPanel extends React.Component {
constructor(props) {
@ -123,11 +124,29 @@ export default class DevicesPanel extends React.Component {
// pop up an interactive auth dialog
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("Use Single Sign On to continue"),
body: _t("Confirm deleting these sessions by using Single Sign On to prove your identity."),
continueText: _t("Single Sign On"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("Confirm deleting these sessions"),
body: _t("Click the button below to confirm deleting these sessions."),
continueText: _t("Delete sessions"),
continueKind: "danger",
},
};
Modal.createTrackedDialog('Delete Device Dialog', '', InteractiveAuthDialog, {
title: _t("Authentication"),
matrixClient: MatrixClientPeg.get(),
authData: error.data,
makeRequest: this._makeDeleteRequest.bind(this),
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
}).catch((e) => {
console.error("Error deleting sessions", e);

View file

@ -598,6 +598,12 @@
"up to date": "up to date",
"Your homeserver does not support session management.": "Your homeserver does not support session management.",
"Unable to load session list": "Unable to load session list",
"Use Single Sign On to continue": "Use Single Sign On to continue",
"Confirm deleting these sessions by using Single Sign On to prove your identity.": "Confirm deleting these sessions by using Single Sign On to prove your identity.",
"Single Sign On": "Single Sign On",
"Confirm deleting these sessions": "Confirm deleting these sessions",
"Click the button below to confirm deleting these sessions.": "Click the button below to confirm deleting these sessions.",
"Delete sessions": "Delete sessions",
"Authentication": "Authentication",
"Delete %(count)s sessions|other": "Delete %(count)s sessions",
"Delete %(count)s sessions|one": "Delete %(count)s session",
@ -1831,7 +1837,7 @@
"Please enter the code it contains:": "Please enter the code it contains:",
"Code": "Code",
"Submit": "Submit",
"Single Sign On": "Single Sign On",
"Confirm": "Confirm",
"Start authentication": "Start authentication",
"Unable to validate homeserver/identity server": "Unable to validate homeserver/identity server",
"Your Modular server": "Your Modular server",
@ -1862,7 +1868,6 @@
"Use lowercase letters, numbers, dashes and underscores only": "Use lowercase letters, numbers, dashes and underscores only",
"Enter username": "Enter username",
"Email (optional)": "Email (optional)",
"Confirm": "Confirm",
"Phone (optional)": "Phone (optional)",
"Create your Matrix account on %(serverName)s": "Create your Matrix account on %(serverName)s",
"Create your Matrix account on <underlinedServerName />": "Create your Matrix account on <underlinedServerName />",