From b10ba1dcc260a3226e59a7e08ed383b51bc28081 Mon Sep 17 00:00:00 2001
From: Gabe Kangas 
Date: Mon, 19 Jul 2021 22:02:02 -0700
Subject: [PATCH] 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 
Co-authored-by: gabek 
---
 web/.github/workflows/linter.yml              |    2 +-
 web/components/ban-user-button.tsx            |   85 +
 web/components/client-table.tsx               |   80 +
 .../config/edit-instance-details.tsx          |    9 -
 web/components/main-layout.tsx                |   14 +-
 web/components/user-popover.tsx               |  146 +
 web/components/user-table.tsx                 |   64 +
 web/next-env.d.ts                             |    1 +
 web/package-lock.json                         | 7273 ++++++++++++++++-
 web/package.json                              |    4 +-
 web/pages/access-tokens.tsx                   |   25 +-
 web/pages/chat/messages.tsx                   |   34 +-
 web/pages/chat/users.tsx                      |  117 +-
 web/pages/config-chat.tsx                     |   32 +-
 web/pages/offline-notice.tsx                  |   34 +-
 web/pages/webhooks.tsx                        |   13 +-
 web/styles/ant-overrides.scss                 |   34 +-
 web/styles/chat.scss                          |  105 +-
 web/styles/globals.scss                       |   36 +
 web/styles/variables.scss                     |   54 +-
 web/types/chat.ts                             |   26 +-
 web/types/config-section.ts                   |    4 +-
 web/utils/apis.ts                             |    6 +
 web/utils/config-constants.tsx                |   14 +-
 web/utils/format.ts                           |   28 +-
 web/utils/server-status-context.tsx           |    5 +-
 26 files changed, 8007 insertions(+), 238 deletions(-)
 create mode 100644 web/components/ban-user-button.tsx
 create mode 100644 web/components/client-table.tsx
 create mode 100644 web/components/user-popover.tsx
 create mode 100644 web/components/user-table.tsx
diff --git a/web/.github/workflows/linter.yml b/web/.github/workflows/linter.yml
index 50ddb517db..82a695165a 100644
--- a/web/.github/workflows/linter.yml
+++ b/web/.github/workflows/linter.yml
@@ -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'
diff --git a/web/components/ban-user-button.tsx b/web/components/ban-user-button.tsx
new file mode 100644
index 0000000000..8763155bfe
--- /dev/null
+++ b/web/components/ban-user-button.tsx
@@ -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 {
+    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 ? (
+    
+  ) : (
+    
+  );
+
+  const content = (
+    <>
+      Are you sure you want to {actionString} {user.displayName}
+      {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 (
+     : null}
+      className="block-user-button"
+    >
+      {label || actionString}
+    
+  );
+}
+BanUserButton.defaultProps = {
+  label: '',
+  onClick: null,
+};
diff --git a/web/components/client-table.tsx b/web/components/client-table.tsx
new file mode 100644
index 0000000000..fa7060fd0d
--- /dev/null
+++ b/web/components/client-table.tsx
@@ -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 = [
+    {
+      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 (
+          
+            {user.displayName}
+          
+        );
+      },
+      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) => ,
+    },
+  ];
+
+  return (
+    
+  );
+}
+
+interface ClientTableProps {
+  data: Client[];
+}
diff --git a/web/components/config/edit-instance-details.tsx b/web/components/config/edit-instance-details.tsx
index 1be3cc901a..ed39c52fd6 100644
--- a/web/components/config/edit-instance-details.tsx
+++ b/web/components/config/edit-instance-details.tsx
@@ -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}
       />
-      
 
       {/* Logo section */}
       
diff --git a/web/components/main-layout.tsx b/web/components/main-layout.tsx
index b4626fc86f..8ec0f5a93d 100644
--- a/web/components/main-layout.tsx
+++ b/web/components/main-layout.tsx
@@ -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) {
       
         
@@ -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) => (
         
-          
       ),
     },
     {
       title: 'Name',
-      dataIndex: 'name',
-      key: 'name',
+      dataIndex: 'displayName',
+      key: 'displayName',
     },
     {
       title: 'Token',
-      dataIndex: 'token',
-      key: 'token',
+      dataIndex: 'accessToken',
+      key: 'accessToken',
       render: 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',
diff --git a/web/pages/chat/messages.tsx b/web/pages/chat/messages.tsx
index 5916bf11ba..bc4977b65e 100644
--- a/web/pages/chat/messages.tsx
+++ b/web/pages/chat/messages.tsx
@@ -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 => (
-        
-          {author}
-        
-      ),
+      render: user => {
+        const { displayName } = user;
+        return {displayName};
+      },
       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) => (
-        
+      render: (hiddenAt, record) => (
+        
       ),
       width: 30,
     },
@@ -234,10 +234,10 @@ export default function Chat() {
       
        (!record.visible ? 'hidden' : '')}
+        rowClassName={record => (record.hiddenAt ? 'hidden' : '')}
         dataSource={messages}
         columns={chatColumns}
         rowKey={row => row.id}
diff --git a/web/pages/chat/users.tsx b/web/pages/chat/users.tsx
index d340be4728..1dc592b920 100644
--- a/web/pages/chat/users.tsx
+++ b/web/pages/chat/users.tsx
@@ -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 ? (
+    <>
+      
+      
+        Visit the{' '}
+        
+          documentation
+        {' '}
+        to configure additional details about your viewers.
+      
+    >
+  ) : (
+    
+      When a stream is active and chat is enabled, connected chat clients will be displayed here.
+    
+  );
   return (
     <>
-      
-        
Connected
-        
 row.clientID} />
-        
-          
-            Visit the{' '}
-            
-              documentation
-            {' '}
-            to configure additional details about your viewers.
-          {' '}
-        
-      
+      Connected Chat Participants
+      {connectedUsers}
+      
+      
+      Banned Users
+      
     >
   );
 }
diff --git a/web/pages/config-chat.tsx b/web/pages/config-chat.tsx
index cbecd0ec60..cc2fa18960 100644
--- a/web/pages/config-chat.tsx
+++ b/web/pages/config-chat.tsx
@@ -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}
         />
         
+        
       
     
diff --git a/web/pages/offline-notice.tsx b/web/pages/offline-notice.tsx
index d56a85ab55..484bf4ec3b 100644
--- a/web/pages/offline-notice.tsx
+++ b/web/pages/offline-notice.tsx
@@ -66,11 +66,6 @@ export default function Offline({ logs = [], config }: OfflineProps) {
         
       ),
     },
-    {
-      icon: ,
-      title: 'Chat is disabled',
-      content: 'Chat will continue to be disabled until you begin a live stream.',
-    },
     {
       icon: ,
       title: 'Embed your video onto other sites',
@@ -86,18 +81,16 @@ export default function Offline({ logs = [], config }: OfflineProps) {
         
       ),
     },
-    {
-      icon: ,
-      title: 'Not sure what to do next?',
-      content: (
-        
-          If you're having issues or would like to know how to customize and configure your
-          Owncast server visit the help page.
-        
-      ),
-    },
   ];
 
+  if (!config?.chatDisabled) {
+    data.push({
+      icon: ,
+      title: 'Chat is disabled',
+      content: Chat will continue to be disabled until you begin a live stream.,
+    });
+  }
+
   if (!config?.yp?.enabled) {
     data.push({
       icon: ,
@@ -111,6 +104,17 @@ export default function Offline({ logs = [], config }: OfflineProps) {
     });
   }
 
+  data.push({
+    icon: ,
+    title: 'Not sure what to do next?',
+    content: (
+      
+        If you're having issues or would like to know how to customize and configure your
+        Owncast server visit the help page.
+      
+    ),
+  });
+
   return (
     <>
       
diff --git a/web/pages/webhooks.tsx b/web/pages/webhooks.tsx
index 6c504513c9..48ebf85680 100644
--- a/web/pages/webhooks.tsx
+++ b/web/pages/webhooks.tsx
@@ -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);
+            })
+          }
+        >
+      ),
     },
   ];
 
diff --git a/web/styles/ant-overrides.scss b/web/styles/ant-overrides.scss
index 2bf941aad5..85cb953a6a 100644
--- a/web/styles/ant-overrides.scss
+++ b/web/styles/ant-overrides.scss
@@ -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
diff --git a/web/styles/chat.scss b/web/styles/chat.scss
index 6f0412b07f..f7b508f616 100644
--- a/web/styles/chat.scss
+++ b/web/styles/chat.scss
@@ -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  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;
+}
diff --git a/web/styles/globals.scss b/web/styles/globals.scss
index 5652868ca0..f4c214f31a 100644
--- a/web/styles/globals.scss
+++ b/web/styles/globals.scss
@@ -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;
+  }
+}
diff --git a/web/styles/variables.scss b/web/styles/variables.scss
index b95c71f042..6ff4856bac 100644
--- a/web/styles/variables.scss
+++ b/web/styles/variables.scss
@@ -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);
 }
diff --git a/web/types/chat.ts b/web/types/chat.ts
index 096839b86f..b0334eb08a 100644
--- a/web/types/chat.ts
+++ b/web/types/chat.ts
@@ -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;
+}
diff --git a/web/types/config-section.ts b/web/types/config-section.ts
index e9e043e165..61c55b0ed6 100644
--- a/web/types/config-section.ts
+++ b/web/types/config-section.ts
@@ -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;
 }
diff --git a/web/utils/apis.ts b/web/utils/apis.ts
index 88bb7a0633..fcda7c749f 100644
--- a/web/utils/apis.ts
+++ b/web/utils/apis.ts
@@ -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`;
 
diff --git a/web/utils/config-constants.tsx b/web/utils/config-constants.tsx
index 0845a9f363..bd6699905b 100644
--- a/web/utils/config-constants.tsx
+++ b/web/utils/config-constants.tsx
@@ -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 = {
diff --git a/web/utils/format.ts b/web/utils/format.ts
index c7db93cb07..81c4a1938e 100644
--- a/web/utils/format.ts
+++ b/web/utils/format.ts
@@ -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}`;
+}
diff --git a/web/utils/server-status-context.tsx b/web/utils/server-status-context.tsx
index cca2b135e2..a242e3b5be 100644
--- a/web/utils/server-status-context.tsx
+++ b/web/utils/server-status-context.tsx
@@ -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({