0

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:
Gabe Kangas 2021-07-19 22:02:02 -07:00 committed by GitHub
parent 4aac80196d
commit b10ba1dcc2
26 changed files with 8007 additions and 238 deletions

View File

@ -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'

View 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,
};

View 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[];
}

View File

@ -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 />

View File

@ -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 &amp; 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>

View 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,
};

View 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
View File

@ -1,2 +1,3 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />

7273
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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',

View File

@ -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}

View File

@ -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} />
</>
);
}

View File

@ -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>

View File

@ -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&apos;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&apos;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>

View File

@ -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);
})
}
</>
),
},
];

View File

@ -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

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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`;

View File

@ -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 = {

View File

@ -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}`;
}

View File

@ -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({