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;
+ }
+}