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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -166,6 +166,7 @@
|
||||
"unsupportedLocal": "Browser notifications are not supported for local servers."
|
||||
},
|
||||
"Chat": {
|
||||
"authenticateToChat": "Authenticate to chat",
|
||||
"moderatorNotification": "You are now a moderator.",
|
||||
"nameChangeText": "is now known as",
|
||||
"userJoined": "joined the chat.",
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface ClientConfig {
|
||||
extraPageContent: string;
|
||||
socialHandles: SocialHandle[];
|
||||
chatDisabled: boolean;
|
||||
chatRequireAuthentication: boolean;
|
||||
externalActions: any[];
|
||||
customStyles: string;
|
||||
appearanceVariables: Map<string, string>;
|
||||
@@ -55,6 +56,7 @@ export function makeEmptyClientConfig(): ClientConfig {
|
||||
extraPageContent: '',
|
||||
socialHandles: [],
|
||||
chatDisabled: false,
|
||||
chatRequireAuthentication: false,
|
||||
externalActions: [],
|
||||
customStyles: '',
|
||||
appearanceVariables: new Map(),
|
||||
|
||||
Generated
+212
-75
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ import {
|
||||
FIELD_PROPS_ENABLE_CHAT_SLUR_FILTER,
|
||||
CHAT_ESTABLISHED_USER_MODE,
|
||||
FIELD_PROPS_DISABLE_CHAT,
|
||||
FIELD_PROPS_CHAT_REQUIRE_AUTHENTICATION,
|
||||
postConfigUpdateToAPI,
|
||||
RESET_TIMEOUT,
|
||||
TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES,
|
||||
@@ -47,6 +48,7 @@ export default function ConfigChat() {
|
||||
chatEstablishedUserMode,
|
||||
chatSpamProtectionEnabled,
|
||||
chatSlurFilterEnabled,
|
||||
chatRequireAuthentication,
|
||||
} = serverConfig;
|
||||
const { welcomeMessage } = instanceDetails;
|
||||
|
||||
@@ -77,6 +79,10 @@ export default function ConfigChat() {
|
||||
handleFieldChange({ fieldName: 'chatSlurFilterEnabled', value: enabled });
|
||||
}
|
||||
|
||||
function handleChatRequireAuthenticationChange(enabled: boolean) {
|
||||
handleFieldChange({ fieldName: 'chatRequireAuthentication', value: enabled });
|
||||
}
|
||||
|
||||
function resetForbiddenUsernameState() {
|
||||
setForbiddenUsernameSaveState(null);
|
||||
}
|
||||
@@ -169,6 +175,7 @@ export default function ConfigChat() {
|
||||
chatEstablishedUserMode,
|
||||
chatSpamProtectionEnabled,
|
||||
chatSlurFilterEnabled,
|
||||
chatRequireAuthentication,
|
||||
});
|
||||
}, [serverConfig]);
|
||||
|
||||
@@ -250,6 +257,12 @@ export default function ConfigChat() {
|
||||
checked={formDataValues.chatSlurFilterEnabled}
|
||||
onChange={handleChatSlurFilterChange}
|
||||
/>
|
||||
<ToggleSwitch
|
||||
fieldName="chatRequireAuthentication"
|
||||
{...FIELD_PROPS_CHAT_REQUIRE_AUTHENTICATION}
|
||||
checked={formDataValues.chatRequireAuthentication}
|
||||
onChange={handleChatRequireAuthenticationChange}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useEffect } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useTranslation } from 'next-export-i18n';
|
||||
import { ChatMessage } from '../../../../interfaces/chat-message.model';
|
||||
import { ChatContainer } from '../../../../components/chat/ChatContainer/ChatContainer';
|
||||
import {
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
appStateAtom,
|
||||
serverStatusState,
|
||||
isChatAvailableSelector,
|
||||
chatAuthenticatedAtom,
|
||||
} from '../../../../components/stores/ClientConfigStore';
|
||||
import Header from '../../../../components/ui/Header/Header';
|
||||
import { ClientConfig } from '../../../../interfaces/client-config.model';
|
||||
@@ -19,8 +21,10 @@ import { AppStateOptions } from '../../../../components/stores/application-state
|
||||
import { ServerStatus } from '../../../../interfaces/server-status.model';
|
||||
import { Theme } from '../../../../components/theme/Theme';
|
||||
import { ComponentError } from '../../../../components/ui/ComponentError/ComponentError';
|
||||
import { Localization } from '../../../../types/localization';
|
||||
|
||||
export default function ReadWriteChatEmbed() {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useRecoilValue(currentUserAtom);
|
||||
const messages = useRecoilValue<ChatMessage[]>(visibleChatMessagesSelector);
|
||||
const clientConfig = useRecoilValue<ClientConfig>(clientConfigStateAtom);
|
||||
@@ -28,8 +32,19 @@ export default function ReadWriteChatEmbed() {
|
||||
|
||||
const appState = useRecoilValue<AppStateOptions>(appStateAtom);
|
||||
const isChatAvailable = useRecoilValue(isChatAvailableSelector);
|
||||
const isUserAuthenticated = useRecoilValue<boolean>(chatAuthenticatedAtom);
|
||||
|
||||
const { name, chatDisabled } = clientConfig;
|
||||
const { name, chatDisabled, chatRequireAuthentication } = clientConfig;
|
||||
|
||||
// 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);
|
||||
const { videoAvailable } = appState;
|
||||
const { streamTitle, online } = clientStatus;
|
||||
|
||||
@@ -73,6 +88,8 @@ export default function ReadWriteChatEmbed() {
|
||||
showInput
|
||||
height="92vh"
|
||||
chatAvailable={isChatAvailable}
|
||||
inputEnabled={chatInputEnabled}
|
||||
inputDisabledPlaceholder={chatInputDisabledMessage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,7 @@ const Page = () => {
|
||||
|
||||
const fakeConfig: ClientConfig = {
|
||||
chatDisabled: false,
|
||||
chatRequireAuthentication: false,
|
||||
name: 'Fake Owncast Server',
|
||||
summary: '',
|
||||
logo: '',
|
||||
|
||||
@@ -154,6 +154,7 @@ export interface ConfigDetails {
|
||||
chatDisabled: boolean;
|
||||
chatSpamProtectionEnabled: boolean;
|
||||
chatSlurFilterEnabled: boolean;
|
||||
chatRequireAuthentication: boolean;
|
||||
federation: Federation;
|
||||
notifications: NotificationsConfig;
|
||||
chatJoinMessagesEnabled: boolean;
|
||||
|
||||
@@ -68,8 +68,7 @@ export const Localization = {
|
||||
deniedTitle: 'Frontend.BrowserNotifyModal.deniedTitle',
|
||||
deniedDescription: 'Frontend.BrowserNotifyModal.deniedDescription',
|
||||
mainDescription: 'Frontend.BrowserNotifyModal.mainDescription',
|
||||
learnMoreAboutNotifications:
|
||||
'Frontend.BrowserNotifyModal.learnMoreAboutNotifications',
|
||||
learnMoreAboutNotifications: 'Frontend.BrowserNotifyModal.learnMoreAboutNotifications',
|
||||
errorTitle: 'Frontend.BrowserNotifyModal.errorTitle',
|
||||
errorMessage: 'Frontend.BrowserNotifyModal.errorMessage',
|
||||
},
|
||||
@@ -107,6 +106,7 @@ export const Localization = {
|
||||
userLeft: 'Frontend.Chat.userLeft',
|
||||
nameChangeText: 'Frontend.Chat.nameChangeText',
|
||||
moderatorNotification: 'Frontend.Chat.moderatorNotification',
|
||||
authenticateToChat: 'Frontend.Chat.authenticateToChat',
|
||||
},
|
||||
|
||||
// Follow modal component
|
||||
|
||||
@@ -40,6 +40,7 @@ const API_CHAT_JOIN_MESSAGES_ENABLED = '/chat/joinmessagesenabled';
|
||||
const API_CHAT_ESTABLISHED_MODE = '/chat/establishedusermode';
|
||||
const API_CHAT_SPAM_PROTECTION_ENABLED = '/chat/spamprotectionenabled';
|
||||
const API_CHAT_SLUR_FILTER_ENABLED = '/chat/slurfilterenabled';
|
||||
const API_CHAT_REQUIRE_AUTHENTICATION = '/chat/requireauthentication';
|
||||
const API_DISABLE_SEARCH_INDEXING = '/disablesearchindexing';
|
||||
const API_SOCKET_HOST_OVERRIDE = '/sockethostoverride';
|
||||
const API_VIDEO_SERVING_ENDPOINT = '/videoservingendpoint';
|
||||
@@ -292,6 +293,14 @@ export const CHAT_ESTABLISHED_USER_MODE = {
|
||||
useSubmit: true,
|
||||
};
|
||||
|
||||
export const FIELD_PROPS_CHAT_REQUIRE_AUTHENTICATION = {
|
||||
apiPath: API_CHAT_REQUIRE_AUTHENTICATION,
|
||||
configPath: '',
|
||||
label: 'Require Authentication',
|
||||
tip: 'Only users who have authenticated may chat.',
|
||||
useSubmit: true,
|
||||
};
|
||||
|
||||
export const TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES = {
|
||||
apiPath: API_CHAT_FORBIDDEN_USERNAMES,
|
||||
placeholder: 'username',
|
||||
|
||||
@@ -71,6 +71,7 @@ const initialServerConfigState: ConfigDetails = {
|
||||
chatDisabled: false,
|
||||
chatSpamProtectionEnabled: true,
|
||||
chatSlurFilterEnabled: false,
|
||||
chatRequireAuthentication: false,
|
||||
chatJoinMessagesEnabled: true,
|
||||
chatEstablishedUserMode: false,
|
||||
hideViewerCount: false,
|
||||
@@ -127,6 +128,7 @@ const ServerStatusProvider: FC<ServerStatusProviderProps> = ({ children }) => {
|
||||
|
||||
setStatus({ ...result, error: { type: null, msg: null } });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch server status:', error);
|
||||
setStatus(initialStatus => ({
|
||||
...initialStatus,
|
||||
error: {
|
||||
@@ -134,7 +136,6 @@ const ServerStatusProvider: FC<ServerStatusProviderProps> = ({ children }) => {
|
||||
msg: 'Cannot connect to the Owncast service. Please check you are connected to the internet and the Owncast server is running.',
|
||||
},
|
||||
}));
|
||||
// todo
|
||||
}
|
||||
};
|
||||
const getConfig = async () => {
|
||||
|
||||
Reference in New Issue
Block a user