mirror of
https://github.com/flarum/framework.git
synced 2025-01-19 07:42:48 +08:00
Add users list to admin dashboard (#2626)
* Commit initial WIP code
* Fix squashed grid on mobile
* Add pagination support; rename to userList
* Improve grid sizing
* Improve grid row shading
* Move EditUserModal to common
* Add link to profile page in grid
* Use Less styling vars
* Move EditUserModal translations to lib
* Add edit user button to grid
* Fix incorrect profile link priority
* Update profile link translation key
* Add priorities to other columns
* Add group badges to grid
* Add username to profile link tooltip
* Organise imports
* Use variable for header border bottom color
* Fix broken export
* Add total user count to API payload's metadata
* Add new metadata to ApiPayload type
* Implement correct page number
* Remove debug code
* Use function to get the total pages
This allows us to use the raw count elsewhere in the component (pssst... check the next commit!)
* Center profile link in column
* Add profile link header
* Show total users above table
* Use ItemList's itemName property for column data attributes
* Add user email column, hidden by default
This column is hidden by default using a placeholder email and blur filter. These are then removed when the visibility toggle is pressed.
This prevents any over-the-shoulder accidental data leakage, as emails are classed as PII under GDPR.
* Fix incorrect tooltip translation keys
* Add extra padding between email and visibility toggle button
* Prevent selection of blurred email
* Fix incorrect icon state for email toggle
* Update API response type to include metadata (for now)
* Increase number of users per page to 50
* Update compat files with new locations
* Format
* Add @deprecated notices for forum compat export
* Use AdminPayload for user count instead of supplying as REST API metadata
* Make nav look less squashed using bottom margin
* Suppress TS warning
* StyleCI fixes
* Fix TS error
* Update based on review comments
* Rename user list -> users
* Rename internal instances of user_list to users
* Fix formatting
* Use CSS custom properties for the table column count
* Use .Button--icon instead of custom style
* Make fake email more realistic length
* Add a11y attributes
* Use padding bottom instead of margin bottom for page spacing
* Make compatible with new CSS LoadingIndicator
I won't let it break here! :P
* Integrate profile link into username column
* Don't force columns to be 300px
This made the grid look very bloated and intimidating -- lets instead increase the padding between items and make it only the width it needs to be.
* Center edit user button in column
* Increase spacing between email and visibility toggle button
* Rename `statistics` to `modelStatistics` in Admin payload
This prevents any possible conflicts with core and `flarum/statistics`. We might want to consider migrating the stats extension to extend this object in the future.
* Update comments, fix TS error
* Various translation key changes
* Change gmail.com -> example.com
* Stretch 'edit user' button to entire cell size
* Update translations
* Is the YAML formatted right this time? 🙈
* Remove email placeholder
Fixes an issue where the table would jump if an email was unhidden that was longer than the placeholder.
* Re-order lib translations
* Clicking blurred email now unblurs
* Correct header class
* Improve edit user button centring
* Improve vertical row item centering
* Fix incorrect column length in aria attribute
* Use .Button--text!
This commit is contained in:
parent
43d6b3104d
commit
f9779284e4
|
@ -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,
|
||||
|
|
|
@ -94,6 +94,13 @@ export default class AdminNav extends Component {
|
|||
</LinkButton>
|
||||
);
|
||||
|
||||
items.add(
|
||||
'userList',
|
||||
<LinkButton href={app.route('users')} icon="fas fa-users" title={app.translator.trans('core.admin.nav.userlist_title')}>
|
||||
{app.translator.trans('core.admin.nav.userlist_button')}
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
items.add(
|
||||
'search',
|
||||
<div className="Search-input">
|
||||
|
|
384
js/src/admin/components/UserListPage.tsx
Normal file
384
js/src/admin/components/UserListPage.tsx
Normal file
|
@ -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<string, unknown>[];
|
||||
included: Record<string, unknown>[];
|
||||
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 [
|
||||
<section class="UserListPage-grid UserListPage-grid--loading">
|
||||
<LoadingIndicator containerClassName="LoadingIndicator--block" size="large" />
|
||||
</section>,
|
||||
];
|
||||
}
|
||||
|
||||
const columns: (ColumnData & { itemName: string })[] = this.columns().toArray();
|
||||
|
||||
return [
|
||||
<p class="UserListPage-totalUsers">{app.translator.trans('core.admin.users.total_users', { count: this.userCount })}</p>,
|
||||
<section
|
||||
class={classList(['UserListPage-grid', this.isLoadingPage ? 'UserListPage-grid--loadingPage' : 'UserListPage-grid--loaded'])}
|
||||
style={{ '--columns': columns.length }}
|
||||
role="table"
|
||||
// +1 to account for header
|
||||
aria-rowcount={this.pageData.length + 1}
|
||||
aria-colcount={columns.length}
|
||||
aria-live="polite"
|
||||
aria-busy={this.isLoadingPage ? 'true' : 'false'}
|
||||
>
|
||||
{/* Render columns */}
|
||||
{columns.map((column, colIndex) => (
|
||||
<div class="UserListPage-grid-header" role="columnheader" aria-colindex={colIndex + 1} aria-rowindex={1}>
|
||||
{column.name}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Render user data */}
|
||||
{this.pageData.map((user, rowIndex) =>
|
||||
columns.map((col, colIndex) => {
|
||||
const columnContent = col.content && col.content(user);
|
||||
|
||||
return (
|
||||
<div
|
||||
class={classList(['UserListPage-grid--rowItem', rowIndex % 2 > 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')}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Loading spinner that shows when a new page is being loaded */}
|
||||
{this.isLoadingPage && <LoadingIndicator size="large" />}
|
||||
</section>,
|
||||
<nav class="UserListPage-gridPagination">
|
||||
<Button
|
||||
disabled={this.pageNumber === 0}
|
||||
title={app.translator.trans('core.admin.users.pagination.back_button')}
|
||||
onclick={this.previousPage.bind(this)}
|
||||
icon="fas fa-chevron-left"
|
||||
className="Button Button--icon UserListPage-backBtn"
|
||||
/>
|
||||
<span class="UserListPage-pageNumber">
|
||||
{app.translator.trans('core.admin.users.pagination.page_counter', {
|
||||
current: this.pageNumber + 1,
|
||||
total: this.getTotalPageCount(),
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
disabled={!this.moreData}
|
||||
title={app.translator.trans('core.admin.users.pagination.next_button')}
|
||||
onclick={this.nextPage.bind(this)}
|
||||
icon="fas fa-chevron-right"
|
||||
className="Button Button--icon UserListPage-nextBtn"
|
||||
/>
|
||||
</nav>,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<a
|
||||
target="_blank"
|
||||
href={profileUrl}
|
||||
title={extractText(app.translator.trans('core.admin.users.grid.columns.username.profile_link_tooltip', { username: user.username() }))}
|
||||
>
|
||||
{user.username()}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
},
|
||||
90
|
||||
);
|
||||
|
||||
columns.add(
|
||||
'joinDate',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.join_time.title'),
|
||||
content: (user: User) => (
|
||||
<span class="UserList-joinDate" title={user.joinTime()}>
|
||||
{dayjs(user.joinTime()).format('LLL')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
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 <ul className="DiscussionHero-badges badges">{listItems(badges)}</ul>;
|
||||
} 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 (
|
||||
<div class="UserList-email" key={user.id()} data-email-shown="false">
|
||||
<span class="UserList-emailAddress" aria-hidden onclick={() => setEmailVisibility(true)}>
|
||||
{user.email()}
|
||||
</span>
|
||||
<button
|
||||
onclick={toggleEmailVisibility}
|
||||
class="Button Button--text UserList-emailIconBtn"
|
||||
title={app.translator.trans('core.admin.users.grid.columns.email.visibility_show')}
|
||||
>
|
||||
{icon('far fa-eye-slash fa-fw', { className: 'icon' })}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
70
|
||||
);
|
||||
|
||||
columns.add(
|
||||
'editUser',
|
||||
{
|
||||
name: app.translator.trans('core.admin.users.grid.columns.edit_user.title'),
|
||||
content: (user: User) => (
|
||||
<Button
|
||||
className="Button UserList-editModalBtn"
|
||||
title={app.translator.trans('core.admin.users.grid.columns.edit_user.tooltip', { username: user.username() })}
|
||||
onclick={() => app.modal.show(EditUserModal, { user })}
|
||||
>
|
||||
{app.translator.trans('core.admin.users.grid.columns.edit_user.button')}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
-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);
|
||||
}
|
||||
}
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 (
|
||||
<div className="Modal-body">
|
||||
{fields.length > 1 ? <div className="Form">{this.fields().toArray()}</div> : app.translator.trans('core.forum.edit_user.nothing_available')}
|
||||
{fields.length > 1 ? <div className="Form">{this.fields().toArray()}</div> : app.translator.trans('core.lib.edit_user.nothing_available')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -52,10 +52,10 @@ export default class EditUserModal extends Modal {
|
|||
items.add(
|
||||
'username',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.username_heading')}</label>
|
||||
<label>{app.translator.trans('core.lib.edit_user.username_heading')}</label>
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.username_label'))}
|
||||
placeholder={extractText(app.translator.trans('core.lib.edit_user.username_label'))}
|
||||
bidi={this.username}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
|
@ -67,11 +67,11 @@ export default class EditUserModal extends Modal {
|
|||
items.add(
|
||||
'email',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.email_heading')}</label>
|
||||
<label>{app.translator.trans('core.lib.edit_user.email_heading')}</label>
|
||||
<div>
|
||||
<input
|
||||
className="FormControl"
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.email_label'))}
|
||||
placeholder={extractText(app.translator.trans('core.lib.edit_user.email_label'))}
|
||||
bidi={this.email}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
|
@ -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')
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
@ -97,7 +97,7 @@ export default class EditUserModal extends Modal {
|
|||
items.add(
|
||||
'password',
|
||||
<div className="Form-group">
|
||||
<label>{app.translator.trans('core.forum.edit_user.password_heading')}</label>
|
||||
<label>{app.translator.trans('core.lib.edit_user.password_heading')}</label>
|
||||
<div>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
|
@ -110,14 +110,14 @@ export default class EditUserModal extends Modal {
|
|||
}}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
{app.translator.trans('core.forum.edit_user.set_password_label')}
|
||||
{app.translator.trans('core.lib.edit_user.set_password_label')}
|
||||
</label>
|
||||
{this.setPassword() ? (
|
||||
<input
|
||||
className="FormControl"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={extractText(app.translator.trans('core.forum.edit_user.password_label'))}
|
||||
placeholder={extractText(app.translator.trans('core.lib.edit_user.password_label'))}
|
||||
bidi={this.password}
|
||||
disabled={this.nonAdminEditingAdmin()}
|
||||
/>
|
||||
|
@ -135,7 +135,7 @@ export default class EditUserModal extends Modal {
|
|||
items.add(
|
||||
'groups',
|
||||
<div className="Form-group EditUserModal-groups">
|
||||
<label>{app.translator.trans('core.forum.edit_user.groups_heading')}</label>
|
||||
<label>{app.translator.trans('core.lib.edit_user.groups_heading')}</label>
|
||||
<div>
|
||||
{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')
|
||||
)}
|
||||
</div>,
|
||||
-10
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -10,3 +10,4 @@
|
|||
@import "admin/ExtensionWidget";
|
||||
@import "admin/AppearancePage";
|
||||
@import "admin/MailPage";
|
||||
@import "admin/UsersListPage.less";
|
||||
|
|
125
less/admin/UsersListPage.less
Normal file
125
less/admin/UsersListPage.less
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,6 +17,7 @@
|
|||
@import "Button";
|
||||
@import "Checkbox";
|
||||
@import "Dropdown";
|
||||
@import "EditUserModal";
|
||||
@import "Form";
|
||||
@import "FormControl";
|
||||
@import "LoadingIndicator";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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 <a>FontAwesome</a> icon class, <em>including</em> the <code>fas fa-</code> prefix."
|
||||
|
|
|
@ -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()
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue
Block a user