Merge branch 'oauth-consumer-strategies' into 'develop'
Support signing up with a third-party account See merge request soapbox-pub/soapbox-fe!1725
This commit is contained in:
commit
946b1144be
|
@ -0,0 +1,55 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import * as BuildConfig from 'soapbox/build_config';
|
||||||
|
import { isURL } from 'soapbox/utils/auth';
|
||||||
|
import sourceCode from 'soapbox/utils/code';
|
||||||
|
import { getFeatures } from 'soapbox/utils/features';
|
||||||
|
|
||||||
|
import { createApp } from './apps';
|
||||||
|
|
||||||
|
import type { AppDispatch, RootState } from 'soapbox/store';
|
||||||
|
|
||||||
|
const createProviderApp = () => {
|
||||||
|
return async(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
|
const state = getState();
|
||||||
|
const { scopes } = getFeatures(state.instance);
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
client_name: sourceCode.displayName,
|
||||||
|
redirect_uris: `${window.location.origin}/login/external`,
|
||||||
|
website: sourceCode.homepage,
|
||||||
|
scopes,
|
||||||
|
};
|
||||||
|
|
||||||
|
return dispatch(createApp(params));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prepareRequest = (provider: string) => {
|
||||||
|
return async(dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
|
const baseURL = isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : '';
|
||||||
|
|
||||||
|
const state = getState();
|
||||||
|
const { scopes } = getFeatures(state.instance);
|
||||||
|
const app = await dispatch(createProviderApp());
|
||||||
|
const { client_id, redirect_uri } = app;
|
||||||
|
|
||||||
|
localStorage.setItem('soapbox:external:app', JSON.stringify(app));
|
||||||
|
localStorage.setItem('soapbox:external:baseurl', baseURL);
|
||||||
|
localStorage.setItem('soapbox:external:scopes', scopes);
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
provider,
|
||||||
|
authorization: {
|
||||||
|
client_id,
|
||||||
|
redirect_uri,
|
||||||
|
scope: scopes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const formdata = axios.toFormData(params);
|
||||||
|
const query = new URLSearchParams(formdata as any);
|
||||||
|
|
||||||
|
location.href = `${baseURL}/oauth/prepare_request?${query.toString()}`;
|
||||||
|
};
|
||||||
|
};
|
|
@ -1,6 +1,7 @@
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { normalizeInstance } from 'soapbox/normalizers';
|
||||||
|
|
||||||
import { fireEvent, render, screen } from '../../../../jest/test-helpers';
|
import { fireEvent, render, screen } from '../../../../jest/test-helpers';
|
||||||
import LoginForm from '../login_form';
|
import LoginForm from '../login_form';
|
||||||
|
|
||||||
|
@ -8,7 +9,7 @@ describe('<LoginForm />', () => {
|
||||||
it('renders for Pleroma', () => {
|
it('renders for Pleroma', () => {
|
||||||
const mockFn = jest.fn();
|
const mockFn = jest.fn();
|
||||||
const store = {
|
const store = {
|
||||||
instance: ImmutableMap({
|
instance: normalizeInstance({
|
||||||
version: '2.7.2 (compatible; Pleroma 2.3.0)',
|
version: '2.7.2 (compatible; Pleroma 2.3.0)',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
@ -21,7 +22,7 @@ describe('<LoginForm />', () => {
|
||||||
it('renders for Mastodon', () => {
|
it('renders for Mastodon', () => {
|
||||||
const mockFn = jest.fn();
|
const mockFn = jest.fn();
|
||||||
const store = {
|
const store = {
|
||||||
instance: ImmutableMap({
|
instance: normalizeInstance({
|
||||||
version: '3.0.0',
|
version: '3.0.0',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { normalizeInstance } from 'soapbox/normalizers';
|
||||||
|
|
||||||
import { render, screen } from '../../../../jest/test-helpers';
|
import { render, screen } from '../../../../jest/test-helpers';
|
||||||
import LoginPage from '../login_page';
|
import LoginPage from '../login_page';
|
||||||
|
|
||||||
describe('<LoginPage />', () => {
|
describe('<LoginPage />', () => {
|
||||||
it('renders correctly on load', () => {
|
it('renders correctly on load', () => {
|
||||||
const store = {
|
const store = {
|
||||||
instance: ImmutableMap({
|
instance: normalizeInstance({
|
||||||
version: '2.7.2 (compatible; Pleroma 2.3.0)',
|
version: '2.7.2 (compatible; Pleroma 2.3.0)',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
import { prepareRequest } from 'soapbox/actions/consumer-auth';
|
||||||
|
import { IconButton, Tooltip } from 'soapbox/components/ui';
|
||||||
|
import { useAppDispatch } from 'soapbox/hooks';
|
||||||
|
import { capitalize } from 'soapbox/utils/strings';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
tooltip: { id: 'oauth_consumer.tooltip', defaultMessage: 'Sign in with {provider}' },
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Map between OAuth providers and brand icons. */
|
||||||
|
const BRAND_ICONS: Record<string, string> = {
|
||||||
|
twitter: require('@tabler/icons/brand-twitter.svg'),
|
||||||
|
facebook: require('@tabler/icons/brand-facebook.svg'),
|
||||||
|
google: require('@tabler/icons/brand-google.svg'),
|
||||||
|
microsoft: require('@tabler/icons/brand-windows.svg'),
|
||||||
|
slack: require('@tabler/icons/brand-slack.svg'),
|
||||||
|
github: require('@tabler/icons/brand-github.svg'),
|
||||||
|
};
|
||||||
|
|
||||||
|
interface IConsumerButton {
|
||||||
|
provider: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OAuth consumer button for logging in with a third-party service. */
|
||||||
|
const ConsumerButton: React.FC<IConsumerButton> = ({ provider }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const icon = BRAND_ICONS[provider] || require('@tabler/icons/key.svg');
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
dispatch(prepareRequest(provider));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip text={intl.formatMessage(messages.tooltip, { provider: capitalize(provider) })}>
|
||||||
|
<IconButton
|
||||||
|
theme='outlined'
|
||||||
|
className='p-2.5'
|
||||||
|
iconClassName='w-6 h-6'
|
||||||
|
src={icon}
|
||||||
|
onClick={handleClick}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConsumerButton;
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Card, HStack, Text } from 'soapbox/components/ui';
|
||||||
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
import ConsumerButton from './consumer-button';
|
||||||
|
|
||||||
|
interface IConsumersList {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Displays OAuth consumers to log in with. */
|
||||||
|
const ConsumersList: React.FC<IConsumersList> = () => {
|
||||||
|
const providers = useAppSelector(state => ImmutableList<string>(state.instance.pleroma.get('oauth_consumer_strategies')));
|
||||||
|
|
||||||
|
if (providers.size > 0) {
|
||||||
|
return (
|
||||||
|
<Card className='p-4 sm:rounded-xl bg-gray-50'>
|
||||||
|
<Text size='xs' theme='muted'>
|
||||||
|
<FormattedMessage id='oauth_consumers.title' defaultMessage='Other ways to sign in' />
|
||||||
|
</Text>
|
||||||
|
<HStack space={2}>
|
||||||
|
{providers.map(provider => (
|
||||||
|
<ConsumerButton provider={provider} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConsumersList;
|
|
@ -2,7 +2,9 @@ import React from 'react';
|
||||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { Button, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui';
|
import { Button, Form, FormActions, FormGroup, Input, Stack } from 'soapbox/components/ui';
|
||||||
|
|
||||||
|
import ConsumersList from './consumers-list';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
username: {
|
username: {
|
||||||
|
@ -29,7 +31,7 @@ const LoginForm: React.FC<ILoginForm> = ({ isLoading, handleSubmit }) => {
|
||||||
<h1 className='text-center font-bold text-2xl'><FormattedMessage id='login_form.header' defaultMessage='Sign In' /></h1>
|
<h1 className='text-center font-bold text-2xl'><FormattedMessage id='login_form.header' defaultMessage='Sign In' /></h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
|
<Stack className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto' space={5}>
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
<FormGroup labelText={intl.formatMessage(messages.username)}>
|
<FormGroup labelText={intl.formatMessage(messages.username)}>
|
||||||
<Input
|
<Input
|
||||||
|
@ -76,7 +78,9 @@ const LoginForm: React.FC<ILoginForm> = ({ isLoading, handleSubmit }) => {
|
||||||
</Button>
|
</Button>
|
||||||
</FormActions>
|
</FormActions>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
|
||||||
|
<ConsumersList />
|
||||||
|
</Stack>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { prepareRequest } from 'soapbox/actions/consumer-auth';
|
||||||
import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui';
|
import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui';
|
||||||
import VerificationBadge from 'soapbox/components/verification_badge';
|
import VerificationBadge from 'soapbox/components/verification_badge';
|
||||||
import RegistrationForm from 'soapbox/features/auth_login/components/registration_form';
|
import RegistrationForm from 'soapbox/features/auth_login/components/registration_form';
|
||||||
import { useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
|
import { useAppDispatch, useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
|
||||||
|
import { capitalize } from 'soapbox/utils/strings';
|
||||||
|
|
||||||
const LandingPage = () => {
|
const LandingPage = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
const soapboxConfig = useSoapboxConfig();
|
const soapboxConfig = useSoapboxConfig();
|
||||||
const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true;
|
const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true;
|
||||||
|
@ -40,6 +43,29 @@ const LandingPage = () => {
|
||||||
return <RegistrationForm />;
|
return <RegistrationForm />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Display login button for external provider. */
|
||||||
|
const renderProvider = () => {
|
||||||
|
const { authProvider } = soapboxConfig;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack space={3}>
|
||||||
|
<Stack>
|
||||||
|
<Text size='2xl' weight='bold' align='center'>
|
||||||
|
<FormattedMessage id='registrations.get_started' defaultMessage="Let's get started!" />
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Button onClick={() => dispatch(prepareRequest(authProvider))} theme='primary' block>
|
||||||
|
<FormattedMessage
|
||||||
|
id='oauth_consumer.tooltip'
|
||||||
|
defaultMessage='Sign in with {provider}'
|
||||||
|
values={{ provider: capitalize(authProvider) }}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/** Pepe API registrations are open */
|
/** Pepe API registrations are open */
|
||||||
const renderPepe = () => {
|
const renderPepe = () => {
|
||||||
return (
|
return (
|
||||||
|
@ -47,18 +73,26 @@ const LandingPage = () => {
|
||||||
<VerificationBadge className='h-16 w-16 mx-auto' />
|
<VerificationBadge className='h-16 w-16 mx-auto' />
|
||||||
|
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text size='2xl' weight='bold' align='center'>Let's get started!</Text>
|
<Text size='2xl' weight='bold' align='center'>
|
||||||
<Text theme='muted' align='center'>Social Media Without Discrimination</Text>
|
<FormattedMessage id='registrations.get_started' defaultMessage="Let's get started!" />
|
||||||
|
</Text>
|
||||||
|
<Text theme='muted' align='center'>
|
||||||
|
<FormattedMessage id='registrations.tagline' defaultMessage='Social Media Without Discrimination' />
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Button to='/verify' theme='primary' block>Create an account</Button>
|
<Button to='/verify' theme='primary' block>
|
||||||
|
<FormattedMessage id='registrations.create_account' defaultMessage='Create an account' />
|
||||||
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render registration flow depending on features
|
// Render registration flow depending on features
|
||||||
const renderBody = () => {
|
const renderBody = () => {
|
||||||
if (pepeEnabled && pepeOpen) {
|
if (soapboxConfig.authProvider) {
|
||||||
|
return renderProvider();
|
||||||
|
} else if (pepeEnabled && pepeOpen) {
|
||||||
return renderPepe();
|
return renderPepe();
|
||||||
} else if (features.accountCreation && instance.registrations) {
|
} else if (features.accountCreation && instance.registrations) {
|
||||||
return renderOpen();
|
return renderOpen();
|
||||||
|
|
|
@ -71,6 +71,7 @@ export const CryptoAddressRecord = ImmutableRecord({
|
||||||
export const SoapboxConfigRecord = ImmutableRecord({
|
export const SoapboxConfigRecord = ImmutableRecord({
|
||||||
ads: ImmutableList<Ad>(),
|
ads: ImmutableList<Ad>(),
|
||||||
appleAppId: null,
|
appleAppId: null,
|
||||||
|
authProvider: '',
|
||||||
logo: '',
|
logo: '',
|
||||||
logoDarkMode: null,
|
logoDarkMode: null,
|
||||||
banner: '',
|
banner: '',
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
/** Capitalize the first letter of a string. */
|
||||||
|
// https://stackoverflow.com/a/1026087
|
||||||
|
function capitalize(str: string) {
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { capitalize };
|
Loading…
Reference in New Issue