From c4479a0ffc7ecf2fc0519545316a8f120fde33f4 Mon Sep 17 00:00:00 2001 From: Gabe Kangas Date: Sat, 20 Aug 2022 16:13:31 -0700 Subject: [PATCH] Add first pass at IndieAuth modal. For #1863 --- .../common/UserDropdown/UserDropdown.tsx | 11 +- web/components/modals/AuthModal.tsx | 6 - .../modals/AuthModal/AuthModal.module.scss | 11 ++ web/components/modals/AuthModal/AuthModal.tsx | 64 +++++++ web/components/modals/IndieAuthModal.tsx | 156 +++++++++++++++++- web/components/stores/ClientConfigStore.tsx | 7 + .../connected-client-info-handler.ts | 4 +- web/components/ui/Modal/Modal.tsx | 2 +- web/interfaces/user.model.ts | 1 + web/stories/AuthModal.stories.tsx | 9 +- web/stories/IndieAuthModal.stories.tsx | 2 +- 11 files changed, 258 insertions(+), 15 deletions(-) delete mode 100644 web/components/modals/AuthModal.tsx create mode 100644 web/components/modals/AuthModal/AuthModal.module.scss create mode 100644 web/components/modals/AuthModal/AuthModal.tsx diff --git a/web/components/common/UserDropdown/UserDropdown.tsx b/web/components/common/UserDropdown/UserDropdown.tsx index 27b49410d..2212e41bc 100644 --- a/web/components/common/UserDropdown/UserDropdown.tsx +++ b/web/components/common/UserDropdown/UserDropdown.tsx @@ -18,6 +18,7 @@ import { import s from './UserDropdown.module.scss'; import NameChangeModal from '../../modals/NameChangeModal'; import { AppStateOptions } from '../../stores/application-state'; +import AuthModal from '../../modals/AuthModal/AuthModal'; interface Props { username?: string; @@ -26,6 +27,7 @@ interface Props { export default function UserDropdown({ username: defaultUsername }: Props) { const username = defaultUsername || useRecoilValue(chatDisplayNameAtom); const [showNameChangeModal, setShowNameChangeModal] = useState(false); + const [showAuthModal, setShowAuthModal] = useState(false); const [chatToggleVisible, setChatToggleVisible] = useRecoilState(chatVisibleToggleAtom); const appState = useRecoilValue(appStateAtom); @@ -52,7 +54,7 @@ export default function UserDropdown({ username: defaultUsername }: Props) { } onClick={() => handleChangeName()}> Change name - }> + } onClick={() => setShowAuthModal(true)}> Authenticate {appState.chatAvailable && ( @@ -80,6 +82,13 @@ export default function UserDropdown({ username: defaultUsername }: Props) { > + setShowAuthModal(false)} + > + + ); } diff --git a/web/components/modals/AuthModal.tsx b/web/components/modals/AuthModal.tsx deleted file mode 100644 index 232f3f3b9..000000000 --- a/web/components/modals/AuthModal.tsx +++ /dev/null @@ -1,6 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -interface Props {} - -export default function AuthModal(props: Props) { - return
Component goes here
; -} diff --git a/web/components/modals/AuthModal/AuthModal.module.scss b/web/components/modals/AuthModal/AuthModal.module.scss new file mode 100644 index 000000000..f086ab1b7 --- /dev/null +++ b/web/components/modals/AuthModal/AuthModal.module.scss @@ -0,0 +1,11 @@ +.tabContent { + flex-direction: row; + display: flex; + justify-content: center; + align-items: center; + + .icon { + height: 15px; + padding-right: 5px; + } +} diff --git a/web/components/modals/AuthModal/AuthModal.tsx b/web/components/modals/AuthModal/AuthModal.tsx new file mode 100644 index 000000000..4d951eca9 --- /dev/null +++ b/web/components/modals/AuthModal/AuthModal.tsx @@ -0,0 +1,64 @@ +import { Tabs } from 'antd'; +import { useRecoilValue } from 'recoil'; +import IndieAuthModal from '../IndieAuthModal'; +import FediAuthModal from '../FediAuthModal'; + +import FediverseIcon from '../../../assets/images/fediverse-black.png'; +import IndieAuthIcon from '../../../assets/images/indieauth.png'; + +import s from './AuthModal.module.scss'; +import { + chatDisplayNameAtom, + chatAuthenticatedAtom, + accessTokenAtom, +} from '../../stores/ClientConfigStore'; + +const { TabPane } = Tabs; + +/* eslint-disable @typescript-eslint/no-unused-vars */ +interface Props {} + +export default function AuthModal(props: Props) { + const chatDisplayName = useRecoilValue(chatDisplayNameAtom); + const authenticated = useRecoilValue(chatAuthenticatedAtom); + const accessToken = useRecoilValue(accessTokenAtom); + const federationEnabled = false; + + return ( +
+ null} + > + + IndieAuth + IndieAuth + + } + key="1" + > + + + + Fediverse auth + FediAuth + + } + key="2" + > + + + +
+ ); +} diff --git a/web/components/modals/IndieAuthModal.tsx b/web/components/modals/IndieAuthModal.tsx index bdb811b75..9a81327d6 100644 --- a/web/components/modals/IndieAuthModal.tsx +++ b/web/components/modals/IndieAuthModal.tsx @@ -1,6 +1,156 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -interface Props {} +import { Alert, Button, Input, Space, Spin, Collapse, Typography } from 'antd'; +import React, { useState } from 'react'; +import isValidURL from '../../utils/urls'; + +const { Panel } = Collapse; +const { Link } = Typography; + +interface Props { + authenticated: boolean; + displayName: string; + accessToken: string; +} export default function IndieAuthModal(props: Props) { - return
Component goes here
; + const { authenticated, displayName: username, accessToken } = props; + + const [errorMessage, setErrorMessage] = useState(null); + const [loading, setLoading] = useState(false); + const [valid, setValid] = useState(false); + const [host, setHost] = useState(''); + + const message = !authenticated ? ( + + Use your own domain to authenticate {username} or login as a previously{' '} + authenticated chat user using IndieAuth. + + ) : ( + + You are already authenticated. However, you can add other domains or log in as a + different user. + + ); + + let errorMessageText = errorMessage; + if (errorMessageText) { + if (errorMessageText.includes('url does not support indieauth')) { + errorMessageText = 'The provided URL is either invalid or does not support IndieAuth.'; + } + } + + const validate = (url: string) => { + if (!isValidURL(url)) { + setValid(false); + return; + } + + if (!url.includes('.')) { + setValid(false); + return; + } + + setValid(true); + }; + + const onInput = (e: React.ChangeEvent) => { + // Don't allow people to type custom ports or protocols. + const char = (e.nativeEvent as any).data; + if (char === ':') { + return; + } + + setHost(e.target.value); + const h = `https://${e.target.value}`; + validate(h); + }; + + const submitButtonPressed = async () => { + if (!valid) { + return; + } + + setLoading(true); + + try { + const url = `/api/auth/indieauth?accessToken=${accessToken}`; + const h = `https://${host}`; + const data = { authHost: h }; + const rawResponse = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + const content = await rawResponse.json(); + if (content.message) { + setErrorMessage(content.message); + setLoading(false); + return; + } + if (!content.redirect) { + setErrorMessage('Auth provider did not return a redirect URL.'); + setLoading(false); + return; + } + + if (content.redirect) { + const { redirect } = content; + window.location = redirect; + } + } catch (e) { + setErrorMessage(e.message); + } + + setLoading(false); + }; + + return ( + + + {message} + {errorMessageText && ( + + )} +
Your domain
+ 0 ? 'error' : undefined} + onPressEnter={submitButtonPressed} + enterButton={ + + } + /> + + + +

+ IndieAuth allows for a completely independent and decentralized way of identifying + yourself using your own domain. +

+ +

+ If you run an Owncast instance, you can use that domain here. Otherwise,{' '} + + learn more about how you can support IndieAuth + + . +

+
+
+
+ Note: This is for authentication purposes only, and no personal + information will be accessed or stored. +
+
+
+ ); } diff --git a/web/components/stores/ClientConfigStore.tsx b/web/components/stores/ClientConfigStore.tsx index 1b6608a86..db5cb1f7f 100644 --- a/web/components/stores/ClientConfigStore.tsx +++ b/web/components/stores/ClientConfigStore.tsx @@ -71,6 +71,11 @@ export const chatMessagesAtom = atom({ default: [] as ChatMessage[], }); +export const chatAuthenticatedAtom = atom({ + key: 'chatAuthenticatedAtom', + default: false, +}); + export const websocketServiceAtom = atom({ key: 'websocketServiceAtom', default: null, @@ -156,6 +161,7 @@ export function ClientConfigStore() { const setChatDisplayName = useSetRecoilState(chatDisplayNameAtom); const setChatDisplayColor = useSetRecoilState(chatDisplayColorAtom); const setChatUserId = useSetRecoilState(chatUserIdAtom); + const setChatAuthenticated = useSetRecoilState(chatAuthenticatedAtom); const setIsChatModerator = useSetRecoilState(isChatModeratorAtom); const setClientConfig = useSetRecoilState(clientConfigStateAtom); const setServerStatus = useSetRecoilState(serverStatusState); @@ -265,6 +271,7 @@ export function ClientConfigStore() { setChatDisplayColor, setChatUserId, setIsChatModerator, + setChatAuthenticated, ); setChatMessages(currentState => [...currentState, message as ChatEvent]); break; diff --git a/web/components/stores/eventhandlers/connected-client-info-handler.ts b/web/components/stores/eventhandlers/connected-client-info-handler.ts index 6063a5480..63dbea6ed 100644 --- a/web/components/stores/eventhandlers/connected-client-info-handler.ts +++ b/web/components/stores/eventhandlers/connected-client-info-handler.ts @@ -6,11 +6,13 @@ export default function handleConnectedClientInfoMessage( setChatDisplayColor: (number) => void, setChatUserId: (number) => void, setIsChatModerator: (boolean) => void, + setChatAuthenticated: (boolean) => void, ) { const { user } = message; - const { id, displayName, displayColor, scopes } = user; + const { id, displayName, displayColor, scopes, authenticated } = user; setChatDisplayName(displayName); setChatDisplayColor(displayColor); setChatUserId(id); setIsChatModerator(scopes?.includes('moderator')); + setChatAuthenticated(authenticated); } diff --git a/web/components/ui/Modal/Modal.tsx b/web/components/ui/Modal/Modal.tsx index 6149260d7..9428c1a40 100644 --- a/web/components/ui/Modal/Modal.tsx +++ b/web/components/ui/Modal/Modal.tsx @@ -19,7 +19,7 @@ export default function Modal(props: Props) { const modalStyle = { padding: '0px', - height: height || '40vh', + minHeight: height || '40vh', }; const iframe = url && ( diff --git a/web/interfaces/user.model.ts b/web/interfaces/user.model.ts index f1d65e4b6..c96fdd777 100644 --- a/web/interfaces/user.model.ts +++ b/web/interfaces/user.model.ts @@ -6,4 +6,5 @@ export interface User { previousNames: string[]; nameChangedAt: Date; scopes: string[]; + authenticated: boolean; } diff --git a/web/stories/AuthModal.stories.tsx b/web/stories/AuthModal.stories.tsx index 2e4c9290e..f86d35f77 100644 --- a/web/stories/AuthModal.stories.tsx +++ b/web/stories/AuthModal.stories.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { ComponentStory, ComponentMeta } from '@storybook/react'; -import AuthModal from '../components/modals/AuthModal'; +import { RecoilRoot } from 'recoil'; +import AuthModal from '../components/modals/AuthModal/AuthModal'; const Example = () => (
@@ -15,7 +16,11 @@ export default { } as ComponentMeta; // eslint-disable-next-line @typescript-eslint/no-unused-vars -const Template: ComponentStory = args => ; +const Template: ComponentStory = args => ( + + + +); // eslint-disable-next-line @typescript-eslint/no-unused-vars export const Basic = Template.bind({}); diff --git a/web/stories/IndieAuthModal.stories.tsx b/web/stories/IndieAuthModal.stories.tsx index 6b46d12bd..cd22078dc 100644 --- a/web/stories/IndieAuthModal.stories.tsx +++ b/web/stories/IndieAuthModal.stories.tsx @@ -5,7 +5,7 @@ import Mock from './assets/mocks/indieauth-modal.png'; const Example = () => (
- +
);