diff --git a/app/soapbox/actions/consumer-auth.ts b/app/soapbox/actions/consumer-auth.ts new file mode 100644 index 000000000..b669c6393 --- /dev/null +++ b/app/soapbox/actions/consumer-auth.ts @@ -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()}`; + }; +}; diff --git a/app/soapbox/features/auth_login/components/__tests__/login_form.test.tsx b/app/soapbox/features/auth_login/components/__tests__/login_form.test.tsx index b46acf31a..8c388601f 100644 --- a/app/soapbox/features/auth_login/components/__tests__/login_form.test.tsx +++ b/app/soapbox/features/auth_login/components/__tests__/login_form.test.tsx @@ -1,6 +1,7 @@ -import { Map as ImmutableMap } from 'immutable'; import React from 'react'; +import { normalizeInstance } from 'soapbox/normalizers'; + import { fireEvent, render, screen } from '../../../../jest/test-helpers'; import LoginForm from '../login_form'; @@ -8,7 +9,7 @@ describe('', () => { it('renders for Pleroma', () => { const mockFn = jest.fn(); const store = { - instance: ImmutableMap({ + instance: normalizeInstance({ version: '2.7.2 (compatible; Pleroma 2.3.0)', }), }; @@ -21,7 +22,7 @@ describe('', () => { it('renders for Mastodon', () => { const mockFn = jest.fn(); const store = { - instance: ImmutableMap({ + instance: normalizeInstance({ version: '3.0.0', }), }; diff --git a/app/soapbox/features/auth_login/components/__tests__/login_page.test.tsx b/app/soapbox/features/auth_login/components/__tests__/login_page.test.tsx index 50f94b285..70a0f3b95 100644 --- a/app/soapbox/features/auth_login/components/__tests__/login_page.test.tsx +++ b/app/soapbox/features/auth_login/components/__tests__/login_page.test.tsx @@ -1,13 +1,14 @@ -import { Map as ImmutableMap } from 'immutable'; import React from 'react'; +import { normalizeInstance } from 'soapbox/normalizers'; + import { render, screen } from '../../../../jest/test-helpers'; import LoginPage from '../login_page'; describe('', () => { it('renders correctly on load', () => { const store = { - instance: ImmutableMap({ + instance: normalizeInstance({ version: '2.7.2 (compatible; Pleroma 2.3.0)', }), }; diff --git a/app/soapbox/features/auth_login/components/consumer-button.tsx b/app/soapbox/features/auth_login/components/consumer-button.tsx new file mode 100644 index 000000000..6be1088af --- /dev/null +++ b/app/soapbox/features/auth_login/components/consumer-button.tsx @@ -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 = { + 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 = ({ provider }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const icon = BRAND_ICONS[provider] || require('@tabler/icons/key.svg'); + + const handleClick = () => { + dispatch(prepareRequest(provider)); + }; + + return ( + + + + ); +}; + +export default ConsumerButton; diff --git a/app/soapbox/features/auth_login/components/consumers-list.tsx b/app/soapbox/features/auth_login/components/consumers-list.tsx new file mode 100644 index 000000000..84f65d900 --- /dev/null +++ b/app/soapbox/features/auth_login/components/consumers-list.tsx @@ -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 = () => { + const providers = useAppSelector(state => ImmutableList(state.instance.pleroma.get('oauth_consumer_strategies'))); + + if (providers.size > 0) { + return ( + + + + + + {providers.map(provider => ( + + ))} + + + ); + } else { + return null; + } +}; + +export default ConsumersList; diff --git a/app/soapbox/features/auth_login/components/login_form.tsx b/app/soapbox/features/auth_login/components/login_form.tsx index 9e8b1dc35..5b1f6f733 100644 --- a/app/soapbox/features/auth_login/components/login_form.tsx +++ b/app/soapbox/features/auth_login/components/login_form.tsx @@ -2,7 +2,9 @@ import React from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; 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({ username: { @@ -29,7 +31,7 @@ const LoginForm: React.FC = ({ isLoading, handleSubmit }) => {

-
+
= ({ isLoading, handleSubmit }) => { -
+ + + ); }; diff --git a/app/soapbox/features/landing_page/index.tsx b/app/soapbox/features/landing_page/index.tsx index dcb9afd36..1e3b01f80 100644 --- a/app/soapbox/features/landing_page/index.tsx +++ b/app/soapbox/features/landing_page/index.tsx @@ -1,12 +1,15 @@ import * as React from 'react'; import { FormattedMessage } from 'react-intl'; +import { prepareRequest } from 'soapbox/actions/consumer-auth'; import { Button, Card, CardBody, Stack, Text } from 'soapbox/components/ui'; import VerificationBadge from 'soapbox/components/verification_badge'; 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 dispatch = useAppDispatch(); const features = useFeatures(); const soapboxConfig = useSoapboxConfig(); const pepeEnabled = soapboxConfig.getIn(['extensions', 'pepe', 'enabled']) === true; @@ -40,6 +43,29 @@ const LandingPage = () => { return ; }; + /** Display login button for external provider. */ + const renderProvider = () => { + const { authProvider } = soapboxConfig; + + return ( + + + + + + + + + + ); + }; + /** Pepe API registrations are open */ const renderPepe = () => { return ( @@ -47,18 +73,26 @@ const LandingPage = () => { - Let's get started! - Social Media Without Discrimination + + + + + + - + ); }; // Render registration flow depending on features const renderBody = () => { - if (pepeEnabled && pepeOpen) { + if (soapboxConfig.authProvider) { + return renderProvider(); + } else if (pepeEnabled && pepeOpen) { return renderPepe(); } else if (features.accountCreation && instance.registrations) { return renderOpen(); diff --git a/app/soapbox/normalizers/soapbox/soapbox_config.ts b/app/soapbox/normalizers/soapbox/soapbox_config.ts index 6e9a2c745..a471401c5 100644 --- a/app/soapbox/normalizers/soapbox/soapbox_config.ts +++ b/app/soapbox/normalizers/soapbox/soapbox_config.ts @@ -71,6 +71,7 @@ export const CryptoAddressRecord = ImmutableRecord({ export const SoapboxConfigRecord = ImmutableRecord({ ads: ImmutableList(), appleAppId: null, + authProvider: '', logo: '', logoDarkMode: null, banner: '', diff --git a/app/soapbox/utils/strings.ts b/app/soapbox/utils/strings.ts new file mode 100644 index 000000000..c1c8e08bc --- /dev/null +++ b/app/soapbox/utils/strings.ts @@ -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 };