diff --git a/app/soapbox/actions/apps.js b/app/soapbox/actions/apps.js index b0824d384..7eb00c98c 100644 --- a/app/soapbox/actions/apps.js +++ b/app/soapbox/actions/apps.js @@ -16,10 +16,10 @@ export const APP_VERIFY_CREDENTIALS_REQUEST = 'APP_VERIFY_CREDENTIALS_REQUEST'; export const APP_VERIFY_CREDENTIALS_SUCCESS = 'APP_VERIFY_CREDENTIALS_SUCCESS'; export const APP_VERIFY_CREDENTIALS_FAIL = 'APP_VERIFY_CREDENTIALS_FAIL'; -export function createApp(params) { +export function createApp(params, baseURL) { return (dispatch, getState) => { dispatch({ type: APP_CREATE_REQUEST, params }); - return baseClient().post('/api/v1/apps', params).then(({ data: app }) => { + return baseClient(null, baseURL).post('/api/v1/apps', params).then(({ data: app }) => { dispatch({ type: APP_CREATE_SUCCESS, params, app }); return app; }).catch(error => { diff --git a/app/soapbox/actions/auth.js b/app/soapbox/actions/auth.js index 2918d41a2..7b357edfa 100644 --- a/app/soapbox/actions/auth.js +++ b/app/soapbox/actions/auth.js @@ -50,6 +50,7 @@ function createAuthApp() { client_name: sourceCode.displayName, redirect_uris: 'urn:ietf:wg:oauth:2.0:oob', scopes: 'read write follow push admin', + website: sourceCode.homepage, }; return dispatch(createApp(params)).then(app => { @@ -88,10 +89,8 @@ function createUserToken(username, password) { password: password, }; - return dispatch(obtainOAuthToken(params)).then(token => { - dispatch(authLoggedIn(token)); - return token; - }); + return dispatch(obtainOAuthToken(params)) + .then(token => dispatch(authLoggedIn(token))); }; } @@ -110,9 +109,8 @@ export function refreshUserToken() { grant_type: 'refresh_token', }; - return dispatch(obtainOAuthToken(params)).then(token => { - dispatch(authLoggedIn(token)); - }); + return dispatch(obtainOAuthToken(params)) + .then(token => dispatch(authLoggedIn(token))); }; } @@ -126,10 +124,7 @@ export function otpVerify(code, mfa_token) { code: code, challenge_type: 'totp', redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', - }).then(({ data: token }) => { - dispatch(authLoggedIn(token)); - return token; - }); + }).then(({ data: token }) => dispatch(authLoggedIn(token))); }; } @@ -209,12 +204,9 @@ export function register(params) { return (dispatch, getState) => { params.fullname = params.username; - return dispatch(createAppAndToken()).then(() => { - return dispatch(createAccount(params)); - }).then(({ token }) => { - dispatch(authLoggedIn(token)); - return token; - }); + return dispatch(createAppAndToken()) + .then(() => dispatch(createAccount(params))) + .then(({ token }) => dispatch(authLoggedIn(token))); }; } @@ -225,8 +217,8 @@ export function fetchCaptcha() { } export function authLoggedIn(token) { - return { - type: AUTH_LOGGED_IN, - token, + return (dispatch, getState) => { + dispatch({ type: AUTH_LOGGED_IN, token }); + return token; }; } diff --git a/app/soapbox/actions/external_auth.js b/app/soapbox/actions/external_auth.js new file mode 100644 index 000000000..951f953bb --- /dev/null +++ b/app/soapbox/actions/external_auth.js @@ -0,0 +1,65 @@ +/** + * External Auth: workflow for logging in to remote servers. + * @module soapbox/actions/external_auth + * @see module:soapbox/actions/auth + * @see module:soapbox/actions/apps + * @see module:soapbox/actions/oauth + */ + +import { createApp } from 'soapbox/actions/apps'; +import { obtainOAuthToken } from 'soapbox/actions/oauth'; +import { authLoggedIn, verifyCredentials } from 'soapbox/actions/auth'; +import { parseBaseURL } from 'soapbox/utils/auth'; +import sourceCode from 'soapbox/utils/code'; + +const scopes = 'read write follow push'; + +export function createAppAndRedirect(host) { + return (dispatch, getState) => { + const baseURL = parseBaseURL(host) || parseBaseURL(`https://${host}`); + + const params = { + client_name: sourceCode.displayName, + redirect_uris: `${window.location.origin}/auth/external`, + website: sourceCode.homepage, + scopes, + }; + + return dispatch(createApp(params, baseURL)).then(app => { + const { client_id, redirect_uri } = app; + + const query = new URLSearchParams({ + client_id, + redirect_uri, + response_type: 'code', + scopes, + }); + + localStorage.setItem('soapbox:external:app', JSON.stringify(app)); + localStorage.setItem('soapbox:external:baseurl', baseURL); + + window.location.href = `${baseURL}/oauth/authorize?${query.toString()}`; + }); + }; +} + +export function loginWithCode(code) { + return (dispatch, getState) => { + const { client_id, client_secret, redirect_uri } = JSON.parse(localStorage.getItem('soapbox:external:app')); + const baseURL = localStorage.getItem('soapbox:external:baseurl'); + + const params = { + client_id, + client_secret, + redirect_uri, + grant_type: 'authorization_code', + scope: scopes, + code, + }; + + return dispatch(obtainOAuthToken(params, baseURL)) + .then(token => dispatch(authLoggedIn(token))) + .then(({ access_token }) => dispatch(verifyCredentials(access_token, baseURL))) + .then(() => window.location.href = '/'); + }; +} diff --git a/app/soapbox/actions/oauth.js b/app/soapbox/actions/oauth.js index e3ee95f3d..fe25d34f0 100644 --- a/app/soapbox/actions/oauth.js +++ b/app/soapbox/actions/oauth.js @@ -16,10 +16,10 @@ export const OAUTH_TOKEN_REVOKE_REQUEST = 'OAUTH_TOKEN_REVOKE_REQUEST'; export const OAUTH_TOKEN_REVOKE_SUCCESS = 'OAUTH_TOKEN_REVOKE_SUCCESS'; export const OAUTH_TOKEN_REVOKE_FAIL = 'OAUTH_TOKEN_REVOKE_FAIL'; -export function obtainOAuthToken(params) { +export function obtainOAuthToken(params, baseURL) { return (dispatch, getState) => { dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params }); - return baseClient().post('/oauth/token', params).then(({ data: token }) => { + return baseClient(null, baseURL).post('/oauth/token', params).then(({ data: token }) => { dispatch({ type: OAUTH_TOKEN_CREATE_SUCCESS, params, token }); return token; }).catch(error => { diff --git a/app/soapbox/components/helmet.js b/app/soapbox/components/helmet.js index e6da9b15e..91cc86c9d 100644 --- a/app/soapbox/components/helmet.js +++ b/app/soapbox/components/helmet.js @@ -3,6 +3,7 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { Helmet } from'react-helmet'; import { getSettings } from 'soapbox/actions/settings'; +import sourceCode from 'soapbox/utils/code'; const getNotifTotals = state => { const notifications = state.getIn(['notifications', 'unread'], 0); @@ -16,7 +17,7 @@ const mapStateToProps = state => { const settings = getSettings(state); return { - siteTitle: state.getIn(['instance', 'title']), + siteTitle: state.getIn(['instance', 'title'], sourceCode.displayName), unreadCount: getNotifTotals(state), demetricator: settings.get('demetricator'), }; diff --git a/app/soapbox/containers/soapbox.js b/app/soapbox/containers/soapbox.js index d3c8dbb0f..27f7ef614 100644 --- a/app/soapbox/containers/soapbox.js +++ b/app/soapbox/containers/soapbox.js @@ -13,7 +13,6 @@ import { Switch, BrowserRouter, Route } from 'react-router-dom'; import { ScrollContext } from 'react-router-scroll-4'; import UI from '../features/ui'; // import Introduction from '../features/introduction'; -import { fetchCustomEmojis } from '../actions/custom_emojis'; import { preload } from '../actions/preload'; import { IntlProvider } from 'react-intl'; import ErrorBoundary from '../components/error_boundary'; @@ -34,7 +33,6 @@ store.dispatch(preload()); store.dispatch(fetchMe()); store.dispatch(fetchInstance()); store.dispatch(fetchSoapboxConfig()); -store.dispatch(fetchCustomEmojis()); const mapStateToProps = (state) => { const me = state.get('me'); diff --git a/app/soapbox/features/external_login/components/external_login_form.js b/app/soapbox/features/external_login/components/external_login_form.js new file mode 100644 index 000000000..0fa91d590 --- /dev/null +++ b/app/soapbox/features/external_login/components/external_login_form.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { injectIntl, FormattedMessage, defineMessages } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { SimpleForm, FieldsGroup, TextInput } from 'soapbox/features/forms'; +import { createAppAndRedirect, loginWithCode } from 'soapbox/actions/external_auth'; +import LoadingIndicator from 'soapbox/components/loading_indicator'; + +const messages = defineMessages({ + instanceLabel: { id: 'login.fields.instance_label', defaultMessage: 'Instance' }, + instancePlaceholder: { id: 'login.fields.instance_placeholder', defaultMessage: 'example.com' }, +}); + +export default @connect() +@injectIntl +class ExternalLoginForm extends ImmutablePureComponent { + + state = { + host: '', + isLoading: false, + } + + handleHostChange = ({ target }) => { + this.setState({ host: target.value }); + } + + handleSubmit = e => { + const { dispatch } = this.props; + const { host } = this.state; + + this.setState({ isLoading: true }); + + dispatch(createAppAndRedirect(host)) + .then(() => this.setState({ isLoading: false })) + .catch(() => this.setState({ isLoading: false })); + } + + componentDidMount() { + const code = new URLSearchParams(window.location.search).get('code'); + + if (code) { + this.setState({ code }); + this.props.dispatch(loginWithCode(code)); + } + } + + render() { + const { intl } = this.props; + const { isLoading, code } = this.state; + + if (code) { + return ; + } + + return ( + +
+ + + +
+
+ +
+
+ ); + } + +} diff --git a/app/soapbox/features/external_login/index.js b/app/soapbox/features/external_login/index.js new file mode 100644 index 000000000..1971b1813 --- /dev/null +++ b/app/soapbox/features/external_login/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ExternalLoginForm from './components/external_login_form'; + +export default class ExternalLoginPage extends ImmutablePureComponent { + + render() { + return ; + } + +} diff --git a/app/soapbox/features/public_layout/index.js b/app/soapbox/features/public_layout/index.js index 46e1580c6..621e9c25b 100644 --- a/app/soapbox/features/public_layout/index.js +++ b/app/soapbox/features/public_layout/index.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { Switch, Route } from 'react-router-dom'; +import { Switch, Route, Redirect } from 'react-router-dom'; import NotificationsContainer from 'soapbox/features/ui/containers/notifications_container'; import ModalContainer from 'soapbox/features/ui/containers/modal_container'; import Header from './components/header'; @@ -9,10 +9,23 @@ import Footer from './components/footer'; import LandingPage from '../landing_page'; import AboutPage from '../about'; import { getSoapboxConfig } from 'soapbox/actions/soapbox'; +import { isPrerendered } from 'soapbox/precheck'; + +const validInstance = state => { + const v = state.getIn(['instance', 'version']); + return v && typeof v === 'string' && v !== '0.0.0'; +}; + +const isStandalone = state => { + const hasInstance = validInstance(state); + const instanceFetchFailed = state.getIn(['meta', 'instance_fetch_failed']); + + return !isPrerendered && !hasInstance && instanceFetchFailed; +}; const mapStateToProps = (state, props) => ({ - instance: state.get('instance'), soapbox: getSoapboxConfig(state), + standalone: isStandalone(state), }); const wave = ( @@ -24,8 +37,11 @@ const wave = ( class PublicLayout extends ImmutablePureComponent { render() { - const { instance } = this.props; - if (instance.isEmpty()) return null; + const { standalone } = this.props; + + if (standalone) { + return ; + } return (
diff --git a/app/soapbox/features/ui/index.js b/app/soapbox/features/ui/index.js index 45d77a466..35c768593 100644 --- a/app/soapbox/features/ui/index.js +++ b/app/soapbox/features/ui/index.js @@ -43,6 +43,7 @@ import { isStaff, isAdmin } from 'soapbox/utils/accounts'; import ProfileHoverCard from 'soapbox/components/profile_hover_card'; import { getAccessToken } from 'soapbox/utils/auth'; import { getFeatures } from 'soapbox/utils/features'; +import { fetchCustomEmojis } from 'soapbox/actions/custom_emojis'; import { Status, @@ -79,6 +80,7 @@ import { // GroupCreate, // GroupEdit, LoginPage, + ExternalLogin, Preferences, EditProfile, SoapboxConfig, @@ -195,6 +197,7 @@ class SwitchingColumnsArea extends React.PureComponent { + @@ -458,6 +461,8 @@ class UI extends React.PureComponent { setTimeout(() => this.props.dispatch(fetchScheduledStatuses()), 900); } + + this.props.dispatch(fetchCustomEmojis()); this.connectStreaming(); } @@ -588,7 +593,10 @@ class UI extends React.PureComponent { const { draggingOver, mobile } = this.state; const { intl, children, location, dropdownMenuIsOpen, me } = this.props; - if (me === null || !streamingUrl) return null; + // Wait for login to succeed or fail + if (me === null) return null; + // If login didn't fail, wait for streaming to become available + if (me !== false && !streamingUrl) return null; const handlers = me ? { help: this.handleHotkeyToggleHelp, diff --git a/app/soapbox/features/ui/util/async-components.js b/app/soapbox/features/ui/util/async-components.js index 1e5645869..1fe50212c 100644 --- a/app/soapbox/features/ui/util/async-components.js +++ b/app/soapbox/features/ui/util/async-components.js @@ -170,6 +170,10 @@ export function LoginPage() { return import(/* webpackChunkName: "features/auth_login" */'../../auth_login/components/login_page'); } +export function ExternalLogin() { + return import(/* webpackChunkName: "features/external_login" */'../../external_login'); +} + export function Preferences() { return import(/* webpackChunkName: "features/preferences" */'../../preferences'); } diff --git a/app/soapbox/main.js b/app/soapbox/main.js index 537ad5b40..a65b65c67 100644 --- a/app/soapbox/main.js +++ b/app/soapbox/main.js @@ -1,6 +1,7 @@ 'use strict'; import './wdyr'; +import './precheck'; // FIXME: Push notifications are temporarily removed // import * as registerPushNotifications from './actions/push_notifications'; // import { default as Soapbox, store } from './containers/soapbox'; diff --git a/app/soapbox/precheck.js b/app/soapbox/precheck.js new file mode 100644 index 000000000..9b03a3f76 --- /dev/null +++ b/app/soapbox/precheck.js @@ -0,0 +1,11 @@ +/** + * Precheck: information about the site before anything renders. + * @module soapbox/precheck + */ + +const hasTitle = Boolean(document.querySelector('title')); + +const hasPrerenderPleroma = Boolean(document.getElementById('initial-results')); +const hasPrerenderMastodon = Boolean(document.getElementById('initial-state')); + +export const isPrerendered = hasTitle || hasPrerenderPleroma || hasPrerenderMastodon; diff --git a/app/soapbox/reducers/meta.js b/app/soapbox/reducers/meta.js index 858b22547..eac95187d 100644 --- a/app/soapbox/reducers/meta.js +++ b/app/soapbox/reducers/meta.js @@ -1,6 +1,7 @@ 'use strict'; import { ME_FETCH_SUCCESS, ME_PATCH_SUCCESS } from 'soapbox/actions/me'; +import { INSTANCE_FETCH_FAIL } from 'soapbox/actions/instance'; import { Map as ImmutableMap, fromJS } from 'immutable'; const initialState = ImmutableMap(); @@ -19,6 +20,8 @@ export default function meta(state = initialState, action) { case ME_FETCH_SUCCESS: case ME_PATCH_SUCCESS: return importAccount(state, fromJS(action.me)); + case INSTANCE_FETCH_FAIL: + return state.set('instance_fetch_failed', true); default: return state; } diff --git a/app/soapbox/utils/code.js b/app/soapbox/utils/code.js index 9e7237066..01786a2ff 100644 --- a/app/soapbox/utils/code.js +++ b/app/soapbox/utils/code.js @@ -37,4 +37,5 @@ module.exports = { url: pkg.repository.url, repository: shortRepoName(pkg.repository.url), version: version(pkg), + homepage: pkg.homepage, };