Login: Angular to React (#18116)

* Migrating login services

* Add user signup

* Remove lodash

* Remove media query and extarct LoginServices

* Add React LoginCtrl

* Handle location with Redux and start form validation

* Fix proposal

* Add basic validation

* Fix validation

* Remove state from controller

* Extract login forms

* Fix things up

* Add change password and LoginPage

* Add React page and route to it

* Make redux connection work

* Add validation for password change

* Change pws request

* Fix feedback

* Fix feedback

* LoginPage to FC

* Move onSkip to a method

* Experimenting with animations

* Make animations work

* Add input focus

* Fix focus problem and clean animation

* Working change password request

* Add routing with window.location instead of Redux

* Fix a bit of feedback

* Move config to LoginCtrl

* Make buttons same size

* Change way of validating

* Update changePassword and remove angular controller

* Remove some console.logs

* Split onChange

* Remove className

* Fix animation, onChange and remove config.loginError code

* Add loginError appEvent

* Make flex and add previosuly removed media query
This commit is contained in:
Tobias Skarhed 2019-08-13 15:46:40 +02:00 committed by GitHub
parent 93ecf63e70
commit 91a911b64e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 594 additions and 277 deletions

View File

@ -0,0 +1,135 @@
import React, { PureComponent, SyntheticEvent, ChangeEvent } from 'react';
import { Tooltip } from '@grafana/ui';
import appEvents from 'app/core/app_events';
interface Props {
onSubmit: (pw: string) => void;
onSkip: Function;
focus?: boolean;
}
interface State {
newPassword: string;
confirmNew: string;
valid: boolean;
}
export class ChangePassword extends PureComponent<Props, State> {
private userInput: HTMLInputElement;
constructor(props: Props) {
super(props);
this.state = {
newPassword: '',
confirmNew: '',
valid: false,
};
}
componentDidUpdate(prevProps: Props) {
if (!prevProps.focus && this.props.focus) {
this.focus();
}
}
focus() {
this.userInput.focus();
}
onSubmit = (e: SyntheticEvent) => {
e.preventDefault();
const { newPassword, valid } = this.state;
if (valid) {
this.props.onSubmit(newPassword);
} else {
appEvents.emit('alert-warning', ['New passwords do not match', '']);
}
};
onNewPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
newPassword: e.target.value,
valid: this.validate('newPassword', e.target.value),
});
};
onConfirmPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
confirmNew: e.target.value,
valid: this.validate('confirmNew', e.target.value),
});
};
onSkip = (e: SyntheticEvent) => {
this.props.onSkip();
};
validate(changed: string, pw: string) {
if (changed === 'newPassword') {
return this.state.confirmNew === pw;
} else if (changed === 'confirmNew') {
return this.state.newPassword === pw;
}
return false;
}
render() {
return (
<div className="login-inner-box" id="change-password-view">
<div className="text-left login-change-password-info">
<h5>Change Password</h5>
Before you can get started with awesome dashboards we need you to make your account more secure by changing
your password.
<br />
You can change your password again later.
</div>
<form className="login-form-group gf-form-group">
<div className="login-form">
<input
type="password"
id="newPassword"
name="newPassword"
className="gf-form-input login-form-input"
required
placeholder="New password"
onChange={this.onNewPasswordChange}
ref={input => {
this.userInput = input;
}}
/>
</div>
<div className="login-form">
<input
type="password"
name="confirmNew"
className="gf-form-input login-form-input"
required
ng-model="command.confirmNew"
placeholder="Confirm new password"
onChange={this.onConfirmPasswordChange}
/>
</div>
<div className="login-button-group login-button-group--right text-right">
<Tooltip
placement="bottom"
content="If you skip you will be prompted to change password next time you login."
>
<a className="btn btn-link" onClick={this.onSkip}>
Skip
</a>
</Tooltip>
<button
type="submit"
className={`btn btn-large p-x-2 ${this.state.valid ? 'btn-primary' : 'btn-inverse'}`}
onClick={this.onSubmit}
disabled={!this.state.valid}
>
Save
</button>
</div>
</form>
</div>
);
}
}

View File

@ -0,0 +1,162 @@
import React from 'react';
import config from 'app/core/config';
import { updateLocation } from 'app/core/actions';
import { connect } from 'react-redux';
import { StoreState } from 'app/types';
import { PureComponent } from 'react';
import { getBackendSrv } from '@grafana/runtime';
import { hot } from 'react-hot-loader';
import appEvents from 'app/core/app_events';
const isOauthEnabled = () => Object.keys(config.oauth).length > 0;
export interface FormModel {
user: string;
password: string;
email: string;
}
interface Props {
routeParams?: any;
updateLocation?: typeof updateLocation;
children: (props: {
isLoggingIn: boolean;
changePassword: (pw: string) => void;
isChangingPassword: boolean;
skipPasswordChange: Function;
login: (data: FormModel) => void;
disableLoginForm: boolean;
ldapEnabled: boolean;
authProxyEnabled: boolean;
disableUserSignUp: boolean;
isOauthEnabled: boolean;
loginHint: string;
passwordHint: string;
}) => JSX.Element;
}
interface State {
isLoggingIn: boolean;
isChangingPassword: boolean;
}
export class LoginCtrl extends PureComponent<Props, State> {
result: any = {};
constructor(props: Props) {
super(props);
this.state = {
isLoggingIn: false,
isChangingPassword: false,
};
if (config.loginError) {
appEvents.emit('alert-warning', ['Login Failed', config.loginError]);
}
}
changePassword = (password: string) => {
const pw = {
newPassword: password,
confirmNew: password,
oldPassword: 'admin',
};
getBackendSrv()
.put('/api/user/password', pw)
.then(() => {
this.toGrafana();
})
.catch((err: any) => console.log(err));
};
login = (formModel: FormModel) => {
this.setState({
isLoggingIn: true,
});
getBackendSrv()
.post('/login', formModel)
.then((result: any) => {
this.result = result;
if (formModel.password !== 'admin' || config.ldapEnabled || config.authProxyEnabled) {
this.toGrafana();
return;
} else {
this.changeView();
}
})
.catch(() => {
this.setState({
isLoggingIn: false,
});
});
};
changeView = () => {
this.setState({
isChangingPassword: true,
});
};
toGrafana = () => {
const params = this.props.routeParams;
// Use window.location.href to force page reload
if (params.redirect && params.redirect[0] === '/') {
window.location.href = config.appSubUrl + params.redirect;
// this.props.updateLocation({
// path: config.appSubUrl + params.redirect,
// });
} else if (this.result.redirectUrl) {
window.location.href = config.appSubUrl + params.redirect;
// this.props.updateLocation({
// path: this.result.redirectUrl,
// });
} else {
window.location.href = config.appSubUrl + '/';
// this.props.updateLocation({
// path: '/',
// });
}
};
render() {
const { children } = this.props;
const { isLoggingIn, isChangingPassword } = this.state;
const { login, toGrafana, changePassword } = this;
const { loginHint, passwordHint, disableLoginForm, ldapEnabled, authProxyEnabled, disableUserSignUp } = config;
return (
<>
{children({
isOauthEnabled: isOauthEnabled(),
loginHint,
passwordHint,
disableLoginForm,
ldapEnabled,
authProxyEnabled,
disableUserSignUp,
login,
isLoggingIn,
changePassword,
skipPasswordChange: toGrafana,
isChangingPassword,
})}
</>
);
}
}
export const mapStateToProps = (state: StoreState) => ({
routeParams: state.location.routeParams,
});
const mapDispatchToProps = { updateLocation };
export default hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(LoginCtrl)
);

View File

@ -0,0 +1,120 @@
import React, { PureComponent, SyntheticEvent, ChangeEvent } from 'react';
import { FormModel } from './LoginCtrl';
interface Props {
displayForgotPassword: boolean;
onChange?: (valid: boolean) => void;
onSubmit: (data: FormModel) => void;
isLoggingIn: boolean;
passwordHint: string;
loginHint: string;
}
interface State {
user: string;
password: string;
email: string;
valid: boolean;
}
export class LoginForm extends PureComponent<Props, State> {
private userInput: HTMLInputElement;
constructor(props: Props) {
super(props);
this.state = {
user: '',
password: '',
email: '',
valid: false,
};
}
componentDidMount() {
this.userInput.focus();
}
onSubmit = (e: SyntheticEvent) => {
e.preventDefault();
const { user, password, email } = this.state;
if (this.state.valid) {
this.props.onSubmit({ user, password, email });
}
};
onChangePassword = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
password: e.target.value,
valid: this.validate(this.state.user, e.target.value),
});
};
onChangeUsername = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({
user: e.target.value,
valid: this.validate(e.target.value, this.state.password),
});
};
validate(user: string, password: string) {
return user.length > 0 && password.length > 0;
}
render() {
return (
<form name="loginForm" className="login-form-group gf-form-group">
<div className="login-form">
<input
ref={input => {
this.userInput = input;
}}
type="text"
name="user"
className="gf-form-input login-form-input"
required
placeholder={this.props.loginHint}
aria-label="Username input field"
onChange={this.onChangeUsername}
/>
</div>
<div className="login-form">
<input
type="password"
name="password"
className="gf-form-input login-form-input"
required
ng-model="formModel.password"
id="inputPassword"
placeholder={this.props.passwordHint}
aria-label="Password input field"
onChange={this.onChangePassword}
/>
</div>
<div className="login-button-group">
{!this.props.isLoggingIn ? (
<button
type="submit"
aria-label="Login button"
className={`btn btn-large p-x-2 ${this.state.valid ? 'btn-primary' : 'btn-inverse'}`}
onClick={this.onSubmit}
disabled={!this.state.valid}
>
Log In
</button>
) : (
<button type="submit" className="btn btn-large p-x-2 btn-inverse btn-loading">
Logging In<span>.</span>
<span>.</span>
<span>.</span>
</button>
)}
{this.props.displayForgotPassword ? (
<div className="small login-button-forgot-password">
<a href="user/password/send-reset-email">Forgot your password?</a>
</div>
) : null}
</div>
</form>
);
}
}

View File

@ -0,0 +1,81 @@
import React, { FC } from 'react';
import { UserSignup } from './UserSignup';
import { LoginServiceButtons } from './LoginServiceButtons';
import LoginCtrl from './LoginCtrl';
import { LoginForm } from './LoginForm';
import { ChangePassword } from './ChangePassword';
import { CSSTransition } from 'react-transition-group';
export const LoginPage: FC = () => {
return (
<div className="login container">
<div className="login-content">
<div className="login-branding">
<img className="logo-icon" src="public/img/grafana_icon.svg" alt="Grafana" />
<div className="logo-wordmark" />
</div>
<LoginCtrl>
{({
loginHint,
passwordHint,
isOauthEnabled,
ldapEnabled,
authProxyEnabled,
disableLoginForm,
disableUserSignUp,
login,
isLoggingIn,
changePassword,
skipPasswordChange,
isChangingPassword,
}) => (
<div className="login-outer-box">
<div className={`login-inner-box ${isChangingPassword ? 'hidden' : ''}`} id="login-view">
{!disableLoginForm ? (
<LoginForm
displayForgotPassword={!(ldapEnabled || authProxyEnabled)}
onSubmit={login}
loginHint={loginHint}
passwordHint={passwordHint}
isLoggingIn={isLoggingIn}
/>
) : null}
{isOauthEnabled ? (
<>
<div className="text-center login-divider">
<div>
<div className="login-divider-line" />
</div>
<div>
<span className="login-divider-text">{disableLoginForm ? null : <span>or</span>}</span>
</div>
<div>
<div className="login-divider-line" />
</div>
</div>
<div className="clearfix" />
<LoginServiceButtons />
</>
) : null}
{!disableUserSignUp ? <UserSignup /> : null}
</div>
<CSSTransition
appear={true}
mountOnEnter={true}
in={isChangingPassword}
timeout={250}
classNames="login-inner-box"
>
<ChangePassword onSubmit={changePassword} onSkip={skipPasswordChange} focus={isChangingPassword} />
</CSSTransition>
</div>
)}
</LoginCtrl>
<div className="clearfix" />
</div>
</div>
);
};

View File

@ -0,0 +1,67 @@
import React from 'react';
import config from 'app/core/config';
const loginServices: () => LoginServices = () => ({
saml: {
enabled: config.samlEnabled,
name: 'SAML',
className: 'github',
icon: 'key',
},
google: {
enabled: config.oauth.google,
name: 'Google',
},
github: {
enabled: config.oauth.github,
name: 'GitHub',
},
gitlab: {
enabled: config.oauth.gitlab,
name: 'GitLab',
},
grafanacom: {
enabled: config.oauth.grafana_com,
name: 'Grafana.com',
hrefName: 'grafana_com',
icon: 'grafana_com',
},
oauth: {
enabled: config.oauth.generic_oauth,
name: 'OAuth',
icon: 'sign-in',
hrefName: 'generic_oauth',
},
});
export interface LoginService {
enabled: boolean;
name: string;
hrefName?: string;
icon?: string;
className?: string;
}
export interface LoginServices {
[key: string]: LoginService;
}
export const LoginServiceButtons = () => {
const keyNames = Object.keys(loginServices());
const serviceElements = keyNames.map(key => {
const service: LoginService = loginServices()[key];
return service.enabled ? (
<a
key={key}
className={`btn btn-medium btn-service btn-service--${service.className || key} login-btn`}
href={`login/${service.hrefName ? service.hrefName : key}`}
target="_self"
>
<i className={`btn-service-icon fa fa-${service.icon ? service.icon : key}`} />
Sign in with {service.name}
</a>
) : null;
});
return <div className="login-oauth text-center">{serviceElements}</div>;
};

View File

@ -0,0 +1,12 @@
import React, { FC } from 'react';
export const UserSignup: FC<{}> = () => {
return (
<div className="login-signup-box">
<div className="login-signup-title p-r-1">New to Grafana?</div>
<a href="signup" className="btn btn-medium btn-signup btn-p-x-2">
Sign Up
</a>
</div>
);
};

View File

@ -1,5 +1,4 @@
import './json_editor_ctrl';
import './login_ctrl';
import './invited_ctrl';
import './signup_ctrl';
import './reset_password_ctrl';

View File

@ -1,145 +0,0 @@
import _ from 'lodash';
import coreModule from '../core_module';
import config from 'app/core/config';
import { BackendSrv } from '../services/backend_srv';
export class LoginCtrl {
/** @ngInject */
constructor($scope: any, backendSrv: BackendSrv, $location: any) {
$scope.formModel = {
user: '',
email: '',
password: '',
};
$scope.command = {};
$scope.result = '';
$scope.loggingIn = false;
$scope.oauth = config.oauth;
$scope.oauthEnabled = _.keys(config.oauth).length > 0;
$scope.ldapEnabled = config.ldapEnabled;
$scope.authProxyEnabled = config.authProxyEnabled;
$scope.samlEnabled = config.samlEnabled;
$scope.disableLoginForm = config.disableLoginForm;
$scope.disableUserSignUp = config.disableUserSignUp;
$scope.loginHint = config.loginHint;
$scope.passwordHint = config.passwordHint;
$scope.loginMode = true;
$scope.submitBtnText = 'Log in';
$scope.init = () => {
$scope.$watch('loginMode', $scope.loginModeChanged);
if (config.loginError) {
$scope.appEvent('alert-warning', ['Login Failed', config.loginError]);
}
};
$scope.submit = () => {
if ($scope.loginMode) {
$scope.login();
} else {
$scope.signUp();
}
};
$scope.changeView = () => {
const loginView = document.querySelector('#login-view');
const changePasswordView = document.querySelector('#change-password-view');
loginView.className += ' add';
setTimeout(() => {
loginView.className += ' hidden';
}, 250);
setTimeout(() => {
changePasswordView.classList.remove('hidden');
}, 251);
setTimeout(() => {
changePasswordView.classList.remove('remove');
}, 301);
setTimeout(() => {
document.getElementById('newPassword').focus();
}, 400);
};
$scope.changePassword = () => {
$scope.command.oldPassword = 'admin';
if ($scope.command.newPassword !== $scope.command.confirmNew) {
$scope.appEvent('alert-warning', ['New passwords do not match', '']);
return;
}
backendSrv.put('/api/user/password', $scope.command).then(() => {
$scope.toGrafana();
});
};
$scope.skip = () => {
$scope.toGrafana();
};
$scope.loginModeChanged = (newValue: boolean) => {
$scope.submitBtnText = newValue ? 'Log in' : 'Sign up';
};
$scope.signUp = () => {
if (!$scope.loginForm.$valid) {
return;
}
backendSrv.post('/api/user/signup', $scope.formModel).then((result: any) => {
if (result.status === 'SignUpCreated') {
$location.path('/signup').search({ email: $scope.formModel.email });
} else {
window.location.href = config.appSubUrl + '/';
}
});
};
$scope.login = () => {
delete $scope.loginError;
if (!$scope.loginForm.$valid) {
return;
}
$scope.loggingIn = true;
backendSrv
.post('/login', $scope.formModel)
.then((result: any) => {
$scope.result = result;
if ($scope.formModel.password !== 'admin' || $scope.ldapEnabled || $scope.authProxyEnabled) {
$scope.toGrafana();
return;
} else {
$scope.changeView();
}
})
.catch(() => {
$scope.loggingIn = false;
});
};
$scope.toGrafana = () => {
const params = $location.search();
if (params.redirect && params.redirect[0] === '/') {
window.location.href = config.appSubUrl + params.redirect;
} else if ($scope.result.redirectUrl) {
window.location.href = $scope.result.redirectUrl;
} else {
window.location.href = config.appSubUrl + '/';
}
};
$scope.init();
}
}
coreModule.controller('LoginCtrl', LoginCtrl);

View File

@ -1,115 +0,0 @@
<div class="login container">
<div class="login-content">
<div class="login-branding">
<img class="logo-icon" src="public/img/grafana_icon.svg" alt="Grafana" />
<div class="logo-wordmark" />
</div>
<div class="login-outer-box">
<div class="login-inner-box" id="login-view">
<form name="loginForm" class="login-form-group gf-form-group" ng-hide="disableLoginForm">
<div class="login-form">
<input type="text" name="username" class="gf-form-input login-form-input" required ng-model='formModel.user' placeholder={{loginHint}} aria-label="Username input field"
autofocus autofill-event-fix>
</div>
<div class="login-form">
<input type="password" name="password" class="gf-form-input login-form-input" required ng-model="formModel.password" id="inputPassword"
placeholder="{{passwordHint}}" aria-label="Password input field">
</div>
<div class="login-button-group">
<button type="submit" aria-label="Login button" class="btn btn-large p-x-2" ng-if="!loggingIn" ng-click="submit();" ng-class="{'btn-inverse': !loginForm.$valid, 'btn-primary': loginForm.$valid}">
Log In
</button>
<button type="submit" class="btn btn-large p-x-2 btn-inverse btn-loading" ng-if="loggingIn">
Logging In<span>.</span><span>.</span><span>.</span>
</button>
<div class="small login-button-forgot-password" ng-hide="ldapEnabled || authProxyEnabled">
<a href="user/password/send-reset-email">
Forgot your password?
</a>
</div>
</div>
</form>
<div class="text-center login-divider" ng-show="oauthEnabled">
<div>
<div class="login-divider-line">
</div>
</div>
<div>
<span class="login-divider-text">
<span ng-hide="disableLoginForm">or</span>
</span>
</div>
<div>
<div class="login-divider-line">
</div>
</div>
</div>
<div class="clearfix"></div>
<a class="btn btn-medium btn-service btn-service--github login-btn" href="login/saml" target="_self" ng-if="samlEnabled">
<i class="btn-service-icon fa fa-key"></i>
Sign in with SAML
</a>
<div class="login-oauth text-center" ng-show="oauthEnabled">
<a class="btn btn-medium btn-service btn-service--google login-btn" href="login/google" target="_self" ng-if="oauth.google">
<i class="btn-service-icon fa fa-google"></i>
Sign in with Google
</a>
<a class="btn btn-medium btn-service btn-service--github login-btn" href="login/github" target="_self" ng-if="oauth.github">
<i class="btn-service-icon fa fa-github"></i>
Sign in with GitHub
</a>
<a class="btn btn-medium btn-service btn-service--gitlab login-btn" href="login/gitlab" target="_self" ng-if="oauth.gitlab">
<i class="btn-service-icon fa fa-gitlab"></i>
Sign in with GitLab
</a>
<a class="btn btn-medium btn-service btn-service--grafanacom login-btn" href="login/grafana_com" target="_self"
ng-if="oauth.grafana_com">
<i class="btn-service-icon"></i>
Sign in with Grafana.com
</a>
<a class="btn btn-medium btn-service btn-service--oauth login-btn" href="login/generic_oauth" target="_self"
ng-if="oauth.generic_oauth">
<i class="btn-service-icon fa fa-sign-in"></i>
Sign in with {{oauth.generic_oauth.name}}
</a>
</div>
<div class="login-signup-box" ng-show="!disableUserSignUp">
<div class="login-signup-title p-r-1">
New to Grafana?
</div>
<a href="signup" class="btn btn-medium btn-signup btn-p-x-2">
Sign Up
</a>
</div>
</div>
<div class="login-inner-box remove hidden" id="change-password-view">
<div class="text-left login-change-password-info">
<h5>Change Password</h5>
Before you can get started with awesome dashboards we need you to make your account more secure by changing your password.
<br />You can change your password again later.
</div>
<form class="login-form-group gf-form-group">
<div class="login-form">
<input type="password" id="newPassword" name="newPassword" class="gf-form-input login-form-input" required ng-model='command.newPassword'
placeholder="New password">
</div>
<div class="login-form">
<input type="password" name="confirmNew" class="gf-form-input login-form-input" required ng-model="command.confirmNew" placeholder="Confirm new password">
</div>
<div class="login-button-group login-button-group--right text-right">
<a class="btn btn-link" ng-click="skip();">
Skip
<info-popover mode="small-padding">
If you skip you will be prompted to change password next time you login.
</info-popover>
</a>
<button type="submit" class="btn btn-large p-x-2" ng-click="changePassword();" ng-class="{'btn-inverse': !loginForm.$valid, 'btn-primary': loginForm.$valid}">
Save
</button>
</div>
</form>
</div>
<div class="clearfix"></div>
</div>
</div>
</div>

View File

@ -30,6 +30,7 @@ import { route, ILocationProvider } from 'angular';
// Types
import { DashboardRouteInfo } from 'app/types';
import { LoginPage } from 'app/core/components/Login/LoginPage';
/** @ngInject */
export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locationProvider: ILocationProvider) {
@ -285,8 +286,10 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
})
// LOGIN / SIGNUP
.when('/login', {
templateUrl: 'public/app/partials/login.html',
controller: 'LoginCtrl',
template: '<react-container/>',
resolve: {
component: () => LoginPage,
},
pageClass: 'login-page sidemenu-hidden',
})
.when('/invite/:code', {

View File

@ -20,7 +20,6 @@ $login-border: #8daac5;
& .btn-primary {
@include buttonBackground(#ff6600, #bc3e06);
height: 40px;
}
}
@ -162,18 +161,17 @@ select:-webkit-autofill:focus {
transform: tranlate(0px, 0px);
transition: 0.25s ease;
&.add {
transform: translate(0px, -320px);
&.hidden {
display: none;
}
&.hidden {
display: none;
}
&.remove {
&-enter {
transform: translate(0px, 320px);
&.hidden {
display: none;
}
display: flex;
}
&-enter-active {
transform: translate(0px, 0px);
}
}
@ -319,15 +317,15 @@ select:-webkit-autofill:focus {
}
}
.login-button-group {
flex-direction: row;
}
.login-inner-box {
width: 55%;
padding: $space-md 56px;
}
.login-button-group {
flex-direction: row;
}
.login-button-forgot-password {
padding-top: 0;
padding-left: 10px;