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:
parent
4aac80196d
commit
b10ba1dcc2
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({
|
||||
|
Loading…
x
Reference in New Issue
Block a user