diff --git a/js/src/admin/compat.js b/js/src/admin/compat.js index a03f90430..96b13bc5a 100644 --- a/js/src/admin/compat.js +++ b/js/src/admin/compat.js @@ -24,6 +24,7 @@ import UploadImageButton from './components/UploadImageButton'; import LoadingModal from './components/LoadingModal'; import DashboardPage from './components/DashboardPage'; import BasicsPage from './components/BasicsPage'; +import UserListPage from './components/UserListPage'; import EditCustomHeaderModal from './components/EditCustomHeaderModal'; import PermissionsPage from './components/PermissionsPage'; import PermissionDropdown from './components/PermissionDropdown'; @@ -59,6 +60,7 @@ export default Object.assign(compat, { 'components/LoadingModal': LoadingModal, 'components/DashboardPage': DashboardPage, 'components/BasicsPage': BasicsPage, + 'components/UserListPage': UserListPage, 'components/EditCustomHeaderModal': EditCustomHeaderModal, 'components/PermissionsPage': PermissionsPage, 'components/PermissionDropdown': PermissionDropdown, diff --git a/js/src/admin/components/AdminNav.js b/js/src/admin/components/AdminNav.js index 0c307e7b3..fcd4f8843 100644 --- a/js/src/admin/components/AdminNav.js +++ b/js/src/admin/components/AdminNav.js @@ -94,6 +94,13 @@ export default class AdminNav extends Component { ); + items.add( + 'userList', + + {app.translator.trans('core.admin.nav.userlist_button')} + + ); + items.add( 'search',
diff --git a/js/src/admin/components/UserListPage.tsx b/js/src/admin/components/UserListPage.tsx new file mode 100644 index 000000000..e49a84dc6 --- /dev/null +++ b/js/src/admin/components/UserListPage.tsx @@ -0,0 +1,384 @@ +import EditUserModal from '../../common/components/EditUserModal'; +import LoadingIndicator from '../../common/components/LoadingIndicator'; +import Button from '../../common/components/Button'; + +import icon from '../../common/helpers/icon'; +import listItems from '../../common/helpers/listItems'; + +import type User from '../../common/models/User'; + +import ItemList from '../../common/utils/ItemList'; +import classList from '../../common/utils/classList'; +import extractText from '../../common/utils/extractText'; + +import AdminPage from './AdminPage'; + +type ColumnData = { + /** + * Column title + */ + name: String; + /** + * Component(s) to show for this column. + */ + content: (user: User) => JSX.Element; +}; + +type ApiPayload = { + data: Record[]; + included: Record[]; + links: { + first: string; + next?: string; + }; +}; + +type UsersApiResponse = User[] & { payload: ApiPayload }; + +/** + * Admin page which displays a paginated list of all users on the forum. + */ +export default class UserListPage extends AdminPage { + /** + * Number of users to load per page. + */ + private numPerPage: number = 50; + + /** + * Current page number. Zero-indexed. + */ + private pageNumber: number = 0; + + /** + * Total number of forum users. + * + * Fetched from the active `AdminApplication` (`app`), with + * data provided by `AdminPayload.php`, or `flarum/statistics` + * if installed. + */ + readonly userCount: number = app.data.modelStatistics.users.total; + + /** + * Get total number of user pages. + */ + private getTotalPageCount(): number { + if (this.userCount === -1) return 0; + + return Math.ceil(this.userCount / this.numPerPage); + } + + /** + * This page's array of users. + * + * `undefined` when page loads as no data has been fetched. + */ + private pageData: User[] | undefined = undefined; + + /** + * Are there more users available? + */ + private moreData: boolean = false; + + private isLoadingPage: boolean = false; + + /** + * Component to render. + */ + content() { + if (typeof this.pageData === 'undefined') { + this.loadPage(0); + + return [ +
+ +
, + ]; + } + + const columns: (ColumnData & { itemName: string })[] = this.columns().toArray(); + + return [ +

{app.translator.trans('core.admin.users.total_users', { count: this.userCount })}

, +
+ {/* Render columns */} + {columns.map((column, colIndex) => ( +
+ {column.name} +
+ ))} + + {/* Render user data */} + {this.pageData.map((user, rowIndex) => + columns.map((col, colIndex) => { + const columnContent = col.content && col.content(user); + + return ( +
0 && 'UserListPage-grid--shadedRow'])} + data-user-id={user.id()} + data-column-name={col.itemName} + aria-colindex={colIndex + 1} + // +2 to account for 0-based index, and for the header row + aria-rowindex={rowIndex + 2} + role="cell" + > + {columnContent || app.translator.trans('core.admin.users.grid.invalid_column_content')} +
+ ); + }) + )} + + {/* Loading spinner that shows when a new page is being loaded */} + {this.isLoadingPage && } +
, + , + ]; + } + + /** + * Build an item list of columns to show for each user. + * + * Each column in the list should be an object with keys `name` and `content`. + * + * `name` is a string that will be used as the column name. + * `content` is a function with the User model passed as the first and only argument. + * + * See `UserListPage.tsx` for examples. + */ + columns(): ItemList { + const columns = new ItemList(); + + columns.add( + 'id', + { + name: app.translator.trans('core.admin.users.grid.columns.user_id.title'), + content: (user: User) => user.id(), + }, + 100 + ); + + columns.add( + 'username', + { + name: app.translator.trans('core.admin.users.grid.columns.username.title'), + content: (user: User) => { + const profileUrl = `${app.forum.attribute('baseUrl')}/u/${user.slug()}`; + + return ( + + {user.username()} + + ); + }, + }, + 90 + ); + + columns.add( + 'joinDate', + { + name: app.translator.trans('core.admin.users.grid.columns.join_time.title'), + content: (user: User) => ( + + {dayjs(user.joinTime()).format('LLL')} + + ), + }, + 80 + ); + + columns.add( + 'groupBadges', + { + name: app.translator.trans('core.admin.users.grid.columns.group_badges.title'), + content: (user: User) => { + const badges = user.badges().toArray(); + + if (badges.length) { + return
    {listItems(badges)}
; + } else { + return app.translator.trans('core.admin.users.grid.columns.group_badges.no_badges'); + } + }, + }, + 70 + ); + + columns.add( + 'emailAddress', + { + name: app.translator.trans('core.admin.users.grid.columns.email.title'), + content: (user: User) => { + function setEmailVisibility(visible: boolean) { + // Get needed jQuery element refs + const emailContainer = $(`[data-column-name=emailAddress][data-user-id=${user.id()}] .UserList-email`); + const emailAddress = emailContainer.find('.UserList-emailAddress'); + const emailToggleButton = emailContainer.find('.UserList-emailIconBtn'); + const emailToggleButtonIcon = emailToggleButton.find('.icon'); + + emailToggleButton.attr( + 'title', + extractText( + visible + ? app.translator.trans('core.admin.users.grid.columns.email.visibility_hide') + : app.translator.trans('core.admin.users.grid.columns.email.visibility_show') + ) + ); + + emailAddress.attr('aria-hidden', visible ? 'false' : 'true'); + + if (visible) { + emailToggleButtonIcon.addClass('fa-eye'); + emailToggleButtonIcon.removeClass('fa-eye-slash'); + } else { + emailToggleButtonIcon.removeClass('fa-eye'); + emailToggleButtonIcon.addClass('fa-eye-slash'); + } + + // Need the string interpolation to prevent TS error. + emailContainer.attr('data-email-shown', `${visible}`); + } + + function toggleEmailVisibility() { + const emailContainer = $(`[data-column-name=emailAddress][data-user-id=${user.id()}] .UserList-email`); + const emailShown = emailContainer.attr('data-email-shown') === 'true'; + + if (emailShown) { + setEmailVisibility(false); + } else { + setEmailVisibility(true); + } + } + + return ( +
+ setEmailVisibility(true)}> + {user.email()} + + +
+ ); + }, + }, + 70 + ); + + columns.add( + 'editUser', + { + name: app.translator.trans('core.admin.users.grid.columns.edit_user.title'), + content: (user: User) => ( + + ), + }, + -90 + ); + + return columns; + } + + headerInfo() { + return { + className: 'UserListPage', + icon: 'fas fa-users', + title: app.translator.trans('core.admin.users.title'), + description: app.translator.trans('core.admin.users.description'), + }; + } + + /** + * Asynchronously fetch the next set of users to be rendered. + * + * Returns an array of Users, plus the raw API payload. + * + * Uses the `this.numPerPage` as the response limit, and automatically calculates the offset required from `pageNumber`. + * + * @param pageNumber The page number to load and display + */ + async loadPage(pageNumber: number) { + if (pageNumber < 0) pageNumber = 0; + + app.store + .find('users', { + page: { + limit: this.numPerPage, + offset: pageNumber * this.numPerPage, + }, + }) + .then((apiData: UsersApiResponse) => { + // Next link won't be present if there's no more data + this.moreData = !!apiData.payload.links.next; + + let data = apiData; + + // @ts-ignore + delete data.payload; + + this.pageData = data; + this.pageNumber = pageNumber; + this.isLoadingPage = false; + + m.redraw(); + }) + .catch((err: Error) => { + console.error(err); + this.pageData = []; + }); + } + + nextPage() { + this.isLoadingPage = true; + this.loadPage(this.pageNumber + 1); + } + + previousPage() { + this.isLoadingPage = true; + this.loadPage(this.pageNumber - 1); + } +} diff --git a/js/src/admin/routes.js b/js/src/admin/routes.js index 3f3e295e1..9c3468fde 100644 --- a/js/src/admin/routes.js +++ b/js/src/admin/routes.js @@ -3,6 +3,7 @@ import BasicsPage from './components/BasicsPage'; import PermissionsPage from './components/PermissionsPage'; import AppearancePage from './components/AppearancePage'; import MailPage from './components/MailPage'; +import UserListPage from './components/UserListPage'; import ExtensionPage from './components/ExtensionPage'; import ExtensionPageResolver from './resolvers/ExtensionPageResolver'; @@ -18,6 +19,7 @@ export default function (app) { permissions: { path: '/permissions', component: PermissionsPage }, appearance: { path: '/appearance', component: AppearancePage }, mail: { path: '/mail', component: MailPage }, + users: { path: '/users', component: UserListPage }, extension: { path: '/extension/:id', component: ExtensionPage, resolverClass: ExtensionPageResolver }, }; } diff --git a/js/src/common/compat.js b/js/src/common/compat.js index f4fbe04e5..6c4d5898b 100644 --- a/js/src/common/compat.js +++ b/js/src/common/compat.js @@ -59,6 +59,7 @@ import Modal from './components/Modal'; import GroupBadge from './components/GroupBadge'; import TextEditor from './components/TextEditor'; import TextEditorButton from './components/TextEditorButton'; +import EditUserModal from './components/EditUserModal'; import Model from './Model'; import Application from './Application'; import fullTime from './helpers/fullTime'; @@ -136,6 +137,7 @@ export default { 'components/GroupBadge': GroupBadge, 'components/TextEditor': TextEditor, 'components/TextEditorButton': TextEditorButton, + 'components/EditUserModal': EditUserModal, Model: Model, Application: Application, 'helpers/fullTime': fullTime, diff --git a/js/src/forum/components/EditUserModal.js b/js/src/common/components/EditUserModal.js similarity index 83% rename from js/src/forum/components/EditUserModal.js rename to js/src/common/components/EditUserModal.js index a64a9fb2c..570e16f3d 100644 --- a/js/src/forum/components/EditUserModal.js +++ b/js/src/common/components/EditUserModal.js @@ -1,10 +1,10 @@ -import Modal from '../../common/components/Modal'; -import Button from '../../common/components/Button'; -import GroupBadge from '../../common/components/GroupBadge'; -import Group from '../../common/models/Group'; -import extractText from '../../common/utils/extractText'; -import ItemList from '../../common/utils/ItemList'; -import Stream from '../../common/utils/Stream'; +import Modal from './Modal'; +import Button from './Button'; +import GroupBadge from './GroupBadge'; +import Group from '../models/Group'; +import extractText from '../utils/extractText'; +import ItemList from '../utils/ItemList'; +import Stream from '../utils/Stream'; /** * The `EditUserModal` component displays a modal dialog with a login form. @@ -33,14 +33,14 @@ export default class EditUserModal extends Modal { } title() { - return app.translator.trans('core.forum.edit_user.title'); + return app.translator.trans('core.lib.edit_user.title'); } content() { const fields = this.fields().toArray(); return (
- {fields.length > 1 ?
{this.fields().toArray()}
: app.translator.trans('core.forum.edit_user.nothing_available')} + {fields.length > 1 ?
{this.fields().toArray()}
: app.translator.trans('core.lib.edit_user.nothing_available')}
); } @@ -52,10 +52,10 @@ export default class EditUserModal extends Modal { items.add( 'username',
- + @@ -67,11 +67,11 @@ export default class EditUserModal extends Modal { items.add( 'email',
- +
@@ -84,7 +84,7 @@ export default class EditUserModal extends Modal { loading: this.loading, onclick: this.activate.bind(this), }, - app.translator.trans('core.forum.edit_user.activate_button') + app.translator.trans('core.lib.edit_user.activate_button') )}
) : ( @@ -97,7 +97,7 @@ export default class EditUserModal extends Modal { items.add( 'password',
- +
{this.setPassword() ? ( @@ -135,7 +135,7 @@ export default class EditUserModal extends Modal { items.add( 'groups',
- +
{Object.keys(this.groups) .map((id) => app.store.getById('groups', id)) @@ -164,7 +164,7 @@ export default class EditUserModal extends Modal { type: 'submit', loading: this.loading, }, - app.translator.trans('core.forum.edit_user.submit_button') + app.translator.trans('core.lib.edit_user.submit_button') )}
, -10 diff --git a/js/src/forum/compat.js b/js/src/forum/compat.js index 4d6a0f5db..a2bb3ffe8 100644 --- a/js/src/forum/compat.js +++ b/js/src/forum/compat.js @@ -51,7 +51,6 @@ import PostPreview from './components/PostPreview'; import EventPost from './components/EventPost'; import DiscussionHero from './components/DiscussionHero'; import PostMeta from './components/PostMeta'; -import EditUserModal from './components/EditUserModal'; import SearchSource from './components/SearchSource'; import DiscussionRenamedPost from './components/DiscussionRenamedPost'; import DiscussionComposer from './components/DiscussionComposer'; @@ -70,6 +69,10 @@ import Search from './components/Search'; import DiscussionListItem from './components/DiscussionListItem'; import LoadingPost from './components/LoadingPost'; import PostsUserPage from './components/PostsUserPage'; +/** + * @deprecated + */ +import EditUserModal from '../common/components/EditUserModal'; import DiscussionPageResolver from './resolvers/DiscussionPageResolver'; import BasicEditorDriver from '../common/utils/BasicEditorDriver'; import routes from './routes'; @@ -128,6 +131,9 @@ export default Object.assign(compat, { 'components/EventPost': EventPost, 'components/DiscussionHero': DiscussionHero, 'components/PostMeta': PostMeta, + /** + * @deprecated Used for backwards compatibility now that the EditUserModal has moved to common. Remove in beta 17. + */ 'components/EditUserModal': EditUserModal, 'components/SearchSource': SearchSource, 'components/DiscussionRenamedPost': DiscussionRenamedPost, diff --git a/js/src/forum/utils/UserControls.js b/js/src/forum/utils/UserControls.js index 8841df6a4..b4512e3dc 100644 --- a/js/src/forum/utils/UserControls.js +++ b/js/src/forum/utils/UserControls.js @@ -1,6 +1,6 @@ import Button from '../../common/components/Button'; import Separator from '../../common/components/Separator'; -import EditUserModal from '../components/EditUserModal'; +import EditUserModal from '../../common/components/EditUserModal'; import UserPage from '../components/UserPage'; import ItemList from '../../common/utils/ItemList'; diff --git a/less/admin.less b/less/admin.less index 22e4d39c2..772207002 100644 --- a/less/admin.less +++ b/less/admin.less @@ -10,3 +10,4 @@ @import "admin/ExtensionWidget"; @import "admin/AppearancePage"; @import "admin/MailPage"; +@import "admin/UsersListPage.less"; diff --git a/less/admin/UsersListPage.less b/less/admin/UsersListPage.less new file mode 100644 index 000000000..d9d2b26d4 --- /dev/null +++ b/less/admin/UsersListPage.less @@ -0,0 +1,125 @@ +.UserListPage { + // Pad bottom of page to make nav area look less squashed + padding-bottom: 24px; + + &-grid { + width: 100%; + position: relative; + border-radius: @border-radius; + + // Use CSS custom properties to define the number of columns in the grid + grid-template-columns: repeat(var(--columns), max-content); + + // Ensure mobile scrollbar isn't on top of content + padding-bottom: 4px; + + // Table refreshing overlay + &--loadingPage { + &::after { + content: ""; + display: block; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background: rgba(128, 128, 128, 0.2); + } + + .LoadingIndicator-container { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } + } + + &--loaded, + &--loadingPage { + display: grid; + overflow-x: auto; + } + + &-header { + font-weight: bold; + border-bottom: 1px solid @muted-more-color; + padding: 8px 16px; + background: @control-bg; + } + + &--rowItem { + padding: 4px 16px; + display: flex; + align-items: center; + + &[data-column-name="editUser"] { + padding: 0; + position: relative; + } + } + + &--shadedRow { + background: darken(@body-bg, 3%); + + & when (@config-dark-mode = true) { + background: lighten(@body-bg, 5%); + } + } + } + + &-gridPagination { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 16px; + } +} + +// Handles styling of default UserList columns +.UserList { + &-joinDate { + cursor: help; + text-decoration: underline; + text-decoration-style: dotted; + } + + &-editModalBtn { + width: 100%; + height: 100%; + + border-radius: 0; + padding: 0; + + display: flex; + align-items: center; + justify-content: center; + } + + &-email { + display: flex; + flex-grow: 1; + + &[data-email-shown="false"] { + .UserList-emailAddress { + user-select: none; + filter: blur(4px); + cursor: pointer; + } + } + + &Address { + flex-grow: 1; + margin-right: 4px; + transition: filter 0.2s ease-out; + } + + &IconBtn { + margin-left: 12px; + + &:hover { + text-decoration: none; + } + } + } +} diff --git a/less/forum/EditUserModal.less b/less/common/EditUserModal.less similarity index 100% rename from less/forum/EditUserModal.less rename to less/common/EditUserModal.less diff --git a/less/common/common.less b/less/common/common.less index 6d126bc29..6b530e0c0 100644 --- a/less/common/common.less +++ b/less/common/common.less @@ -17,6 +17,7 @@ @import "Button"; @import "Checkbox"; @import "Dropdown"; +@import "EditUserModal"; @import "Form"; @import "FormControl"; @import "LoadingIndicator"; diff --git a/less/forum.less b/less/forum.less index 70e4532c7..16c1c1604 100644 --- a/less/forum.less +++ b/less/forum.less @@ -7,7 +7,6 @@ @import "forum/DiscussionList"; @import "forum/DiscussionListItem"; @import "forum/DiscussionPage"; -@import "forum/EditUserModal"; @import "forum/Hero"; @import "forum/IndexPage"; @import "forum/LogInButton"; diff --git a/locale/core.yml b/locale/core.yml index 8ead6d66d..d34bfbc76 100644 --- a/locale/core.yml +++ b/locale/core.yml @@ -163,6 +163,8 @@ core: email_title: => core.admin.email.description permissions_button: => core.admin.permissions.title permissions_title: => core.admin.permissions.description + userlist_button: => core.admin.users.title + userlist_title: => core.admin.users.description search_placeholder: Search Extensions # These translations are used in the Permissions page of the admin interface. @@ -217,6 +219,46 @@ core: remove_button: => core.ref.remove upload_button: Choose an Image... + # These translations are used for the users list on the admin dashboard. + users: + description: A paginated list of all users on your forum. + + grid: + columns: + edit_user: + button: => core.ref.edit + title: => core.ref.edit_user + tooltip: Edit {username} + + email: + title: => core.ref.email + visibility_hide: Hide email address + visibility_show: Show email address + + group_badges: + no_badges: None + title: Groups + + join_time: + title: Joined + + user_id: + title: ID + + username: + profile_link_tooltip: Visit {username}'s profile + title: => core.ref.username + + invalid_column_content: Invalid + + pagination: + back_button: Previous page + next_button: Next page + page_counter: Page {current} of {total} + + title: => core.ref.users + total_users: "Total users: {count}" + # Translations in this namespace are used by the forum user interface. forum: @@ -288,21 +330,6 @@ core: replied_text: "{username} replied {ago}" started_text: "{username} started {ago}" - # These translations are used in the Edit User modal dialog (admin function). - edit_user: - activate_button: Activate User - email_heading: => core.ref.email - email_label: => core.ref.email - groups_heading: Groups - nothing_available: There is nothing available for you to edit at this time. - password_heading: => core.ref.password - password_label: => core.ref.password - set_password_label: Set new password - submit_button: => core.ref.save_changes - title: Edit User - username_heading: => core.ref.username - username_label: => core.ref.username - # These translations are used in the Forgot Password modal dialog. forgot_password: dismiss_button: => core.ref.okay @@ -474,6 +501,20 @@ core: dropdown: toggle_dropdown_accessible_label: Toggle dropdown menu + # These translations are used in the Edit User modal dialog (admin function). + edit_user: + activate_button: Activate User + email_heading: => core.ref.email + email_label: => core.ref.email + groups_heading: Groups + password_heading: => core.ref.password + password_label: => core.ref.password + set_password_label: Set new password + submit_button: => core.ref.save_changes + title: => core.ref.edit_user + username_heading: => core.ref.username + username_label: => core.ref.username + # These translations are displayed as error messages. error: dependent_extensions_message: "Cannot disable {extension} until the following dependent extensions are disabled: {extensions}" @@ -624,6 +665,7 @@ core: delete_forever: Delete Forever discussions: Discussions # Referenced by flarum-statistics.yml edit: Edit + edit_user: Edit User email: Email icon: Icon icon_text: "Enter the name of any FontAwesome icon class, including the fas fa- prefix." diff --git a/src/Admin/Content/AdminPayload.php b/src/Admin/Content/AdminPayload.php index 21347271e..8c4237c8e 100644 --- a/src/Admin/Content/AdminPayload.php +++ b/src/Admin/Content/AdminPayload.php @@ -14,6 +14,7 @@ use Flarum\Frontend\Document; use Flarum\Group\Permission; use Flarum\Settings\Event\Deserializing; use Flarum\Settings\SettingsRepositoryInterface; +use Flarum\User\User; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Events\Dispatcher; use Illuminate\Database\ConnectionInterface; @@ -81,5 +82,18 @@ class AdminPayload $document->payload['phpVersion'] = PHP_VERSION; $document->payload['mysqlVersion'] = $this->db->selectOne('select version() as version')->version; + + /** + * Used in the admin user list. Implemented as this as it matches the API in flarum/statistics. + * If flarum/statistics ext is enabled, it will override this data with its own stats. + * + * This allows the front-end code to be simpler and use one single source of truth to pull the + * total user count from. + */ + $document->payload['modelStatistics'] = [ + 'users' => [ + 'total' => User::count() + ] + ]; } }