mirror of
				https://github.com/owncast/owncast.git
				synced 2025-10-31 18:18:06 +08:00 
			
		
		
		
	Admin support for managing users (#245)
* First pass at displaying user data in admin * Hide chat blurb on home page if chat is disabled * Hide sidebar chat section if chat is disabled * Block/unblock user interface for https://github.com/owncast/owncast/issues/1096 * Simplify past display name handling * Updates to reflect the api access token change * Update paths * Clean up the new access token page * Fix linter * Update linter workflow action * Cleanup * Fix exception rendering table row * Commit next-env file that seems to be required with next 11 * chat refactor - admin adjustments (#250) * add useragent parser; clean up some html; * some ui changes - use modal instead of popover to confirm block/unblock user - update styles, table styles for consistency - rename some user/chat labels in nav and content * format user info modal a bit * add some sort of mild treatment and delay while processing ban of users * rename button to 'ban' * add some notes * Prettified Code! * fix disableChat toggle for nav bar * Support sorting the disabled user list * Fix linter error around table sorting * No longer restoring messages on unban so change message prompt * Standardize on forbiddenUsername terminology * The linter broke the webhooks page. Fixed it. Linter is probably pissed. * Move chat welcome message to chat config * Other submenus don't have icons so remove these ones Co-authored-by: gingervitis <omqmail@gmail.com> Co-authored-by: gabek <gabek@users.noreply.github.com>
This commit is contained in:
		
							
								
								
									
										2
									
								
								web/.github/workflows/linter.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								web/.github/workflows/linter.yml
									
									
									
									
										vendored
									
									
								
							| @ -25,7 +25,7 @@ jobs: | ||||
|         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
|          | ||||
|     - name: Linter | ||||
|       uses: tj-actions/eslint-changed-files@v4 | ||||
|       uses: tj-actions/eslint-changed-files@v6.5 | ||||
|       with: | ||||
|         config-path: '.eslintrc.js' | ||||
|         ignore-path: '.eslintignore' | ||||
|  | ||||
							
								
								
									
										85
									
								
								web/components/ban-user-button.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								web/components/ban-user-button.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,85 @@ | ||||
| import { Modal, Button } from 'antd'; | ||||
| import { ExclamationCircleFilled, QuestionCircleFilled, StopTwoTone } from '@ant-design/icons'; | ||||
| import { USER_ENABLED_TOGGLE, fetchData } from '../utils/apis'; | ||||
| import { User } from '../types/chat'; | ||||
|  | ||||
| interface BanUserButtonProps { | ||||
|   user: User; | ||||
|   isEnabled: Boolean; // = this user's current status | ||||
|   label?: string; | ||||
|   onClick?: () => void; | ||||
| } | ||||
| export default function BanUserButton({ user, isEnabled, label, onClick }: BanUserButtonProps) { | ||||
|   async function buttonClicked({ id }): Promise<Boolean> { | ||||
|     const data = { | ||||
|       userId: id, | ||||
|       enabled: !isEnabled, // set user to this value | ||||
|     }; | ||||
|     try { | ||||
|       const result = await fetchData(USER_ENABLED_TOGGLE, { | ||||
|         data, | ||||
|         method: 'POST', | ||||
|         auth: true, | ||||
|       }); | ||||
|       return result.success; | ||||
|     } catch (e) { | ||||
|       // eslint-disable-next-line no-console | ||||
|       console.error(e); | ||||
|     } | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   const actionString = isEnabled ? 'ban' : 'unban'; | ||||
|   const icon = isEnabled ? ( | ||||
|     <ExclamationCircleFilled style={{ color: 'var(--ant-error)' }} /> | ||||
|   ) : ( | ||||
|     <QuestionCircleFilled style={{ color: 'var(--ant-warning)' }} /> | ||||
|   ); | ||||
|  | ||||
|   const content = ( | ||||
|     <> | ||||
|       Are you sure you want to {actionString} <strong>{user.displayName}</strong> | ||||
|       {isEnabled ? ' and remove their messages?' : '?'} | ||||
|     </> | ||||
|   ); | ||||
|  | ||||
|   const confirmBlockAction = () => { | ||||
|     Modal.confirm({ | ||||
|       title: `Confirm ${actionString}`, | ||||
|       content, | ||||
|       onCancel: () => {}, | ||||
|       onOk: () => | ||||
|         new Promise((resolve, reject) => { | ||||
|           const result = buttonClicked(user); | ||||
|           if (result) { | ||||
|             // wait a bit before closing so the user/client tables repopulate | ||||
|             // GW: TODO: put users/clients data in global app context instead, then call a function here to update that state. (current in another branch) | ||||
|             setTimeout(() => { | ||||
|               resolve(result); | ||||
|               onClick?.(); | ||||
|             }, 3000); | ||||
|           } else { | ||||
|             reject(); | ||||
|           } | ||||
|         }), | ||||
|       okType: 'danger', | ||||
|       okText: isEnabled ? 'Absolutely' : null, | ||||
|       icon, | ||||
|     }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <Button | ||||
|       onClick={confirmBlockAction} | ||||
|       size="small" | ||||
|       icon={isEnabled ? <StopTwoTone twoToneColor="#ff4d4f" /> : null} | ||||
|       className="block-user-button" | ||||
|     > | ||||
|       {label || actionString} | ||||
|     </Button> | ||||
|   ); | ||||
| } | ||||
| BanUserButton.defaultProps = { | ||||
|   label: '', | ||||
|   onClick: null, | ||||
| }; | ||||
							
								
								
									
										80
									
								
								web/components/client-table.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								web/components/client-table.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,80 @@ | ||||
| import { Table } from 'antd'; | ||||
| import { SortOrder } from 'antd/lib/table/interface'; | ||||
| import { ColumnsType } from 'antd/es/table'; | ||||
| import { formatDistanceToNow } from 'date-fns'; | ||||
| import { Client } from '../types/chat'; | ||||
| import UserPopover from './user-popover'; | ||||
| import BanUserButton from './ban-user-button'; | ||||
| import { formatUAstring } from '../utils/format'; | ||||
|  | ||||
| export default function ClientTable({ data }: ClientTableProps) { | ||||
|   const columns: ColumnsType<Client> = [ | ||||
|     { | ||||
|       title: 'Display Name', | ||||
|       key: 'username', | ||||
|       // eslint-disable-next-line react/destructuring-assignment | ||||
|       render: (client: Client) => { | ||||
|         const { user, connectedAt, messageCount, userAgent } = client; | ||||
|         const connectionInfo = { connectedAt, messageCount, userAgent }; | ||||
|         return ( | ||||
|           <UserPopover user={user} connectionInfo={connectionInfo}> | ||||
|             <span className="display-name">{user.displayName}</span> | ||||
|           </UserPopover> | ||||
|         ); | ||||
|       }, | ||||
|       sorter: (a: any, b: any) => a.user.displayName - b.user.displayName, | ||||
|       sortDirections: ['descend', 'ascend'] as SortOrder[], | ||||
|     }, | ||||
|     { | ||||
|       title: 'Messages sent', | ||||
|       dataIndex: 'messageCount', | ||||
|       key: 'messageCount', | ||||
|       className: 'number-col', | ||||
|       sorter: (a: any, b: any) => a.messageCount - b.messageCount, | ||||
|       sortDirections: ['descend', 'ascend'] as SortOrder[], | ||||
|     }, | ||||
|     { | ||||
|       title: 'Connected Time', | ||||
|       dataIndex: 'connectedAt', | ||||
|       key: 'connectedAt', | ||||
|       defaultSortOrder: 'ascend', | ||||
|       render: (time: Date) => formatDistanceToNow(new Date(time)), | ||||
|       sorter: (a: any, b: any) => | ||||
|         new Date(a.connectedAt).getTime() - new Date(b.connectedAt).getTime(), | ||||
|       sortDirections: ['descend', 'ascend'] as SortOrder[], | ||||
|     }, | ||||
|     { | ||||
|       title: 'User Agent', | ||||
|       dataIndex: 'userAgent', | ||||
|       key: 'userAgent', | ||||
|       render: (ua: string) => formatUAstring(ua), | ||||
|     }, | ||||
|     { | ||||
|       title: 'Location', | ||||
|       dataIndex: 'geo', | ||||
|       key: 'geo', | ||||
|       render: geo => (geo ? `${geo.regionName}, ${geo.countryCode}` : '-'), | ||||
|     }, | ||||
|     { | ||||
|       title: '', | ||||
|       key: 'block', | ||||
|       className: 'actions-col', | ||||
|       render: (_, row) => <BanUserButton user={row.user} isEnabled={!row.user.disabledAt} />, | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <Table | ||||
|       className="table-container" | ||||
|       pagination={{ hideOnSinglePage: true }} | ||||
|       columns={columns} | ||||
|       dataSource={data} | ||||
|       size="small" | ||||
|       rowKey="id" | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| interface ClientTableProps { | ||||
|   data: Client[]; | ||||
| } | ||||
| @ -12,7 +12,6 @@ import { | ||||
|   TEXTFIELD_PROPS_INSTANCE_URL, | ||||
|   TEXTFIELD_PROPS_SERVER_NAME, | ||||
|   TEXTFIELD_PROPS_SERVER_SUMMARY, | ||||
|   TEXTFIELD_PROPS_SERVER_WELCOME_MESSAGE, | ||||
|   API_YP_SWITCH, | ||||
|   FIELD_PROPS_YP, | ||||
|   FIELD_PROPS_NSFW, | ||||
| @ -97,14 +96,6 @@ export default function EditInstanceDetails() { | ||||
|         initialValue={instanceDetails.summary} | ||||
|         onChange={handleFieldChange} | ||||
|       /> | ||||
|       <TextFieldWithSubmit | ||||
|         fieldName="welcomeMessage" | ||||
|         {...TEXTFIELD_PROPS_SERVER_WELCOME_MESSAGE} | ||||
|         type={TEXTFIELD_TYPE_TEXTAREA} | ||||
|         value={formDataValues.welcomeMessage} | ||||
|         initialValue={instanceDetails.welcomeMessage} | ||||
|         onChange={handleFieldChange} | ||||
|       /> | ||||
|  | ||||
|       {/* Logo section */} | ||||
|       <EditLogo /> | ||||
|  | ||||
| @ -16,7 +16,6 @@ import { | ||||
|   QuestionCircleOutlined, | ||||
|   MessageOutlined, | ||||
|   ExperimentOutlined, | ||||
|   UserOutlined, | ||||
| } from '@ant-design/icons'; | ||||
| import classNames from 'classnames'; | ||||
| import { upgradeVersionAvailable } from '../utils/apis'; | ||||
| @ -36,7 +35,7 @@ export default function MainLayout(props) { | ||||
|  | ||||
|   const context = useContext(ServerStatusContext); | ||||
|   const { serverConfig, online, broadcaster, versionNumber } = context || {}; | ||||
|   const { instanceDetails } = serverConfig; | ||||
|   const { instanceDetails, chatDisabled } = serverConfig; | ||||
|  | ||||
|   const [currentStreamTitle, setCurrentStreamTitle] = useState(''); | ||||
|  | ||||
| @ -78,8 +77,7 @@ export default function MainLayout(props) { | ||||
|   const upgradeMenuItemStyle = upgradeVersion ? 'block' : 'none'; | ||||
|   const upgradeVersionString = `${upgradeVersion}` || ''; | ||||
|   const upgradeMessage = `Upgrade to v${upgradeVersionString}`; | ||||
|  | ||||
|   const chatMenuItemStyle = 'block'; // upgradeVersion ? 'block' : 'none'; | ||||
|   const chatMenuItemStyle = chatDisabled ? 'none' : 'block'; | ||||
|  | ||||
|   const clearAlertMessage = () => { | ||||
|     alertMessage.setMessage(null); | ||||
| @ -129,7 +127,7 @@ export default function MainLayout(props) { | ||||
|       <Sider width={240} className="side-nav"> | ||||
|         <Menu | ||||
|           defaultSelectedKeys={[route.substring(1) || 'home']} | ||||
|           defaultOpenKeys={['current-stream-menu', 'utilities-menu', 'configuration']} | ||||
|           defaultOpenKeys={[]} | ||||
|           mode="inline" | ||||
|           className="menu-container" | ||||
|         > | ||||
| @ -149,15 +147,15 @@ export default function MainLayout(props) { | ||||
|  | ||||
|           <SubMenu | ||||
|             key="chat-config" | ||||
|             title="Chat" | ||||
|             title="Chat & Users" | ||||
|             icon={<MessageOutlined />} | ||||
|             style={{ display: chatMenuItemStyle }} | ||||
|           > | ||||
|             <Menu.Item key="messages" icon={<MessageOutlined />} title="Chat utilities"> | ||||
|             <Menu.Item key="messages" title="Chat utilities"> | ||||
|               <Link href="/chat/messages">Messages</Link> | ||||
|             </Menu.Item> | ||||
|  | ||||
|             <Menu.Item key="chat-users" icon={<UserOutlined />} title="Chat utilities"> | ||||
|             <Menu.Item key="chat-users" title="Chat utilities"> | ||||
|               <Link href="/chat/users">Users</Link> | ||||
|             </Menu.Item> | ||||
|           </SubMenu> | ||||
|  | ||||
							
								
								
									
										146
									
								
								web/components/user-popover.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										146
									
								
								web/components/user-popover.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,146 @@ | ||||
| // This displays a clickable user name (or whatever children element you provide), and displays a simple tooltip of created time. OnClick a modal with more information about the user is displayed. | ||||
|  | ||||
| import { useState } from 'react'; | ||||
| import { Divider, Modal, Tooltip, Typography, Row, Col } from 'antd'; | ||||
| import formatDistanceToNow from 'date-fns/formatDistanceToNow'; | ||||
| import format from 'date-fns/format'; | ||||
| import { ReactNode } from 'react-markdown'; | ||||
| import BlockUserbutton from './ban-user-button'; | ||||
|  | ||||
| import { User, UserConnectionInfo } from '../types/chat'; | ||||
| import { formatDisplayDate } from './user-table'; | ||||
| import { formatUAstring } from '../utils/format'; | ||||
|  | ||||
| interface UserPopoverProps { | ||||
|   user: User; | ||||
|   connectionInfo?: UserConnectionInfo | null; | ||||
|   children: ReactNode; | ||||
| } | ||||
|  | ||||
| export default function UserPopover({ user, connectionInfo, children }: UserPopoverProps) { | ||||
|   const [isModalVisible, setIsModalVisible] = useState(false); | ||||
|   const handleShowModal = () => { | ||||
|     setIsModalVisible(true); | ||||
|   }; | ||||
|   const handleCloseModal = () => { | ||||
|     setIsModalVisible(false); | ||||
|   }; | ||||
|  | ||||
|   const { displayName, createdAt, previousNames, nameChangedAt, disabledAt } = user; | ||||
|   const { connectedAt, messageCount, userAgent } = connectionInfo || {}; | ||||
|  | ||||
|   let lastNameChangeDate = null; | ||||
|   const nameList = previousNames && [...previousNames]; | ||||
|  | ||||
|   if (previousNames && previousNames.length > 1 && nameChangedAt) { | ||||
|     lastNameChangeDate = new Date(nameChangedAt); | ||||
|     // reverse prev names for display purposes | ||||
|     nameList.reverse(); | ||||
|   } | ||||
|  | ||||
|   const dateObject = new Date(createdAt); | ||||
|   const createdAtDate = format(dateObject, 'PP pp'); | ||||
|  | ||||
|   const lastNameChangeDuration = lastNameChangeDate | ||||
|     ? formatDistanceToNow(lastNameChangeDate) | ||||
|     : null; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Tooltip | ||||
|         title={ | ||||
|           <> | ||||
|             Created at: {createdAtDate}. | ||||
|             <br /> Click for more info. | ||||
|           </> | ||||
|         } | ||||
|         placement="bottomLeft" | ||||
|       > | ||||
|         <button | ||||
|           type="button" | ||||
|           aria-label="Display more details about this user" | ||||
|           className="user-item-container" | ||||
|           onClick={handleShowModal} | ||||
|         > | ||||
|           {children} | ||||
|         </button> | ||||
|       </Tooltip> | ||||
|  | ||||
|       <Modal | ||||
|         destroyOnClose | ||||
|         width={600} | ||||
|         cancelText="Close" | ||||
|         okButtonProps={{ style: { display: 'none' } }} | ||||
|         title={`User details: ${displayName}`} | ||||
|         visible={isModalVisible} | ||||
|         onOk={handleCloseModal} | ||||
|         onCancel={handleCloseModal} | ||||
|       > | ||||
|         <div className="user-details"> | ||||
|           <Typography.Title level={4}>{displayName}</Typography.Title> | ||||
|           <p className="created-at">User created at {createdAtDate}.</p> | ||||
|           <Row gutter={16}> | ||||
|             {connectionInfo && ( | ||||
|               <Col md={lastNameChangeDate ? 12 : 24}> | ||||
|                 <Typography.Title level={5}> | ||||
|                   This user is currently connected to Chat. | ||||
|                 </Typography.Title> | ||||
|                 <ul className="connection-info"> | ||||
|                   <li> | ||||
|                     <strong>Active for:</strong> {formatDistanceToNow(new Date(connectedAt))} | ||||
|                   </li> | ||||
|                   <li> | ||||
|                     <strong>Messages sent:</strong> {messageCount} | ||||
|                   </li> | ||||
|                   <li> | ||||
|                     <strong>User Agent:</strong> | ||||
|                     <br /> | ||||
|                     {formatUAstring(userAgent)} | ||||
|                   </li> | ||||
|                 </ul> | ||||
|               </Col> | ||||
|             )} | ||||
|             {lastNameChangeDate && ( | ||||
|               <Col md={connectionInfo ? 12 : 24}> | ||||
|                 <Typography.Title level={5}>This user is also seen as:</Typography.Title> | ||||
|                 <ul className="previous-names-list"> | ||||
|                   {nameList.map((name, index) => ( | ||||
|                     <li className={index === 0 ? 'latest' : ''}> | ||||
|                       <span className="user-name-item">{name}</span> | ||||
|                       {index === 0 ? ` (Changed ${lastNameChangeDuration} ago)` : ''} | ||||
|                     </li> | ||||
|                   ))} | ||||
|                 </ul> | ||||
|               </Col> | ||||
|             )} | ||||
|           </Row> | ||||
|           <Divider /> | ||||
|           {disabledAt ? ( | ||||
|             <> | ||||
|               This user was banned on <code>{formatDisplayDate(disabledAt)}</code>. | ||||
|               <br /> | ||||
|               <br /> | ||||
|               <BlockUserbutton | ||||
|                 label="Unban this user" | ||||
|                 user={user} | ||||
|                 isEnabled={false} | ||||
|                 onClick={handleCloseModal} | ||||
|               /> | ||||
|             </> | ||||
|           ) : ( | ||||
|             <BlockUserbutton | ||||
|               label="Ban this user" | ||||
|               user={user} | ||||
|               isEnabled | ||||
|               onClick={handleCloseModal} | ||||
|             /> | ||||
|           )} | ||||
|         </div> | ||||
|       </Modal> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| UserPopover.defaultProps = { | ||||
|   connectionInfo: null, | ||||
| }; | ||||
							
								
								
									
										64
									
								
								web/components/user-table.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								web/components/user-table.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,64 @@ | ||||
| import { Table } from 'antd'; | ||||
| import format from 'date-fns/format'; | ||||
| import { SortOrder } from 'antd/lib/table/interface'; | ||||
| import { User } from '../types/chat'; | ||||
| import UserPopover from './user-popover'; | ||||
| import BanUserButton from './ban-user-button'; | ||||
|  | ||||
| export function formatDisplayDate(date: string | Date) { | ||||
|   return format(new Date(date), 'MMM d H:mma'); | ||||
| } | ||||
| export default function UserTable({ data }: UserTableProps) { | ||||
|   const columns = [ | ||||
|     { | ||||
|       title: 'Last Known Display Name', | ||||
|       dataIndex: 'displayName', | ||||
|       key: 'displayName', | ||||
|       // eslint-disable-next-line react/destructuring-assignment | ||||
|       render: (displayName: string, user: User) => ( | ||||
|         <UserPopover user={user}> | ||||
|           <span className="display-name">{displayName}</span> | ||||
|         </UserPopover> | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|       title: 'Created', | ||||
|       dataIndex: 'createdAt', | ||||
|       key: 'createdAt', | ||||
|       render: (date: Date) => formatDisplayDate(date), | ||||
|       sorter: (a: any, b: any) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), | ||||
|       sortDirections: ['descend', 'ascend'] as SortOrder[], | ||||
|     }, | ||||
|     { | ||||
|       title: 'Disabled at', | ||||
|       dataIndex: 'disabledAt', | ||||
|       key: 'disabledAt', | ||||
|       defaultSortOrder: 'descend' as SortOrder, | ||||
|       render: (date: Date) => (date ? formatDisplayDate(date) : null), | ||||
|       sorter: (a: any, b: any) => | ||||
|         new Date(a.disabledAt).getTime() - new Date(b.disabledAt).getTime(), | ||||
|       sortDirections: ['descend', 'ascend'] as SortOrder[], | ||||
|     }, | ||||
|     { | ||||
|       title: '', | ||||
|       key: 'block', | ||||
|       className: 'actions-col', | ||||
|       render: (_, user) => <BanUserButton user={user} isEnabled={!user.disabledAt} />, | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <Table | ||||
|       pagination={{ hideOnSinglePage: true }} | ||||
|       className="table-container" | ||||
|       columns={columns} | ||||
|       dataSource={data} | ||||
|       size="small" | ||||
|       rowKey="id" | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| interface UserTableProps { | ||||
|   data: User[]; | ||||
| } | ||||
							
								
								
									
										1
									
								
								web/next-env.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								web/next-env.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -1,2 +1,3 @@ | ||||
| /// <reference types="next" /> | ||||
| /// <reference types="next/types/global" /> | ||||
| /// <reference types="next/image-types/global" /> | ||||
|  | ||||
							
								
								
									
										7273
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										7273
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -25,7 +25,8 @@ | ||||
|     "react-linkify": "^1.0.0-alpha", | ||||
|     "react-markdown": "^6.0.2", | ||||
|     "react-markdown-editor-lite": "^1.3.0", | ||||
|     "sass": "^1.35.2" | ||||
|     "sass": "^1.35.2", | ||||
|     "ua-parser-js": "^0.7.28" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@types/chart.js": "^2.9.32", | ||||
| @ -35,6 +36,7 @@ | ||||
|     "@types/prop-types": "^15.7.3", | ||||
|     "@types/react": "^17.0.11", | ||||
|     "@types/react-linkify": "^1.0.0", | ||||
|     "@types/ua-parser-js": "^0.7.36", | ||||
|     "@typescript-eslint/eslint-plugin": "^4.28.0", | ||||
|     "@typescript-eslint/parser": "^4.28.0", | ||||
|     "eslint": "^7.31.0", | ||||
|  | ||||
| @ -23,17 +23,17 @@ const { Title, Paragraph } = Typography; | ||||
| const availableScopes = { | ||||
|   CAN_SEND_SYSTEM_MESSAGES: { | ||||
|     name: 'System messages', | ||||
|     description: 'You can send official messages on behalf of the system', | ||||
|     description: 'Can send official messages on behalf of the system.', | ||||
|     color: 'purple', | ||||
|   }, | ||||
|   CAN_SEND_MESSAGES: { | ||||
|     name: 'User chat messages', | ||||
|     description: 'You can send messages on behalf of a username', | ||||
|     description: 'Can send chat messages on behalf of the owner of this token.', | ||||
|     color: 'green', | ||||
|   }, | ||||
|   HAS_ADMIN_ACCESS: { | ||||
|     name: 'Has admin access', | ||||
|     description: 'Can perform administrative actions such as moderation, get server statuses, etc', | ||||
|     description: 'Can perform administrative actions such as moderation, get server statuses, etc.', | ||||
|     color: 'red', | ||||
|   }, | ||||
| }; | ||||
| @ -101,9 +101,12 @@ function NewTokenModal(props: Props) { | ||||
|       okButtonProps={okButtonProps} | ||||
|     > | ||||
|       <p> | ||||
|         <p> | ||||
|           The name will be displayed as the chat user when sending messages with this access token. | ||||
|         </p> | ||||
|         <Input | ||||
|           value={name} | ||||
|           placeholder="Access token name/description" | ||||
|           placeholder="Name of bot, service, or integration" | ||||
|           onChange={input => setName(input.currentTarget.value)} | ||||
|         /> | ||||
|       </p> | ||||
| @ -131,7 +134,6 @@ export default function AccessTokens() { | ||||
|  | ||||
|   function handleError(error) { | ||||
|     console.error('error', error); | ||||
|     alert(error); | ||||
|   } | ||||
|  | ||||
|   async function getAccessTokens() { | ||||
| @ -176,26 +178,27 @@ export default function AccessTokens() { | ||||
|       key: 'delete', | ||||
|       render: (text, record) => ( | ||||
|         <Space size="middle"> | ||||
|           <Button onClick={() => handleDeleteToken(record.token)} icon={<DeleteOutlined />} /> | ||||
|           <Button onClick={() => handleDeleteToken(record.accessToken)} icon={<DeleteOutlined />} /> | ||||
|         </Space> | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|       title: 'Name', | ||||
|       dataIndex: 'name', | ||||
|       key: 'name', | ||||
|       dataIndex: 'displayName', | ||||
|       key: 'displayName', | ||||
|     }, | ||||
|     { | ||||
|       title: 'Token', | ||||
|       dataIndex: 'token', | ||||
|       key: 'token', | ||||
|       dataIndex: 'accessToken', | ||||
|       key: 'accessToken', | ||||
|       render: text => <Input.Password size="small" bordered={false} value={text} />, | ||||
|     }, | ||||
|     { | ||||
|       title: 'Scopes', | ||||
|       dataIndex: 'scopes', | ||||
|       key: 'scopes', | ||||
|       render: ({ map }: string[]) => <>{map(scope => convertScopeStringToTag(scope))}</>, | ||||
|       // eslint-disable-next-line react/destructuring-assignment | ||||
|       render: scopes => <>{scopes.map(scope => convertScopeStringToTag(scope))}</>, | ||||
|     }, | ||||
|     { | ||||
|       title: 'Last Used', | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import React, { useState, useEffect } from 'react'; | ||||
| import { Table, Typography, Tooltip, Button } from 'antd'; | ||||
| import { Table, Typography, Button } from 'antd'; | ||||
| import { CheckCircleFilled, ExclamationCircleFilled } from '@ant-design/icons'; | ||||
| import classNames from 'classnames'; | ||||
| import { ColumnsType } from 'antd/es/table'; | ||||
| @ -9,12 +9,13 @@ import { CHAT_HISTORY, fetchData, FETCH_INTERVAL, UPDATE_CHAT_MESSGAE_VIZ } from | ||||
| import { MessageType } from '../../types/chat'; | ||||
| import { isEmptyObject } from '../../utils/format'; | ||||
| import MessageVisiblityToggle from '../../components/message-visiblity-toggle'; | ||||
| import UserPopover from '../../components/user-popover'; | ||||
|  | ||||
| const { Title } = Typography; | ||||
|  | ||||
| function createUserNameFilters(messages: MessageType[]) { | ||||
|   const filtered = messages.reduce((acc, curItem) => { | ||||
|     const curAuthor = curItem.author; | ||||
|     const curAuthor = curItem.user.id; | ||||
|     if (!acc.some(item => item.text === curAuthor)) { | ||||
|       acc.push({ text: curAuthor, value: curAuthor }); | ||||
|     } | ||||
| @ -149,19 +150,18 @@ export default function Chat() { | ||||
|     }, | ||||
|     { | ||||
|       title: 'User', | ||||
|       dataIndex: 'author', | ||||
|       key: 'author', | ||||
|       dataIndex: 'user', | ||||
|       key: 'user', | ||||
|       className: 'name-col', | ||||
|       filters: nameFilters, | ||||
|       onFilter: (value, record) => record.author === value, | ||||
|       sorter: (a, b) => a.author.localeCompare(b.author), | ||||
|       onFilter: (value, record) => record.user.id === value, | ||||
|       sorter: (a, b) => a.user.displayName.localeCompare(b.user.displayName), | ||||
|       sortDirections: ['ascend', 'descend'], | ||||
|       ellipsis: true, | ||||
|       render: author => ( | ||||
|         <Tooltip placement="topLeft" title={author}> | ||||
|           {author} | ||||
|         </Tooltip> | ||||
|       ), | ||||
|       render: user => { | ||||
|         const { displayName } = user; | ||||
|         return <UserPopover user={user}>{displayName}</UserPopover>; | ||||
|       }, | ||||
|       width: 110, | ||||
|     }, | ||||
|     { | ||||
| @ -180,16 +180,16 @@ export default function Chat() { | ||||
|     }, | ||||
|     { | ||||
|       title: '', | ||||
|       dataIndex: 'visible', | ||||
|       key: 'visible', | ||||
|       dataIndex: 'hiddenAt', | ||||
|       key: 'hiddenAt', | ||||
|       className: 'toggle-col', | ||||
|       filters: [ | ||||
|         { text: 'Visible messages', value: true }, | ||||
|         { text: 'Hidden messages', value: false }, | ||||
|       ], | ||||
|       onFilter: (value, record) => record.visible === value, | ||||
|       render: (visible, record) => ( | ||||
|         <MessageVisiblityToggle isVisible={visible} message={record} setMessage={updateMessage} /> | ||||
|       render: (hiddenAt, record) => ( | ||||
|         <MessageVisiblityToggle isVisible={!hiddenAt} message={record} setMessage={updateMessage} /> | ||||
|       ), | ||||
|       width: 30, | ||||
|     }, | ||||
| @ -234,10 +234,10 @@ export default function Chat() { | ||||
|       </div> | ||||
|       <Table | ||||
|         size="small" | ||||
|         className="messages-table" | ||||
|         className="table-container" | ||||
|         pagination={{ pageSize: 100 }} | ||||
|         scroll={{ y: 540 }} | ||||
|         rowClassName={record => (!record.visible ? 'hidden' : '')} | ||||
|         rowClassName={record => (record.hiddenAt ? 'hidden' : '')} | ||||
|         dataSource={messages} | ||||
|         columns={chatColumns} | ||||
|         rowKey={row => row.id} | ||||
|  | ||||
| @ -1,25 +1,25 @@ | ||||
| import React, { useState, useEffect, useContext } from 'react'; | ||||
| import { Table, Typography } from 'antd'; | ||||
| import { formatDistanceToNow } from 'date-fns'; | ||||
| import { SortOrder } from 'antd/lib/table/interface'; | ||||
|  | ||||
| import { Typography } from 'antd'; | ||||
| import { ServerStatusContext } from '../../utils/server-status-context'; | ||||
| import { CONNECTED_CLIENTS, fetchData, DISABLED_USERS } from '../../utils/apis'; | ||||
| import UserTable from '../../components/user-table'; | ||||
| import ClientTable from '../../components/client-table'; | ||||
|  | ||||
| import { CONNECTED_CLIENTS, VIEWERS_OVER_TIME, fetchData } from '../../utils/apis'; | ||||
| const { Title } = Typography; | ||||
|  | ||||
| const FETCH_INTERVAL = 60 * 1000; // 1 min | ||||
| export const FETCH_INTERVAL = 10 * 1000; // 10 sec | ||||
|  | ||||
| export default function ChatUsers() { | ||||
|   const context = useContext(ServerStatusContext); | ||||
|   const { online } = context || {}; | ||||
|  | ||||
|   const [viewerInfo, setViewerInfo] = useState([]); | ||||
|   const [disabledUsers, setDisabledUsers] = useState([]); | ||||
|   const [clients, setClients] = useState([]); | ||||
|  | ||||
|   const getInfo = async () => { | ||||
|     try { | ||||
|       const result = await fetchData(VIEWERS_OVER_TIME); | ||||
|       setViewerInfo(result); | ||||
|       const result = await fetchData(DISABLED_USERS); | ||||
|       setDisabledUsers(result); | ||||
|     } catch (error) { | ||||
|       console.log('==== error', error); | ||||
|     } | ||||
| @ -36,79 +36,42 @@ export default function ChatUsers() { | ||||
|     let getStatusIntervalId = null; | ||||
|  | ||||
|     getInfo(); | ||||
|     if (online) { | ||||
|       getStatusIntervalId = setInterval(getInfo, FETCH_INTERVAL); | ||||
|       // returned function will be called on component unmount | ||||
|       return () => { | ||||
|         clearInterval(getStatusIntervalId); | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|     return () => []; | ||||
|     getStatusIntervalId = setInterval(getInfo, FETCH_INTERVAL); | ||||
|     // returned function will be called on component unmount | ||||
|     return () => { | ||||
|       clearInterval(getStatusIntervalId); | ||||
|     }; | ||||
|   }, [online]); | ||||
|  | ||||
|   // todo - check to see if broadcast active has changed. if so, start polling. | ||||
|  | ||||
|   if (!viewerInfo.length) { | ||||
|     return 'no info'; | ||||
|   } | ||||
|  | ||||
|   const columns = [ | ||||
|     { | ||||
|       title: 'User name', | ||||
|       dataIndex: 'username', | ||||
|       key: 'username', | ||||
|       render: username => username || '-', | ||||
|       sorter: (a, b) => a.username - b.username, | ||||
|       sortDirections: ['descend', 'ascend'] as SortOrder[], | ||||
|     }, | ||||
|     { | ||||
|       title: 'Messages sent', | ||||
|       dataIndex: 'messageCount', | ||||
|       key: 'messageCount', | ||||
|       sorter: (a, b) => a.messageCount - b.messageCount, | ||||
|       sortDirections: ['descend', 'ascend'] as SortOrder[], | ||||
|     }, | ||||
|     { | ||||
|       title: 'Connected Time', | ||||
|       dataIndex: 'connectedAt', | ||||
|       key: 'connectedAt', | ||||
|       render: time => formatDistanceToNow(new Date(time)), | ||||
|       sorter: (a, b) => new Date(a.connectedAt).getTime() - new Date(b.connectedAt).getTime(), | ||||
|       sortDirections: ['descend', 'ascend'] as SortOrder[], | ||||
|     }, | ||||
|     { | ||||
|       title: 'User Agent', | ||||
|       dataIndex: 'userAgent', | ||||
|       key: 'userAgent', | ||||
|     }, | ||||
|     { | ||||
|       title: 'Location', | ||||
|       dataIndex: 'geo', | ||||
|       key: 'geo', | ||||
|       render: geo => (geo ? `${geo.regionName}, ${geo.countryCode}` : '-'), | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   const connectedUsers = online ? ( | ||||
|     <> | ||||
|       <ClientTable data={clients} /> | ||||
|       <p className="description"> | ||||
|         Visit the{' '} | ||||
|         <a | ||||
|           href="https://owncast.online/docs/viewers/?source=admin" | ||||
|           target="_blank" | ||||
|           rel="noopener noreferrer" | ||||
|         > | ||||
|           documentation | ||||
|         </a>{' '} | ||||
|         to configure additional details about your viewers. | ||||
|       </p> | ||||
|     </> | ||||
|   ) : ( | ||||
|     <p className="description"> | ||||
|       When a stream is active and chat is enabled, connected chat clients will be displayed here. | ||||
|     </p> | ||||
|   ); | ||||
|   return ( | ||||
|     <> | ||||
|       <div> | ||||
|         <Typography.Title>Connected</Typography.Title> | ||||
|         <Table dataSource={clients} columns={columns} rowKey={row => row.clientID} /> | ||||
|         <p> | ||||
|           <Typography.Text type="secondary"> | ||||
|             Visit the{' '} | ||||
|             <a | ||||
|               href="https://owncast.online/docs/viewers/?source=admin" | ||||
|               target="_blank" | ||||
|               rel="noopener noreferrer" | ||||
|             > | ||||
|               documentation | ||||
|             </a>{' '} | ||||
|             to configure additional details about your viewers. | ||||
|           </Typography.Text>{' '} | ||||
|         </p> | ||||
|       </div> | ||||
|       <Title>Connected Chat Participants</Title> | ||||
|       {connectedUsers} | ||||
|       <br /> | ||||
|       <br /> | ||||
|       <Title>Banned Users</Title> | ||||
|       <UserTable data={disabledUsers} /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| @ -6,7 +6,8 @@ import ToggleSwitch from '../components/config/form-toggleswitch'; | ||||
| import { UpdateArgs } from '../types/config-section'; | ||||
| import { | ||||
|   FIELD_PROPS_DISABLE_CHAT, | ||||
|   TEXTFIELD_PROPS_CHAT_USERNAME_BLOCKLIST, | ||||
|   TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES, | ||||
|   TEXTFIELD_PROPS_SERVER_WELCOME_MESSAGE, | ||||
| } from '../utils/config-constants'; | ||||
| import { ServerStatusContext } from '../utils/server-status-context'; | ||||
|  | ||||
| @ -16,8 +17,9 @@ export default function ConfigChat() { | ||||
|   const serverStatusData = useContext(ServerStatusContext); | ||||
|  | ||||
|   const { serverConfig } = serverStatusData || {}; | ||||
|   const { chatDisabled } = serverConfig; | ||||
|   const { usernameBlocklist } = serverConfig; | ||||
|   const { chatDisabled, forbiddenUsernames } = serverConfig; | ||||
|   const { instanceDetails } = serverConfig; | ||||
|   const { welcomeMessage } = instanceDetails; | ||||
|  | ||||
|   const handleFieldChange = ({ fieldName, value }: UpdateArgs) => { | ||||
|     setFormDataValues({ | ||||
| @ -30,14 +32,16 @@ export default function ConfigChat() { | ||||
|     handleFieldChange({ fieldName: 'chatDisabled', value: disabled }); | ||||
|   } | ||||
|  | ||||
|   function handleChatUsernameBlockListChange(args: UpdateArgs) { | ||||
|     handleFieldChange({ fieldName: 'usernameBlocklist', value: args.value }); | ||||
|   function handleChatForbiddenUsernamesChange(args: UpdateArgs) { | ||||
|     const updatedForbiddenUsernameList = args.value.split(','); | ||||
|     handleFieldChange({ fieldName: args.fieldName, value: updatedForbiddenUsernameList }); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setFormDataValues({ | ||||
|       chatDisabled, | ||||
|       usernameBlocklist, | ||||
|       forbiddenUsernames, | ||||
|       welcomeMessage, | ||||
|     }); | ||||
|   }, [serverConfig]); | ||||
|  | ||||
| @ -56,12 +60,18 @@ export default function ConfigChat() { | ||||
|           onChange={handleChatDisableChange} | ||||
|         /> | ||||
|         <TextFieldWithSubmit | ||||
|           fieldName="usernameBlocklist" | ||||
|           {...TEXTFIELD_PROPS_CHAT_USERNAME_BLOCKLIST} | ||||
|           fieldName="forbiddenUsernames" | ||||
|           {...TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES} | ||||
|           type={TEXTFIELD_TYPE_TEXTAREA} | ||||
|           value={formDataValues.usernameBlocklist} | ||||
|           initialValue={usernameBlocklist} | ||||
|           onChange={handleChatUsernameBlockListChange} | ||||
|           value={formDataValues.forbiddenUsernames} | ||||
|           onChange={handleChatForbiddenUsernamesChange} | ||||
|         /> | ||||
|         <TextFieldWithSubmit | ||||
|           fieldName="welcomeMessage" | ||||
|           {...TEXTFIELD_PROPS_SERVER_WELCOME_MESSAGE} | ||||
|           type={TEXTFIELD_TYPE_TEXTAREA} | ||||
|           value={formDataValues.welcomeMessage} | ||||
|           onChange={handleFieldChange} | ||||
|         /> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
| @ -66,11 +66,6 @@ export default function Offline({ logs = [], config }: OfflineProps) { | ||||
|         </div> | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|       icon: <MessageTwoTone twoToneColor="#0366d6" />, | ||||
|       title: 'Chat is disabled', | ||||
|       content: 'Chat will continue to be disabled until you begin a live stream.', | ||||
|     }, | ||||
|     { | ||||
|       icon: <PlaySquareTwoTone twoToneColor="#f9826c" />, | ||||
|       title: 'Embed your video onto other sites', | ||||
| @ -86,18 +81,16 @@ export default function Offline({ logs = [], config }: OfflineProps) { | ||||
|         </div> | ||||
|       ), | ||||
|     }, | ||||
|     { | ||||
|       icon: <QuestionCircleTwoTone twoToneColor="#ffd33d" />, | ||||
|       title: 'Not sure what to do next?', | ||||
|       content: ( | ||||
|         <div> | ||||
|           If you're having issues or would like to know how to customize and configure your | ||||
|           Owncast server visit <Link href="/help">the help page.</Link> | ||||
|         </div> | ||||
|       ), | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|   if (!config?.chatDisabled) { | ||||
|     data.push({ | ||||
|       icon: <MessageTwoTone twoToneColor="#0366d6" />, | ||||
|       title: 'Chat is disabled', | ||||
|       content: <span>Chat will continue to be disabled until you begin a live stream.</span>, | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   if (!config?.yp?.enabled) { | ||||
|     data.push({ | ||||
|       icon: <ProfileTwoTone twoToneColor="#D18BFE" />, | ||||
| @ -111,6 +104,17 @@ export default function Offline({ logs = [], config }: OfflineProps) { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   data.push({ | ||||
|     icon: <QuestionCircleTwoTone twoToneColor="#ffd33d" />, | ||||
|     title: 'Not sure what to do next?', | ||||
|     content: ( | ||||
|       <div> | ||||
|         If you're having issues or would like to know how to customize and configure your | ||||
|         Owncast server visit <Link href="/help">the help page.</Link> | ||||
|       </div> | ||||
|     ), | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Row> | ||||
|  | ||||
| @ -1,3 +1,4 @@ | ||||
| /* eslint-disable react/destructuring-assignment */ | ||||
| import { DeleteOutlined } from '@ant-design/icons'; | ||||
| import { | ||||
|   Button, | ||||
| @ -128,7 +129,6 @@ export default function Webhooks() { | ||||
|  | ||||
|   function handleError(error) { | ||||
|     console.error('error', error); | ||||
|     alert(error); | ||||
|   } | ||||
|  | ||||
|   async function getWebhooks() { | ||||
| @ -197,7 +197,16 @@ export default function Webhooks() { | ||||
|       title: 'Events', | ||||
|       dataIndex: 'events', | ||||
|       key: 'events', | ||||
|       render: ({ map }: string[]) => <>{map(event => convertEventStringToTag(event))}</>, | ||||
|       render: events => ( | ||||
|         <> | ||||
|           { | ||||
|             // eslint-disable-next-line arrow-body-style | ||||
|             events.map(event => { | ||||
|               return convertEventStringToTag(event); | ||||
|             }) | ||||
|           } | ||||
|         </> | ||||
|       ), | ||||
|     }, | ||||
|   ]; | ||||
|  | ||||
|  | ||||
| @ -297,6 +297,14 @@ textarea.ant-input { | ||||
|   transition-delay: 0s; | ||||
|   transition-duration: 0.15s; | ||||
| } | ||||
| .ant-btn-dangerous { | ||||
|   color: var(--white-88); | ||||
|   border-color: var(--ant-error); | ||||
|   background-color: var(--purple-dark); | ||||
| } | ||||
| .ant-btn-sm { | ||||
|   font-size: 12px; | ||||
| } | ||||
|  | ||||
| // ANT TABLE | ||||
| .ant-table-thead > tr > th, | ||||
| @ -381,6 +389,13 @@ textarea.ant-input { | ||||
|   border-color: var(--white-50); | ||||
| } | ||||
|  | ||||
| .ant-modal-confirm-body { | ||||
|   .ant-modal-confirm-title, | ||||
|   .ant-modal-confirm-content { | ||||
|     color: var(--default-text-color); | ||||
|   } | ||||
| } | ||||
|  | ||||
| // SELECT | ||||
| .ant-select-dropdown { | ||||
|   background-color: var(--black); | ||||
| @ -473,14 +488,29 @@ textarea.ant-input { | ||||
|  | ||||
| // ANT POPOVER | ||||
| .ant-popover-inner { | ||||
|   background-color: var(--gray); | ||||
|   background-color: var(--popover-base-color); | ||||
| } | ||||
| .ant-popover-message, | ||||
| .ant-popover-inner-content { | ||||
|   color: var(--default-text-color); | ||||
| } | ||||
| .ant-popover-placement-topLeft > .ant-popover-content > .ant-popover-arrow { | ||||
|   border-color: var(--gray); | ||||
|   border-color: var(--popover-base-color); | ||||
| } | ||||
| .ant-popover-arrow-content { | ||||
|   background-color: var(--popover-base-color); | ||||
| } | ||||
|  | ||||
| // ANT TOOLTIP | ||||
| .ant-tooltip { | ||||
|   font-size: 0.75em; | ||||
| } | ||||
| .ant-tooltip-inner { | ||||
|   color: var(--white); | ||||
| } | ||||
| .ant-tooltip-inner, | ||||
| .ant-tooltip-arrow-content { | ||||
|   background-color: var(--tooltip-base-color); | ||||
| } | ||||
|  | ||||
| // ANT TAGS | ||||
|  | ||||
| @ -1,24 +1,7 @@ | ||||
| .chat-messages { | ||||
|   .ant-table-small .ant-table-selection-column { | ||||
|     width: 20px; | ||||
|     min-width: 20px; | ||||
|   } | ||||
|   .ant-table-tbody > tr > td { | ||||
|     transition: background-color 0.15s; | ||||
|   } | ||||
|   .ant-table-row.hidden { | ||||
|     .ant-table-cell { | ||||
|       color: var(--black-35) | ||||
|     } | ||||
|     @media (prefers-color-scheme: dark) { | ||||
|       .ant-table-cell { | ||||
|         color: var(--white-25); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   .ant-table-cell { | ||||
|     font-size: 12px; | ||||
| // Users, Chat views | ||||
|  | ||||
| .chat-messages { | ||||
|   .ant-table-cell { | ||||
|     &.name-col { | ||||
|       text-overflow: ellipsis; | ||||
|       overflow: hidden; | ||||
| @ -31,7 +14,7 @@ | ||||
|  | ||||
|     .message-contents { | ||||
|       overflow: auto; | ||||
|       max-height: 200px;  | ||||
|       max-height: 200px; | ||||
|       img { | ||||
|         position: relative; | ||||
|         margin-top: -5px; | ||||
| @ -45,8 +28,8 @@ | ||||
|   } | ||||
|  | ||||
|   .bulk-editor { | ||||
|     margin: .5rem 0; | ||||
|     padding: .5rem; | ||||
|     margin: 0.5rem 0; | ||||
|     padding: 0.5rem; | ||||
|     border: 1px solid var(--textfield-border); | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
| @ -60,16 +43,15 @@ | ||||
|     } | ||||
|  | ||||
|     .label { | ||||
|       font-size: .75rem; | ||||
|       font-size: 0.75rem; | ||||
|       color: var(--white-50); | ||||
|       margin-right: .5rem; | ||||
|       margin-right: 0.5rem; | ||||
|     } | ||||
|  | ||||
|     button { | ||||
|       margin: 0 .2rem; | ||||
|       font-size: .75rem; | ||||
|       margin: 0 0.2rem; | ||||
|       font-size: 0.75rem; | ||||
|     } | ||||
|  | ||||
|   } | ||||
| } | ||||
| .ant-table-filter-dropdown { | ||||
| @ -82,20 +64,20 @@ | ||||
|   align-items: center; | ||||
|   flex-wrap: nowrap; | ||||
|   justify-content: flex-end; | ||||
|   transition: opacity .15s; | ||||
|   transition: opacity 0.15s; | ||||
|  | ||||
|   .outcome-icon { | ||||
|     margin-right: .5rem; | ||||
|     margin-right: 0.5rem; | ||||
|   } | ||||
|   &.hidden { | ||||
|     opacity: .25; | ||||
|     opacity: 0.25; | ||||
|     &:hover { | ||||
|       opacity: 1; | ||||
|     } | ||||
|   } | ||||
|   .ant-btn { | ||||
|     .anticon { | ||||
|       opacity: .5; | ||||
|       opacity: 0.5; | ||||
|     } | ||||
|     &:hover { | ||||
|       .anticon { | ||||
| @ -104,6 +86,63 @@ | ||||
|     } | ||||
|   } | ||||
|   .ant-btn-text:hover { | ||||
|     background-color: var(--black-35) | ||||
|     background-color: var(--black-35); | ||||
|   } | ||||
| } | ||||
|  | ||||
| .blockuser-popover { | ||||
|   max-width: 400px; | ||||
| } | ||||
|  | ||||
| .user-item-container { | ||||
|   // reset <button> properties | ||||
|   border: none; | ||||
|   background: none; | ||||
|   text-align: left; | ||||
|   padding: 0; | ||||
|   margin: 0; | ||||
|   cursor: pointer; | ||||
|   outline: none; | ||||
|  | ||||
|   .display-name { | ||||
|     color: var(--white); | ||||
|     border-bottom: 1px dotted var(--white-50); | ||||
|   } | ||||
|   &:hover { | ||||
|     .display-name { | ||||
|       border-color: var(--white); | ||||
|     } | ||||
|   } | ||||
| } | ||||
| .user-details { | ||||
|   h5 { | ||||
|     color: var(--white); | ||||
|   } | ||||
|   .created-at { | ||||
|     font-size: 0.75em; | ||||
|     font-style: italic; | ||||
|   } | ||||
|   .connection-info { | ||||
|     font-size: 0.88em; | ||||
|   } | ||||
|   .previous-names-list { | ||||
|     font-size: 0.88em; | ||||
|     .user-name-item { | ||||
|       font-family: monospace; | ||||
|     } | ||||
|     .latest { | ||||
|       font-style: italic; | ||||
|       .user-name-item { | ||||
|         font-weight: bold; | ||||
|         font-style: normal; | ||||
|         color: var(--pink); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   .ant-divider { | ||||
|     border-color: var(--white-25); | ||||
|   } | ||||
| } | ||||
| .block-user-button { | ||||
|   text-transform: capitalize; | ||||
| } | ||||
|  | ||||
| @ -106,3 +106,39 @@ input { | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .table-container { | ||||
|   .ant-table-tbody > tr > td { | ||||
|     transition: background-color 0.15s; | ||||
|   } | ||||
|   .ant-table-tbody > tr.ant-table-row:hover > td { | ||||
|     background-color: var(--gray); | ||||
|   } | ||||
|   .ant-table-small { | ||||
|     .ant-table-cell { | ||||
|       font-size: 12px; | ||||
|     } | ||||
|     .ant-table-selection-column { | ||||
|       width: 20px; | ||||
|       min-width: 20px; | ||||
|     } | ||||
|   } | ||||
|   .ant-table-row.hidden { | ||||
|     .ant-table-cell { | ||||
|       color: var(--black-35); | ||||
|     } | ||||
|     @media (prefers-color-scheme: dark) { | ||||
|       .ant-table-cell { | ||||
|         color: var(--white-25); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   .ant-table-cell { | ||||
|     &.actions-col { | ||||
|       text-align: right; | ||||
|     } | ||||
|   } | ||||
|   td.number-col { | ||||
|     text-align: right; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,32 +1,32 @@ | ||||
| :root { | ||||
|   // colors | ||||
|   --white: rgba(255,255,255,1); | ||||
|   --white-15: rgba(255,255,255,.15); | ||||
|   --white-25: rgba(255,255,255,.25); | ||||
|   --white-35: rgba(255,255,255,.35); | ||||
|   --white-50: rgba(255,255,255,.5); | ||||
|   --white-75: rgba(255,255,255,.75); | ||||
|   --white-88: rgba(255,255,255,.88); | ||||
|   --white: rgba(255, 255, 255, 1); | ||||
|   --white-15: rgba(255, 255, 255, 0.15); | ||||
|   --white-25: rgba(255, 255, 255, 0.25); | ||||
|   --white-35: rgba(255, 255, 255, 0.35); | ||||
|   --white-50: rgba(255, 255, 255, 0.5); | ||||
|   --white-75: rgba(255, 255, 255, 0.75); | ||||
|   --white-88: rgba(255, 255, 255, 0.88); | ||||
|  | ||||
|   --black: rgba(0,0,0,1); | ||||
|   --black-35: rgba(0,0,0,.35); | ||||
|   --black-50: rgba(0,0,0,.5); | ||||
|   --black-75: rgba(0,0,0,.75); | ||||
|   --black: rgba(0, 0, 0, 1); | ||||
|   --black-35: rgba(0, 0, 0, 0.35); | ||||
|   --black-50: rgba(0, 0, 0, 0.5); | ||||
|   --black-75: rgba(0, 0, 0, 0.75); | ||||
|  | ||||
|   // owncast logo color family | ||||
|   --owncast-purple: rgba(120,113,255,1); // #7871FF; | ||||
|   --purple-dark: rgba(28,26,59,1); // #1c1a3b;// | ||||
|   --pink: rgba(201,139,254,1); // #D18BFE; | ||||
|   --blue: rgba(32,134,225,1); // #2086E1; | ||||
|   --owncast-purple: rgba(120, 113, 255, 1); // #7871FF; | ||||
|   --purple-dark: rgba(28, 26, 59, 1); // #1c1a3b;// | ||||
|   --pink: rgba(201, 139, 254, 1); // #D18BFE; | ||||
|   --blue: rgba(32, 134, 225, 1); // #2086E1; | ||||
|  | ||||
|   // owncast purple variations | ||||
|   --owncast-purple-25: rgba(120,113,255,.25); | ||||
|   --owncast-purple-50: rgba(120,113,255,.5); | ||||
|   --owncast-purple-25: rgba(120, 113, 255, 0.25); | ||||
|   --owncast-purple-50: rgba(120, 113, 255, 0.5); | ||||
|  | ||||
|   --gray-light:  rgba(168,175,197,1);  | ||||
|   --gray-medium:  rgba(102,107,120,1);  | ||||
|   --gray:  rgba(51,53,60,1);  | ||||
|   --gray-dark: rgba(23,24,27,1); // #17181b; | ||||
|   --gray-light: rgba(168, 175, 197, 1); | ||||
|   --gray-medium: rgba(102, 107, 120, 1); | ||||
|   --gray: rgba(51, 53, 60, 1); | ||||
|   --gray-dark: rgba(23, 24, 27, 1); // #17181b; | ||||
|  | ||||
|   --online-color: #73dd3f; | ||||
|   --offline-color: #999; | ||||
| @ -34,8 +34,7 @@ | ||||
|   --ant-error: #ff4d4f; | ||||
|   --ant-success: #52c41a; | ||||
|   --ant-warning: #faad14; | ||||
|   --ant-transition-duration: .15s; | ||||
|  | ||||
|   --ant-transition-duration: 0.15s; | ||||
|  | ||||
|   // //////////////////////////////// | ||||
|   --default-text-color: var(--white-88); | ||||
| @ -43,7 +42,7 @@ | ||||
|   --default-link-color: var(--owncast-purple); | ||||
|  | ||||
|   --container-bg-color: var(--gray-dark); | ||||
|   --container-bg-color-alt: var(--purple-dark);  | ||||
|   --container-bg-color-alt: var(--purple-dark); | ||||
|   --container-border-radius: 4px; | ||||
|  | ||||
|   --code-color: #9cdcfe; | ||||
| @ -55,7 +54,10 @@ | ||||
|  | ||||
|   --button-focused: var(--owncast-purple-50); | ||||
|  | ||||
|   --textfield-border: var(--white-25);; | ||||
|   --textfield-border: var(--white-25); | ||||
|   --textfield-bg: var(--black); | ||||
|   | ||||
|  | ||||
|   // | ||||
|   --popover-base-color: var(--gray); | ||||
|   --tooltip-base-color: var(--gray-medium); | ||||
| } | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| export interface MessageType { | ||||
|   author: string; | ||||
|   user: User; | ||||
|   body: string; | ||||
|   id: string; | ||||
|   key: string; | ||||
| @ -8,3 +8,27 @@ export interface MessageType { | ||||
|   type: string; | ||||
|   visible: boolean; | ||||
| } | ||||
|  | ||||
| export interface User { | ||||
|   id: string; | ||||
|   displayName: string; | ||||
|   createdAt: Date; | ||||
|   disabledAt: Date; | ||||
|   previousNames: [string]; | ||||
|   nameChangedAt: Date; | ||||
| } | ||||
|  | ||||
| export interface UsernameHistory { | ||||
|   displayName: string; | ||||
|   changedAt: Date; | ||||
| } | ||||
|  | ||||
| export interface UserConnectionInfo { | ||||
|   connectedAt: Date; | ||||
|   messageCount: number; | ||||
|   userAgent: string; | ||||
| } | ||||
|  | ||||
| export interface Client extends UserConnectionInfo { | ||||
|   user: User; | ||||
| } | ||||
|  | ||||
| @ -89,7 +89,6 @@ export interface ExternalAction { | ||||
| } | ||||
|  | ||||
| export interface ConfigDetails { | ||||
|   chatDisabled: boolean; | ||||
|   externalActions: ExternalAction[]; | ||||
|   ffmpegPath: string; | ||||
|   instanceDetails: ConfigInstanceDetailsFields; | ||||
| @ -101,5 +100,6 @@ export interface ConfigDetails { | ||||
|   yp: ConfigDirectoryFields; | ||||
|   supportedCodecs: string[]; | ||||
|   videoCodec: string; | ||||
|   usernameBlocklist: string; | ||||
|   forbiddenUsernames: string[]; | ||||
|   chatDisabled: boolean; | ||||
| } | ||||
|  | ||||
| @ -28,6 +28,12 @@ export const VIEWERS_OVER_TIME = `${API_LOCATION}viewersOverTime`; | ||||
| // Get currently connected clients | ||||
| export const CONNECTED_CLIENTS = `${API_LOCATION}clients`; | ||||
|  | ||||
| // Get list of disabled/blocked chat users | ||||
| export const DISABLED_USERS = `${API_LOCATION}chat/users/disabled`; | ||||
|  | ||||
| // Disable/enable a single user | ||||
| export const USER_ENABLED_TOGGLE = `${API_LOCATION}chat/users/setenabled`; | ||||
|  | ||||
| // Get hardware stats | ||||
| export const HARDWARE_STATS = `${API_LOCATION}hardwarestats`; | ||||
|  | ||||
|  | ||||
| @ -30,7 +30,7 @@ export const API_VIDEO_VARIANTS = '/video/streamoutputvariants'; | ||||
| export const API_WEB_PORT = '/webserverport'; | ||||
| export const API_YP_SWITCH = '/directoryenabled'; | ||||
| export const API_CHAT_DISABLE = '/chat/disable'; | ||||
| export const API_CHAT_USERNAME_BLOCKLIST = '/chat/disallowedusernames'; | ||||
| export const API_CHAT_FORBIDDEN_USERNAMES = '/chat/forbiddenusernames'; | ||||
| export const API_EXTERNAL_ACTIONS = '/externalactions'; | ||||
| export const API_VIDEO_CODEC = '/video/codec'; | ||||
|  | ||||
| @ -177,17 +177,17 @@ export const DEFAULT_VARIANT_STATE: VideoVariant = { | ||||
|  | ||||
| export const FIELD_PROPS_DISABLE_CHAT = { | ||||
|   apiPath: API_CHAT_DISABLE, | ||||
|   configPath: 'chatDisabled', | ||||
|   configPath: '', | ||||
|   label: 'Disable chat', | ||||
|   tip: 'Disable chat functionality from your Owncast server.', | ||||
|   useSubmit: true, | ||||
| }; | ||||
|  | ||||
| export const TEXTFIELD_PROPS_CHAT_USERNAME_BLOCKLIST = { | ||||
|   apiPath: API_CHAT_USERNAME_BLOCKLIST, | ||||
|   placeholder: 'admin, god, owncast, stewiegriffin', | ||||
|   label: 'Disallowed usernames', | ||||
|   tip: 'A comma seperated list of chat usernames you disallow.', | ||||
| export const TEXTFIELD_PROPS_CHAT_FORBIDDEN_USERNAMES = { | ||||
|   apiPath: API_CHAT_FORBIDDEN_USERNAMES, | ||||
|   placeholder: 'admin,god,owncast,stewiegriffin', | ||||
|   label: 'Forbidden usernames', | ||||
|   tip: 'A comma separated list of chat usernames you disallow.', | ||||
| }; | ||||
|  | ||||
| export const VIDEO_VARIANT_SETTING_DEFAULTS = { | ||||
|  | ||||
| @ -1,13 +1,15 @@ | ||||
| import UAParser from 'ua-parser-js'; | ||||
|  | ||||
| export function formatIPAddress(ipAddress: string): string { | ||||
|   const ipAddressComponents = ipAddress.split(':') | ||||
|   const ipAddressComponents = ipAddress.split(':'); | ||||
|  | ||||
|   // Wipe out the port component | ||||
|   ipAddressComponents[ipAddressComponents.length - 1] = ''; | ||||
|  | ||||
|   let ip = ipAddressComponents.join(':') | ||||
|   ip = ip.slice(0, ip.length - 1) | ||||
|   let ip = ipAddressComponents.join(':'); | ||||
|   ip = ip.slice(0, ip.length - 1); | ||||
|   if (ip === '[::1]' || ip === '127.0.0.1') { | ||||
|     return "Localhost" | ||||
|     return 'Localhost'; | ||||
|   } | ||||
|  | ||||
|   return ip; | ||||
| @ -39,3 +41,21 @@ export function parseSecondsToDurationString(seconds = 0) { | ||||
|  | ||||
|   return daysString + hoursString + minString + secsString; | ||||
| } | ||||
|  | ||||
| export function makeAndStringFromArray(arr: string[]): string { | ||||
|   if (arr.length === 1) return arr[0]; | ||||
|   const firsts = arr.slice(0, arr.length - 1); | ||||
|   const last = arr[arr.length - 1]; | ||||
|   return `${firsts.join(', ')} and ${last}`; | ||||
| } | ||||
|  | ||||
| export function formatUAstring(uaString: string) { | ||||
|   const parser = UAParser(uaString); | ||||
|   const { device, os, browser } = parser; | ||||
|   const { major: browserVersion, name } = browser; | ||||
|   const { version: osVersion, name: osName } = os; | ||||
|   const { model, type } = device; | ||||
|   const deviceString = model || type ? ` (${model || type})` : ''; | ||||
|   return `${name} ${browserVersion} on ${osName} ${osVersion} | ||||
|   ${deviceString}`; | ||||
| } | ||||
|  | ||||
| @ -25,7 +25,6 @@ export const initialServerConfigState: ConfigDetails = { | ||||
|   ffmpegPath: '', | ||||
|   rtmpServerPort: '', | ||||
|   webServerPort: '', | ||||
|   chatDisabled: false, | ||||
|   s3: { | ||||
|     accessKey: '', | ||||
|     acl: '', | ||||
| @ -48,7 +47,8 @@ export const initialServerConfigState: ConfigDetails = { | ||||
|   externalActions: [], | ||||
|   supportedCodecs: [], | ||||
|   videoCodec: '', | ||||
|   usernameBlocklist: '', | ||||
|   forbiddenUsernames: [], | ||||
|   chatDisabled: false, | ||||
| }; | ||||
|  | ||||
| const initialServerStatusState = { | ||||
| @ -62,6 +62,7 @@ const initialServerStatusState = { | ||||
|   overallPeakViewerCount: 0, | ||||
|   versionNumber: '0.0.0', | ||||
|   streamTitle: '', | ||||
|   chatDisabled: false, | ||||
| }; | ||||
|  | ||||
| export const ServerStatusContext = React.createContext({ | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Gabe Kangas
					Gabe Kangas