Add possibility to change one's email with confirmation

This commit is contained in:
Clément Pasteau
2021-10-07 13:28:44 +02:00
committed by GitHub
parent c5ad127e83
commit e7aa75bcd7
9 changed files with 308 additions and 28 deletions

View File

@@ -18,6 +18,7 @@ export type AuthenticatedUser = {|
onLogout: () => void,
onLogin: () => void,
onEdit: () => void,
onChangeEmail: () => void,
onCreateAccount: () => void,
onRefreshUserProfile: () => void,
onRefreshFirebaseProfile: () => void,
@@ -35,6 +36,7 @@ export const initialAuthenticatedUser = {
onLogout: () => {},
onLogin: () => {},
onEdit: () => {},
onChangeEmail: () => {},
onCreateAccount: () => {},
onRefreshUserProfile: () => {},
onRefreshFirebaseProfile: () => {},

View File

@@ -10,7 +10,7 @@ import Authentication, {
type LoginForm,
type RegisterForm,
type EditForm,
type ForgotPasswordForm,
type ChangeEmailForm,
type AuthError,
} from '../Utils/GDevelopServices/Authentication';
import { User as FirebaseUser } from 'firebase/auth';
@@ -24,6 +24,7 @@ import AuthenticatedUserContext, {
} from './AuthenticatedUserContext';
import CreateAccountDialog from './CreateAccountDialog';
import EditProfileDialog from './EditProfileDialog';
import ChangeEmailDialog from './ChangeEmailDialog';
import EmailVerificationPendingDialog from './EmailVerificationPendingDialog';
type Props = {|
@@ -43,6 +44,8 @@ type State = {|
resetPasswordDialogOpen: boolean,
emailVerificationPendingDialogOpen: boolean,
forgotPasswordInProgress: boolean,
changeEmailDialogOpen: boolean,
changeEmailInProgress: boolean,
|};
export default class AuthenticatedUserProvider extends React.Component<
@@ -61,6 +64,8 @@ export default class AuthenticatedUserProvider extends React.Component<
resetPasswordDialogOpen: false,
emailVerificationPendingDialogOpen: false,
forgotPasswordInProgress: false,
changeEmailDialogOpen: false,
changeEmailInProgress: false,
};
componentDidMount() {
@@ -76,6 +81,7 @@ export default class AuthenticatedUserProvider extends React.Component<
onLogout: this._doLogout,
onLogin: () => this.openLoginDialog(true),
onEdit: () => this.openEditProfileDialog(true),
onChangeEmail: () => this.openChangeEmailDialog(true),
onCreateAccount: () => this.openCreateAccountDialog(true),
onRefreshUserProfile: this._fetchUserProfile,
onRefreshFirebaseProfile: this._reloadFirebaseProfile,
@@ -241,7 +247,7 @@ export default class AuthenticatedUserProvider extends React.Component<
);
};
_doForgotPassword = (form: ForgotPasswordForm) => {
_doForgotPassword = (form: LoginForm) => {
const { authentication } = this.props;
if (!authentication) return;
@@ -271,6 +277,27 @@ export default class AuthenticatedUserProvider extends React.Component<
});
};
_doChangeEmail = (form: ChangeEmailForm) => {
const { authentication } = this.props;
if (!authentication) return;
watchPromiseInState(this, 'changeEmailInProgress', () =>
authentication
.changeEmail(authentication.getAuthorizationHeader, form)
.then(
() => {
this._fetchUserProfile();
this.openChangeEmailDialog(false);
},
(authError: AuthError) => {
this.setState({
authError,
});
}
)
);
};
openEmailVerificationPendingDialog = (open: boolean = true) => {
this.setState({
emailVerificationPendingDialogOpen: open,
@@ -307,6 +334,13 @@ export default class AuthenticatedUserProvider extends React.Component<
});
};
openChangeEmailDialog = (open: boolean = true) => {
this.setState({
changeEmailDialogOpen: open,
authError: null,
});
};
render() {
return (
<React.Fragment>
@@ -336,6 +370,16 @@ export default class AuthenticatedUserProvider extends React.Component<
error={this.state.authError}
/>
)}
{this.state.authenticatedUser.firebaseUser &&
this.state.changeEmailDialogOpen && (
<ChangeEmailDialog
firebaseUser={this.state.authenticatedUser.firebaseUser}
onClose={() => this.openChangeEmailDialog(false)}
onChangeEmail={this._doChangeEmail}
changeEmailInProgress={this.state.changeEmailInProgress}
error={this.state.authError}
/>
)}
{this.state.createAccountDialogOpen && (
<CreateAccountDialog
onClose={() => this.openCreateAccountDialog(false)}

View File

@@ -0,0 +1,93 @@
// @flow
import { Trans } from '@lingui/macro';
import React, { Component } from 'react';
import FlatButton from '../UI/FlatButton';
import RaisedButton from '../UI/RaisedButton';
import Dialog from '../UI/Dialog';
import { User as FirebaseUser } from 'firebase/auth';
import {
type ChangeEmailForm,
type AuthError,
} from '../Utils/GDevelopServices/Authentication';
import LeftLoader from '../UI/LeftLoader';
import { ColumnStackLayout } from '../UI/Layout';
import TextField from '../UI/TextField';
import { getEmailErrorText } from './CreateAccountDialog';
type Props = {|
firebaseUser: FirebaseUser,
onClose: () => void,
onChangeEmail: (form: ChangeEmailForm) => void,
changeEmailInProgress: boolean,
error: ?AuthError,
|};
type State = {|
form: ChangeEmailForm,
|};
export default class ChangeEmailDialog extends Component<Props, State> {
state = {
form: {
email: this.props.firebaseUser.email,
},
};
_onChangeEmail = () => {
const { form } = this.state;
this.props.onChangeEmail(form);
};
render() {
const { onClose, changeEmailInProgress, error } = this.props;
const actions = [
<FlatButton
label={<Trans>Back</Trans>}
disabled={changeEmailInProgress}
key="back"
primary={false}
onClick={onClose}
/>,
<LeftLoader isLoading={changeEmailInProgress} key="change-email">
<RaisedButton
label={<Trans>Save</Trans>}
primary
onClick={this._onChangeEmail}
disabled={changeEmailInProgress}
/>
</LeftLoader>,
];
return (
<Dialog
title={<Trans>Change your email</Trans>}
actions={actions}
onRequestClose={() => {
if (!changeEmailInProgress) onClose();
}}
maxWidth="sm"
cannotBeDismissed={true}
open
>
<ColumnStackLayout noMargin>
<TextField
value={this.state.form.email}
floatingLabelText={<Trans>Email</Trans>}
errorText={getEmailErrorText(error)}
fullWidth
required
onChange={(e, value) => {
this.setState({
form: {
...this.state.form,
email: value,
},
});
}}
/>
</ColumnStackLayout>
</Dialog>
);
}
}

View File

@@ -40,6 +40,8 @@ export const getEmailErrorText = (error: ?AuthError) => {
return 'This email was already used for another account';
if (error.code === 'auth/operation-not-allowed')
return 'Service seems to be unavailable, please try again later';
if (error.code === 'auth/requires-recent-login')
return 'Please log out and log in again to verify your identify, then change your email';
return undefined;
};

View File

@@ -11,17 +11,18 @@ import RaisedButton from '../UI/RaisedButton';
import TextField from '../UI/TextField';
import { I18n } from '@lingui/react';
import FlatButton from '../UI/FlatButton';
import { ColumnStackLayout } from '../UI/Layout';
import { ColumnStackLayout, ResponsiveLineStackLayout } from '../UI/Layout';
import AlertMessage from '../UI/AlertMessage';
import { type AuthenticatedUser } from './AuthenticatedUserContext';
import { useIsMounted } from '../Utils/UseIsMounted';
type Props = {|
onEditProfile: Function,
onEditProfile: () => void,
onChangeEmail: () => void,
authenticatedUser: AuthenticatedUser,
|};
export default ({ onEditProfile, authenticatedUser }: Props) => {
export default ({ onEditProfile, onChangeEmail, authenticatedUser }: Props) => {
const profile = authenticatedUser.profile;
const firebaseUser = authenticatedUser.firebaseUser;
const isMounted = useIsMounted();
@@ -97,7 +98,8 @@ export default ({ onEditProfile, authenticatedUser }: Props) => {
</Line>
<Line>
<TextField
value={profile.email}
// The firebase user is the source of truth for the emails.
value={firebaseUser.email}
readOnly
fullWidth
floatingLabelText={<Trans>Email</Trans>}
@@ -117,13 +119,17 @@ export default ({ onEditProfile, authenticatedUser }: Props) => {
rowsMax={5}
/>
</Line>
<Line justifyContent="flex-end">
<ResponsiveLineStackLayout justifyContent="flex-end">
<RaisedButton
label={<Trans>Change my email</Trans>}
onClick={onChangeEmail}
/>
<RaisedButton
label={<Trans>Edit my profile</Trans>}
primary
onClick={onEditProfile}
/>
</Line>
</ResponsiveLineStackLayout>
</Column>
</ColumnStackLayout>
)}

View File

@@ -93,6 +93,7 @@ export default class ProfileDialog extends Component<Props, State> {
<ProfileDetails
authenticatedUser={authenticatedUser}
onEditProfile={authenticatedUser.onEdit}
onChangeEmail={authenticatedUser.onChangeEmail}
/>
<SubscriptionDetails
subscription={authenticatedUser.subscription}

View File

@@ -10,37 +10,38 @@ import {
sendPasswordResetEmail,
signOut,
sendEmailVerification,
updateEmail,
} from 'firebase/auth';
import { GDevelopFirebaseConfig, GDevelopUserApi } from './ApiConfigs';
import axios from 'axios';
import { showErrorBox } from '../../UI/Messages/MessageBox';
export type Profile = {
export type Profile = {|
id: string,
email: string,
username: ?string,
description: ?string,
};
|};
export type LoginForm = {
export type LoginForm = {|
email: string,
password: string,
};
|};
export type RegisterForm = {
export type RegisterForm = {|
email: string,
password: string,
username: string,
};
|};
export type EditForm = {
export type EditForm = {|
username: string,
description: string,
};
|};
export type ForgotPasswordForm = {
export type ChangeEmailForm = {|
email: string,
};
|};
export type AuthError = {
code:
@@ -52,7 +53,8 @@ export type AuthError = {
| 'auth/operation-not-allowed'
| 'auth/weak-password'
| 'auth/username-used'
| 'auth/malformed-username',
| 'auth/malformed-username'
| 'auth/requires-recent-login',
};
export default class Authentication {
@@ -100,8 +102,12 @@ export default class Authentication {
return getAuthorizationHeader()
.then(authorizationHeader => {
if (!this.firebaseUser) {
console.error('Cannot get user if not logged in');
throw new Error('Cannot get user if not logged in');
console.error(
'Cannot create the user as it is not logged in any more.'
);
throw new Error(
'Cannot create the user as it is not logged in any more.'
);
}
return axios.post(
`${GDevelopUserApi.baseUrl}/user`,
@@ -140,7 +146,7 @@ export default class Authentication {
});
};
forgotPassword = (form: ForgotPasswordForm): Promise<void> => {
forgotPassword = (form: LoginForm): Promise<void> => {
return sendPasswordResetEmail(this.auth, form.email);
};
@@ -169,12 +175,58 @@ export default class Authentication {
});
};
changeEmail = (
getAuthorizationHeader: () => Promise<string>,
form: ChangeEmailForm
) => {
return updateEmail(this.firebaseUser, form.email)
.then(() => {
console.log('Email successfully changed in Firebase.');
return getAuthorizationHeader();
})
.then(authorizationHeader => {
if (!this.firebaseUser) {
console.error(
'Cannot finish editing the user email as it is not logged in any more.'
);
throw new Error(
'Cannot finish editing the user email as it is not logged in any more.'
);
}
return axios.patch(
`${GDevelopUserApi.baseUrl}/user/${this.firebaseUser.uid}`,
{
email: form.email,
},
{
params: {
userId: this.firebaseUser.uid,
},
headers: {
Authorization: authorizationHeader,
},
}
);
})
.then(() => {
console.log('Email successfully changed in the GDevelop services.');
})
.catch(error => {
console.error('An error happened during email change.', error);
throw error;
});
};
getUserProfile = (getAuthorizationHeader: () => Promise<string>) => {
return getAuthorizationHeader()
.then(authorizationHeader => {
if (!this.firebaseUser) {
console.error('Cannot get user if not logged in');
throw new Error('Cannot get user if not logged in');
console.error(
'Cannot get the user profile as it is not logged in any more.'
);
throw new Error(
'Cannot get the user profile as it is not logged in any more.'
);
}
return axios.get(
`${GDevelopUserApi.baseUrl}/user/${this.firebaseUser.uid}`,
@@ -202,8 +254,12 @@ export default class Authentication {
return getAuthorizationHeader()
.then(authorizationHeader => {
if (!this.firebaseUser) {
console.error('Cannot get user if not logged in');
throw new Error('Cannot get user if not logged in');
console.error(
'Cannot finish editing the user as it is not logged in any more.'
);
throw new Error(
'Cannot finish editing the user as it is not logged in any more.'
);
}
const { username, description } = form;
return axios.patch(
@@ -236,10 +292,11 @@ export default class Authentication {
logout = () => {
signOut(this.auth)
.then(() => {
console.log('Logout successful');
console.log('Logout successful.');
})
.catch(error => {
console.log('An error happened during logout', error);
console.error('An error happened during logout.', error);
throw error;
});
};

View File

@@ -100,6 +100,7 @@ export const fakeIndieAuthenticatedUser: AuthenticatedUser = {
onLogout: () => {},
onLogin: () => {},
onEdit: () => {},
onChangeEmail: () => {},
onCreateAccount: () => {},
onRefreshUserProfile: () => {
console.info('This should refresh the user profile');
@@ -123,6 +124,7 @@ export const fakeNoSubscriptionAuthenticatedUser: AuthenticatedUser = {
onLogout: () => {},
onLogin: () => {},
onEdit: () => {},
onChangeEmail: () => {},
onCreateAccount: () => {},
onRefreshUserProfile: () => {
console.info('This should refresh the user profile');
@@ -146,6 +148,7 @@ export const fakeAuthenticatedAndEmailVerifiedUser: AuthenticatedUser = {
onLogout: () => {},
onLogin: () => {},
onEdit: () => {},
onChangeEmail: () => {},
onCreateAccount: () => {},
onRefreshUserProfile: () => {
console.info('This should refresh the user profile');
@@ -169,6 +172,7 @@ export const fakeAuthenticatedButLoadingAuthenticatedUser: AuthenticatedUser = {
onLogout: () => {},
onLogin: () => {},
onEdit: () => {},
onChangeEmail: () => {},
onCreateAccount: () => {},
onRefreshUserProfile: () => {
console.info('This should refresh the user profile');
@@ -192,6 +196,7 @@ export const fakeNotAuthenticatedAuthenticatedUser: AuthenticatedUser = {
onLogout: () => {},
onLogin: () => {},
onEdit: () => {},
onChangeEmail: () => {},
onCreateAccount: () => {},
onRefreshUserProfile: () => {
console.info('This should refresh the user profile');

View File

@@ -123,6 +123,7 @@ import UsagesDetails from '../Profile/UsagesDetails';
import SubscriptionDialog from '../Profile/SubscriptionDialog';
import LoginDialog from '../Profile/LoginDialog';
import EditProfileDialog from '../Profile/EditProfileDialog';
import ChangeEmailDialog from '../Profile/ChangeEmailDialog';
import AuthenticatedUserContext from '../Profile/AuthenticatedUserContext';
import { SubscriptionCheckDialog } from '../Profile/SubscriptionChecker';
import DebuggerContent from '../Debugger/DebuggerContent';
@@ -3942,12 +3943,14 @@ storiesOf('ProfileDetails', module)
<ProfileDetails
authenticatedUser={fakeIndieAuthenticatedUser}
onEditProfile={action('edit profile')}
onChangeEmail={action('change email')}
/>
))
.add('loading', () => (
<ProfileDetails
authenticatedUser={fakeAuthenticatedButLoadingAuthenticatedUser}
onEditProfile={action('edit profile')}
onChangeEmail={action('change email')}
/>
));
@@ -4122,6 +4125,73 @@ storiesOf('Profile/EditProfileDialog', module)
onEdit={action('on edit')}
error={null}
/>
))
.add('errored', () => (
<EditProfileDialog
profile={{
id: 'id',
email: 'email',
username: 'username',
description: 'I am just another video game enthusiast!',
}}
onClose={action('on close')}
editInProgress={false}
onEdit={action('on edit')}
error={{ code: 'auth/username-used' }}
/>
))
.add('loading', () => (
<EditProfileDialog
profile={{
id: 'id',
email: 'email',
username: 'username',
description: 'I am just another video game enthusiast!',
}}
onClose={action('on close')}
editInProgress
onEdit={action('on edit')}
error={null}
/>
));
storiesOf('Profile/ChangeEmailDialog', module)
.addDecorator(muiDecorator)
.add('default', () => (
<ChangeEmailDialog
firebaseUser={{
uid: 'id',
email: 'email',
}}
onClose={action('on close')}
changeEmailInProgress={false}
onChangeEmail={action('on change email')}
error={null}
/>
))
.add('errored', () => (
<ChangeEmailDialog
firebaseUser={{
uid: 'id',
email: 'email',
}}
onClose={action('on close')}
changeEmailInProgress={false}
onChangeEmail={action('on change email')}
error={{ code: 'auth/requires-recent-login' }}
/>
))
.add('loading', () => (
<ChangeEmailDialog
firebaseUser={{
uid: 'id',
email: 'email',
}}
onClose={action('on close')}
changeEmailInProgress
onChangeEmail={action('on change email')}
error={null}
/>
));
storiesOf('Profile/CreateAccountDialog', module)