diff --git a/app/soapbox/actions/auth.js b/app/soapbox/actions/auth.js index 46e7ea97e..5c884dfd1 100644 --- a/app/soapbox/actions/auth.js +++ b/app/soapbox/actions/auth.js @@ -15,6 +15,22 @@ export const RESET_PASSWORD_REQUEST = 'RESET_PASSWORD_REQUEST'; export const RESET_PASSWORD_SUCCESS = 'RESET_PASSWORD_SUCCESS'; export const RESET_PASSWORD_FAIL = 'RESET_PASSWORD_FAIL'; +export const CHANGE_EMAIL_REQUEST = 'CHANGE_EMAIL_REQUEST'; +export const CHANGE_EMAIL_SUCCESS = 'CHANGE_EMAIL_SUCCESS'; +export const CHANGE_EMAIL_FAIL = 'CHANGE_EMAIL_FAIL'; + +export const CHANGE_PASSWORD_REQUEST = 'CHANGE_PASSWORD_REQUEST'; +export const CHANGE_PASSWORD_SUCCESS = 'CHANGE_PASSWORD_SUCCESS'; +export const CHANGE_PASSWORD_FAIL = 'CHANGE_PASSWORD_FAIL'; + +export const FETCH_TOKENS_REQUEST = 'FETCH_TOKENS_REQUEST'; +export const FETCH_TOKENS_SUCCESS = 'FETCH_TOKENS_SUCCESS'; +export const FETCH_TOKENS_FAIL = 'FETCH_TOKENS_FAIL'; + +export const REVOKE_TOKEN_REQUEST = 'REVOKE_TOKEN_REQUEST'; +export const REVOKE_TOKEN_SUCCESS = 'REVOKE_TOKEN_SUCCESS'; +export const REVOKE_TOKEN_FAIL = 'REVOKE_TOKEN_FAIL'; + const noOp = () => () => new Promise(f => f()); function createAppAndToken() { @@ -148,6 +164,61 @@ export function resetPassword(nickNameOrEmail) { }; } +export function changeEmail(email, password) { + return (dispatch, getState) => { + dispatch({ type: CHANGE_EMAIL_REQUEST, email }); + return api(getState).post('/api/pleroma/change_email', { + email, + password, + }).then(response => { + if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure + dispatch({ type: CHANGE_EMAIL_SUCCESS, email, response }); + }).catch(error => { + dispatch({ type: CHANGE_EMAIL_FAIL, email, error, skipAlert: true }); + throw error; + }); + }; +} + +export function changePassword(oldPassword, newPassword, confirmation) { + return (dispatch, getState) => { + dispatch({ type: CHANGE_PASSWORD_REQUEST }); + return api(getState).post('/api/pleroma/change_password', { + password: oldPassword, + new_password: newPassword, + new_password_confirmation: confirmation, + }).then(response => { + if (response.data.error) throw response.data.error; // This endpoint returns HTTP 200 even on failure + dispatch({ type: CHANGE_PASSWORD_SUCCESS, response }); + }).catch(error => { + dispatch({ type: CHANGE_PASSWORD_FAIL, error, skipAlert: true }); + throw error; + }); + }; +} + +export function fetchOAuthTokens() { + return (dispatch, getState) => { + dispatch({ type: FETCH_TOKENS_REQUEST }); + return api(getState).get('/api/oauth_tokens.json').then(response => { + dispatch({ type: FETCH_TOKENS_SUCCESS, tokens: response.data }); + }).catch(error => { + dispatch({ type: FETCH_TOKENS_FAIL }); + }); + }; +} + +export function revokeOAuthToken(id) { + return (dispatch, getState) => { + dispatch({ type: REVOKE_TOKEN_REQUEST, id }); + return api(getState).delete(`/api/oauth_tokens/${id}`).then(response => { + dispatch({ type: REVOKE_TOKEN_SUCCESS, id }); + }).catch(error => { + dispatch({ type: REVOKE_TOKEN_FAIL, id }); + }); + }; +} + export function authAppCreated(app) { return { type: AUTH_APP_CREATED, diff --git a/app/soapbox/components/sidebar_menu.js b/app/soapbox/components/sidebar_menu.js index e660ddb19..844a1bc4d 100644 --- a/app/soapbox/components/sidebar_menu.js +++ b/app/soapbox/components/sidebar_menu.js @@ -28,6 +28,7 @@ const messages = defineMessages({ mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' }, admin_settings: { id: 'navigation_bar.admin_settings', defaultMessage: 'Admin settings' }, + security: { id: 'navigation_bar.security', defaultMessage: 'Security' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, lists: { id: 'column.lists', defaultMessage: 'Lists' }, apps: { id: 'tabs_bar.apps', defaultMessage: 'Apps' }, @@ -167,6 +168,10 @@ class SidebarMenu extends ImmutablePureComponent { {intl.formatMessage(messages.preferences)} + + + {intl.formatMessage(messages.security)} +
diff --git a/app/soapbox/features/compose/components/action_bar.js b/app/soapbox/features/compose/components/action_bar.js index 47b774850..db1774903 100644 --- a/app/soapbox/features/compose/components/action_bar.js +++ b/app/soapbox/features/compose/components/action_bar.js @@ -18,6 +18,7 @@ const messages = defineMessages({ mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' }, admin_settings: { id: 'navigation_bar.admin_settings', defaultMessage: 'Admin settings' }, + security: { id: 'navigation_bar.security', defaultMessage: 'Security' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Hotkeys' }, }); @@ -80,6 +81,7 @@ class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(messages.admin_settings), href: '/pleroma/admin/' }); } menu.push({ text: intl.formatMessage(messages.preferences), to: '/settings/preferences' }); + menu.push({ text: intl.formatMessage(messages.security), to: '/auth/edit' }); menu.push({ text: intl.formatMessage(messages.logout), to: '/auth/sign_out', action: onClickLogOut }); return ( diff --git a/app/soapbox/features/security/index.js b/app/soapbox/features/security/index.js new file mode 100644 index 000000000..c3d5fe41f --- /dev/null +++ b/app/soapbox/features/security/index.js @@ -0,0 +1,270 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl, FormattedDate } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Column from '../ui/components/column'; +import { + SimpleForm, + SimpleInput, + FieldsGroup, + TextInput, +} from 'soapbox/features/forms'; +import { + changeEmail, + changePassword, + fetchOAuthTokens, + revokeOAuthToken, +} from 'soapbox/actions/auth'; +import { showAlert } from 'soapbox/actions/alerts'; + +const messages = defineMessages({ + heading: { id: 'column.security', defaultMessage: 'Security' }, + submit: { id: 'security.submit', defaultMessage: 'Save changes' }, + updateEmailSuccess: { id: 'security.update_email.success', defaultMessage: 'Email successfully updated.' }, + updateEmailFail: { id: 'security.update_email.fail', defaultMessage: 'Update email failed.' }, + updatePasswordSuccess: { id: 'security.update_password.success', defaultMessage: 'Password successfully updated.' }, + updatePasswordFail: { id: 'security.update_password.fail', defaultMessage: 'Update password failed.' }, + emailFieldLabel: { id: 'security.fields.email.label', defaultMessage: 'Email address' }, + passwordFieldLabel: { id: 'security.fields.password.label', defaultMessage: 'Password' }, + oldPasswordFieldLabel: { id: 'security.fields.old_password.label', defaultMessage: 'Current password' }, + newPasswordFieldLabel: { id: 'security.fields.new_password.label', defaultMessage: 'New password' }, + confirmationFieldLabel: { id: 'security.fields.password_confirmation.label', defaultMessage: 'New password (again)' }, + revoke: { id: 'security.tokens.revoke', defaultMessage: 'Revoke' }, + emailHeader: { id: 'security.headers.update_email', defaultMessage: 'Change Email' }, + passwordHeader: { id: 'security.headers.update_password', defaultMessage: 'Change Password' }, + tokenHeader: { id: 'security.headers.tokens', defaultMessage: 'Sessions' }, +}); + +export default @injectIntl +class SecurityForm extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + render() { + const { intl } = this.props; + + return ( + + + + + + ); + } + +} + +@connect() +@injectIntl +class ChangeEmailForm extends ImmutablePureComponent { + + static propTypes = { + email: PropTypes.string, + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + email: '', + password: '', + isLoading: false, + } + + handleInputChange = e => { + this.setState({ [e.target.name]: e.target.value }); + } + + handleSubmit = e => { + const { email, password } = this.state; + const { dispatch, intl } = this.props; + this.setState({ isLoading: true }); + return dispatch(changeEmail(email, password)).then(() => { + this.setState({ email: '', password: '' }); // TODO: Maybe redirect user + dispatch(showAlert('', intl.formatMessage(messages.updateEmailSuccess))); + }).catch(error => { + this.setState({ password: '' }); + dispatch(showAlert('', intl.formatMessage(messages.updateEmailFail))); + }).then(() => { + this.setState({ isLoading: false }); + }); + } + + render() { + const { intl } = this.props; + + return ( + +

{intl.formatMessage(messages.emailHeader)}

+
+ + + +
+ +
+
+
+
+ ); + } + +} + +@connect() +@injectIntl +class ChangePasswordForm extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + state = { + oldPassword: '', + newPassword: '', + confirmation: '', + isLoading: false, + } + + handleInputChange = e => { + this.setState({ [e.target.name]: e.target.value }); + } + + clearForm = () => { + this.setState({ oldPassword: '', newPassword: '', confirmation: '' }); + } + + handleSubmit = e => { + const { oldPassword, newPassword, confirmation } = this.state; + const { dispatch, intl } = this.props; + this.setState({ isLoading: true }); + return dispatch(changePassword(oldPassword, newPassword, confirmation)).then(() => { + this.clearForm(); // TODO: Maybe redirect user + dispatch(showAlert('', intl.formatMessage(messages.updatePasswordSuccess))); + }).catch(error => { + this.clearForm(); + dispatch(showAlert('', intl.formatMessage(messages.updatePasswordFail))); + }).then(() => { + this.setState({ isLoading: false }); + }); + } + + render() { + const { intl } = this.props; + + return ( + +

{intl.formatMessage(messages.passwordHeader)}

+
+ + + + +
+ +
+
+
+
+ ); + } + +} + +const mapStateToProps = state => ({ + tokens: state.getIn(['auth', 'tokens']), +}); + +@connect(mapStateToProps) +@injectIntl +class AuthTokenList extends ImmutablePureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + tokens: ImmutablePropTypes.list, + }; + + handleRevoke = id => { + return e => { + this.props.dispatch(revokeOAuthToken(id)); + }; + } + + componentDidMount() { + this.props.dispatch(fetchOAuthTokens()); + } + + render() { + const { tokens, intl } = this.props; + if (tokens.isEmpty()) return null; + return ( + +

{intl.formatMessage(messages.tokenHeader)}

+
+ {tokens.reverse().map((token, i) => ( +
+
{token.get('app_name')}
+
+ +
+
+ +
+
+ ))} +
+
+ ); + } + +} diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js index 887d24fce..001f55cfc 100644 --- a/app/soapbox/features/ui/index.js +++ b/app/soapbox/features/ui/index.js @@ -72,6 +72,7 @@ import { Preferences, EditProfile, PasswordReset, + SecurityForm, } from './util/async-components'; // Dummy import, to make sure that ends up in the application bundle. @@ -194,6 +195,7 @@ class SwitchingColumnsArea extends React.PureComponent { + diff --git a/app/soapbox/features/ui/util/async-components.js b/app/soapbox/features/ui/util/async-components.js index 823a91eb2..749b72cc2 100644 --- a/app/soapbox/features/ui/util/async-components.js +++ b/app/soapbox/features/ui/util/async-components.js @@ -177,3 +177,7 @@ export function EditProfile() { export function PasswordReset() { return import(/* webpackChunkName: "features/auth_login" */'../../auth_login/components/password_reset'); } + +export function SecurityForm() { + return import(/* webpackChunkName: "features/security" */'../../security'); +} diff --git a/app/soapbox/reducers/auth.js b/app/soapbox/reducers/auth.js index b25330518..5bd996f34 100644 --- a/app/soapbox/reducers/auth.js +++ b/app/soapbox/reducers/auth.js @@ -3,12 +3,15 @@ import { AUTH_LOGGED_IN, AUTH_APP_AUTHORIZED, AUTH_LOGGED_OUT, + FETCH_TOKENS_SUCCESS, + REVOKE_TOKEN_SUCCESS, } from '../actions/auth'; -import { Map as ImmutableMap } from 'immutable'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; const initialState = ImmutableMap({ app: ImmutableMap(JSON.parse(localStorage.getItem('soapbox:auth:app'))), user: ImmutableMap(JSON.parse(localStorage.getItem('soapbox:auth:user'))), + tokens: ImmutableList(), }); export default function auth(state = initialState, action) { @@ -25,7 +28,12 @@ export default function auth(state = initialState, action) { return state.set('user', ImmutableMap(action.user)); case AUTH_LOGGED_OUT: localStorage.removeItem('soapbox:auth:user'); - return state.setIn(['user'], ImmutableMap()); + return state.set('user', ImmutableMap()); + case FETCH_TOKENS_SUCCESS: + return state.set('tokens', fromJS(action.tokens)); + case REVOKE_TOKEN_SUCCESS: + const idx = state.get('tokens').findIndex(t => t.get('id') === action.id); + return state.deleteIn(['tokens', idx]); default: return state; } diff --git a/app/styles/forms.scss b/app/styles/forms.scss index d954d4e74..8510fc59e 100644 --- a/app/styles/forms.scss +++ b/app/styles/forms.scss @@ -504,6 +504,13 @@ code { } } } + + h2 { + font-size: 20px; + line-height: normal; + margin-bottom: 14px; + font-weight: bold; + } } .block-icon { @@ -930,3 +937,28 @@ code { border-radius: 0 0 4px 4px; } } + +.authtokens { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-gap: 20px; +} + +.authtoken { + &__app-name { + font-size: 16px; + font-weight: bold; + overflow: hidden; + text-overflow: ellipsis; + } + + &__valid-until { + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + } + + &__revoke { + margin-top: 10px; + } +}