Require authentication to participate in chat (#4762)

* feat(chat): require authentication to participate in chat

* fix: it's pretty much impossible to bypass the auth requirement, addressing review feedback anyway

* feat(chat): render chat text input as disabled if chat auth is required

* Commit updated API documentation

---------

Co-authored-by: Owncast <owncast@owncast.online>
This commit is contained in:
Gabe Kangas
2026-01-28 11:49:07 -08:00
committed by GitHub
co-authored by Owncast
parent 83c8b2b3d5
commit 93b482871f
29 changed files with 5194 additions and 5143 deletions
@@ -618,6 +618,22 @@ export const ChatDisabled = {
},
};
export const AuthenticationRequired = {
render: Template,
args: {
loading: false,
messages,
usernameToHighlight: 'testuser',
chatUserId: 'testuser',
isModerator: false,
showInput: true,
chatAvailable: true,
inputEnabled: false,
inputDisabledPlaceholder: 'Authenticate to chat',
},
};
export const SingleMessage = {
render: Template,
@@ -34,6 +34,8 @@ export type ChatContainerProps = {
focusInput?: boolean;
desktop?: boolean;
readonly?: boolean;
inputEnabled?: boolean;
inputDisabledPlaceholder?: string;
};
let resizeWindowCallback: () => void;
@@ -82,11 +84,15 @@ export const ChatContainer: FC<ChatContainerProps> = ({
isModerator,
showInput = true,
height = 'auto',
chatAvailable: chatEnabled,
chatAvailable,
desktop,
focusInput = true,
readonly = false,
inputEnabled,
inputDisabledPlaceholder,
}) => {
// If inputEnabled is explicitly set, use that; otherwise fall back to chatAvailable
const chatInputEnabled = inputEnabled !== undefined ? inputEnabled : chatAvailable;
const [showScrollToBottomButton, setShowScrollToBottomButton] = useState(false);
const [isAtBottom, setIsAtBottom] = useState(false);
@@ -366,7 +372,11 @@ export const ChatContainer: FC<ChatContainerProps> = ({
{MessagesTable}
{showInput && (
<div className={styles.chatTextField}>
<ChatTextField enabled={chatEnabled} focusInput={focusInput} />
<ChatTextField
enabled={chatInputEnabled}
focusInput={focusInput}
disabledPlaceholder={inputDisabledPlaceholder}
/>
</div>
)}
{desktop && (
@@ -30,6 +30,7 @@ export type ChatTextFieldProps = {
defaultText?: string;
enabled: boolean;
focusInput: boolean;
disabledPlaceholder?: string;
};
const characterLimit = 300;
@@ -119,7 +120,12 @@ const getTextContent = node => {
return text;
};
export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, focusInput }) => {
export const ChatTextField: FC<ChatTextFieldProps> = ({
defaultText,
enabled,
focusInput,
disabledPlaceholder,
}) => {
const [inputDraft, setInputDraft] = useRecoilState(chatInputDraftAtom);
const [characterCount, setCharacterCount] = useState(defaultText?.length);
const websocketService = useRecoilValue<WebsocketService>(websocketServiceAtom);
@@ -276,7 +282,9 @@ export const ChatTextField: FC<ChatTextFieldProps> = ({ defaultText, enabled, fo
<ContentEditable
id="chat-input-content-editable"
html={defaultText || ''}
placeholder={enabled ? 'Send a message to chat' : 'Chat is disabled'}
placeholder={
enabled ? 'Send a message to chat' : disabledPlaceholder || 'Chat is disabled'
}
disabled={!enabled}
onKeyDown={onKeyDown}
onContentChange={handleChange}
+11 -1
View File
@@ -14,9 +14,17 @@ export type ChatModalProps = {
messages: ChatMessage[];
currentUser: CurrentUser;
handleClose: () => void;
inputEnabled?: boolean;
inputDisabledPlaceholder?: string;
};
export const ChatModal: FC<ChatModalProps> = ({ messages, currentUser, handleClose }) => {
export const ChatModal: FC<ChatModalProps> = ({
messages,
currentUser,
handleClose,
inputEnabled = true,
inputDisabledPlaceholder,
}) => {
if (!currentUser) {
return null;
}
@@ -58,6 +66,8 @@ export const ChatModal: FC<ChatModalProps> = ({ messages, currentUser, handleClo
chatUserId={id}
isModerator={isModerator}
chatAvailable
inputEnabled={inputEnabled}
inputDisabledPlaceholder={inputDisabledPlaceholder}
focusInput={false}
/>
</Modal>
+20
View File
@@ -4,9 +4,11 @@ import MessageFilled from '@ant-design/icons/MessageFilled';
import { FC, useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import classnames from 'classnames';
import { useTranslation } from 'next-export-i18n';
import ActionButtons from './ActionButtons';
import { LOCAL_STORAGE_KEYS, getLocalStorage, setLocalStorage } from '../../../utils/localStorage';
import { canPushNotificationsBeSupported } from '../../../utils/browserPushNotifications';
import { Localization } from '../../../types/localization';
import {
clientConfigStateAtom,
@@ -19,6 +21,7 @@ import {
serverStatusState,
isChatAvailableSelector,
visibleChatMessagesSelector,
chatAuthenticatedAtom,
} from '../../stores/ClientConfigStore';
import { ClientConfig } from '../../../interfaces/client-config.model';
@@ -97,6 +100,7 @@ const ExternalModal = ({ externalActionToDisplay, setExternalActionToDisplay })
};
export const Content: FC = () => {
const { t } = useTranslation();
const appState = useRecoilValue<AppStateOptions>(appStateAtom);
const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom);
const chatState = useRecoilValue<ChatState>(chatStateAtom);
@@ -106,6 +110,7 @@ export const Content: FC = () => {
const messages = useRecoilValue<ChatMessage[]>(visibleChatMessagesSelector);
const online = useRecoilValue<boolean>(isOnlineSelector);
const isChatAvailable = useRecoilValue<boolean>(isChatAvailableSelector);
const isUserAuthenticated = useRecoilValue<boolean>(chatAuthenticatedAtom);
const { viewerCount, lastConnectTime, lastDisconnectTime, streamTitle } =
useRecoilValue<ServerStatus>(serverStatusState);
@@ -118,6 +123,7 @@ export const Content: FC = () => {
externalActions,
offlineMessage,
chatDisabled,
chatRequireAuthentication,
federation,
notifications,
} = clientConfig;
@@ -219,6 +225,16 @@ export const Content: FC = () => {
const showChat = isChatAvailable && !chatDisabled && chatState === ChatState.VISIBLE;
// Determine if chat input should be enabled based on authentication requirements.
// Moderators bypass the authentication requirement.
const chatInputEnabled = !!(
isChatAvailable &&
(!chatRequireAuthentication || isUserAuthenticated || currentUser?.isModerator)
);
const chatInputDisabledMessage = chatRequireAuthentication
? t(Localization.Frontend.Chat.authenticateToChat)
: t(Localization.Frontend.chatDisabled);
return (
<div className={styles.main}>
<div className={styles.mainColumn}>
@@ -325,6 +341,8 @@ export const Content: FC = () => {
isModerator={currentUser.isModerator}
chatAvailable={isChatAvailable}
showInput={!!currentUser}
inputEnabled={chatInputEnabled}
inputDisabledPlaceholder={chatInputDisabledMessage}
desktop
/>
)}
@@ -351,6 +369,8 @@ export const Content: FC = () => {
messages={messages}
currentUser={currentUser}
handleClose={() => setShowChatModal(false)}
inputEnabled={chatInputEnabled}
inputDisabledPlaceholder={chatInputDisabledMessage}
/>
)}
{isMobile && isChatAvailable && !chatDisabled && (