mirror of
https://github.com/flarum/core.git
synced 2025-08-04 23:47:32 +02:00
feat: add user creation to users list page (#3744)
This commit is contained in:
@@ -35,6 +35,7 @@ import EditGroupModal from './components/EditGroupModal';
|
|||||||
import routes from './routes';
|
import routes from './routes';
|
||||||
import AdminApplication from './AdminApplication';
|
import AdminApplication from './AdminApplication';
|
||||||
import generateElementId from './utils/generateElementId';
|
import generateElementId from './utils/generateElementId';
|
||||||
|
import CreateUserModal from './components/CreateUserModal';
|
||||||
|
|
||||||
export default Object.assign(compat, {
|
export default Object.assign(compat, {
|
||||||
'utils/saveSettings': saveSettings,
|
'utils/saveSettings': saveSettings,
|
||||||
@@ -70,6 +71,7 @@ export default Object.assign(compat, {
|
|||||||
'components/AdminHeader': AdminHeader,
|
'components/AdminHeader': AdminHeader,
|
||||||
'components/EditCustomCssModal': EditCustomCssModal,
|
'components/EditCustomCssModal': EditCustomCssModal,
|
||||||
'components/EditGroupModal': EditGroupModal,
|
'components/EditGroupModal': EditGroupModal,
|
||||||
|
'components/CreateUserModal': CreateUserModal,
|
||||||
routes: routes,
|
routes: routes,
|
||||||
AdminApplication: AdminApplication,
|
AdminApplication: AdminApplication,
|
||||||
});
|
});
|
||||||
|
248
framework/core/js/src/admin/components/CreateUserModal.tsx
Normal file
248
framework/core/js/src/admin/components/CreateUserModal.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import app from '../../admin/app';
|
||||||
|
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
|
||||||
|
import Button from '../../common/components/Button';
|
||||||
|
import extractText from '../../common/utils/extractText';
|
||||||
|
import ItemList from '../../common/utils/ItemList';
|
||||||
|
import Stream from '../../common/utils/Stream';
|
||||||
|
import type Mithril from 'mithril';
|
||||||
|
import Switch from '../../common/components/Switch';
|
||||||
|
import { generateRandomString } from '../../common/utils/string';
|
||||||
|
|
||||||
|
export interface ICreateUserModalAttrs extends IInternalModalAttrs {
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
token?: string;
|
||||||
|
provided?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SignupBody = {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
isEmailConfirmed: boolean;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class CreateUserModal<CustomAttrs extends ICreateUserModalAttrs = ICreateUserModalAttrs> extends Modal<CustomAttrs> {
|
||||||
|
/**
|
||||||
|
* The value of the username input.
|
||||||
|
*/
|
||||||
|
username!: Stream<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value of the email input.
|
||||||
|
*/
|
||||||
|
email!: Stream<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value of the password input.
|
||||||
|
*/
|
||||||
|
password!: Stream<string | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether email confirmation is required after signing in.
|
||||||
|
*/
|
||||||
|
requireEmailConfirmation!: Stream<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the modal open after the user is created to facilitate creating
|
||||||
|
* multiple users at once.
|
||||||
|
*/
|
||||||
|
bulkAdd!: Stream<boolean>;
|
||||||
|
|
||||||
|
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
|
||||||
|
super.oninit(vnode);
|
||||||
|
|
||||||
|
this.username = Stream('');
|
||||||
|
this.email = Stream('');
|
||||||
|
this.password = Stream<string | null>('');
|
||||||
|
this.requireEmailConfirmation = Stream(false);
|
||||||
|
this.bulkAdd = Stream(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
className() {
|
||||||
|
return 'Modal--small CreateUserModal';
|
||||||
|
}
|
||||||
|
|
||||||
|
title() {
|
||||||
|
return app.translator.trans('core.admin.create_user.title');
|
||||||
|
}
|
||||||
|
|
||||||
|
content() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="Modal-body">{this.body()}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
body() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="Form Form--centered">{this.fields().toArray()}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fields() {
|
||||||
|
const items = new ItemList();
|
||||||
|
|
||||||
|
const usernameLabel = extractText(app.translator.trans('core.admin.create_user.username_placeholder'));
|
||||||
|
const emailLabel = extractText(app.translator.trans('core.admin.create_user.email_placeholder'));
|
||||||
|
const emailConfirmationLabel = extractText(app.translator.trans('core.admin.create_user.email_confirmed_label'));
|
||||||
|
const useRandomPasswordLabel = extractText(app.translator.trans('core.admin.create_user.use_random_password'));
|
||||||
|
const passwordLabel = extractText(app.translator.trans('core.admin.create_user.password_placeholder'));
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
'username',
|
||||||
|
<div className="Form-group">
|
||||||
|
<input
|
||||||
|
className="FormControl"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
placeholder={usernameLabel}
|
||||||
|
aria-label={usernameLabel}
|
||||||
|
bidi={this.username}
|
||||||
|
disabled={this.loading}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
100
|
||||||
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
'email',
|
||||||
|
<div className="Form-group">
|
||||||
|
<input
|
||||||
|
className="FormControl"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder={emailLabel}
|
||||||
|
aria-label={emailLabel}
|
||||||
|
bidi={this.email}
|
||||||
|
disabled={this.loading}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
80
|
||||||
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
'password',
|
||||||
|
<div className="Form-group">
|
||||||
|
<input
|
||||||
|
className="FormControl"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder={passwordLabel}
|
||||||
|
aria-label={passwordLabel}
|
||||||
|
bidi={this.password}
|
||||||
|
disabled={this.loading || this.password() === null}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
60
|
||||||
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
'emailConfirmation',
|
||||||
|
<div className="Form-group">
|
||||||
|
<Switch
|
||||||
|
name="emailConfirmed"
|
||||||
|
state={this.requireEmailConfirmation()}
|
||||||
|
onchange={(checked: boolean) => this.requireEmailConfirmation(checked)}
|
||||||
|
disabled={this.loading}
|
||||||
|
>
|
||||||
|
{emailConfirmationLabel}
|
||||||
|
</Switch>
|
||||||
|
</div>,
|
||||||
|
40
|
||||||
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
'useRandomPassword',
|
||||||
|
<div className="Form-group">
|
||||||
|
<Switch
|
||||||
|
name="useRandomPassword"
|
||||||
|
state={this.password() === null}
|
||||||
|
onchange={(enabled: boolean) => {
|
||||||
|
this.password(enabled ? null : '');
|
||||||
|
}}
|
||||||
|
disabled={this.loading}
|
||||||
|
>
|
||||||
|
{useRandomPasswordLabel}
|
||||||
|
</Switch>
|
||||||
|
</div>,
|
||||||
|
20
|
||||||
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
'submit',
|
||||||
|
<div className="Form-group">
|
||||||
|
<Button className="Button Button--primary Button--block" type="submit" loading={this.loading}>
|
||||||
|
{app.translator.trans('core.admin.create_user.submit_button')}
|
||||||
|
</Button>
|
||||||
|
</div>,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
'submitAndAdd',
|
||||||
|
<div className="Form-group">
|
||||||
|
<Button className="Button Button--block" onclick={() => this.bulkAdd(true) && this.onsubmit()} disabled={this.loading}>
|
||||||
|
{app.translator.trans('core.admin.create_user.submit_and_create_another_button')}
|
||||||
|
</Button>
|
||||||
|
</div>,
|
||||||
|
-20
|
||||||
|
);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
onready() {
|
||||||
|
this.$('[name=username]').trigger('select');
|
||||||
|
}
|
||||||
|
|
||||||
|
onsubmit(e: SubmitEvent | null = null) {
|
||||||
|
e?.preventDefault();
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
|
||||||
|
app
|
||||||
|
.request({
|
||||||
|
url: app.forum.attribute('apiUrl') + '/users',
|
||||||
|
method: 'POST',
|
||||||
|
body: { data: { attributes: this.submitData() } },
|
||||||
|
errorHandler: this.onerror.bind(this),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
if (this.bulkAdd()) {
|
||||||
|
this.resetData();
|
||||||
|
} else {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.bulkAdd(false);
|
||||||
|
this.loaded();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the data that should be submitted in the sign-up request.
|
||||||
|
*/
|
||||||
|
submitData(): SignupBody {
|
||||||
|
const data = {
|
||||||
|
username: this.username(),
|
||||||
|
email: this.email(),
|
||||||
|
isEmailConfirmed: !this.requireEmailConfirmation(),
|
||||||
|
password: this.password() ?? generateRandomString(32),
|
||||||
|
};
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetData() {
|
||||||
|
this.username('');
|
||||||
|
this.email('');
|
||||||
|
this.password('');
|
||||||
|
}
|
||||||
|
}
|
@@ -1,4 +1,4 @@
|
|||||||
import type Mithril from 'mithril';
|
import Mithril from 'mithril';
|
||||||
|
|
||||||
import app from '../../admin/app';
|
import app from '../../admin/app';
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ import classList from '../../common/utils/classList';
|
|||||||
import extractText from '../../common/utils/extractText';
|
import extractText from '../../common/utils/extractText';
|
||||||
import AdminPage from './AdminPage';
|
import AdminPage from './AdminPage';
|
||||||
import { debounce } from '../../common/utils/throttleDebounce';
|
import { debounce } from '../../common/utils/throttleDebounce';
|
||||||
|
import CreateUserModal from './CreateUserModal';
|
||||||
|
|
||||||
type ColumnData = {
|
type ColumnData = {
|
||||||
/**
|
/**
|
||||||
@@ -116,19 +117,7 @@ export default class UserListPage extends AdminPage {
|
|||||||
const columns = this.columns().toArray();
|
const columns = this.columns().toArray();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
<div className="Search-input">
|
<div className="UserListPage-header">{this.headerItems().toArray()}</div>,
|
||||||
<input
|
|
||||||
className="FormControl SearchBar"
|
|
||||||
type="search"
|
|
||||||
placeholder={app.translator.trans('core.admin.users.search_placeholder')}
|
|
||||||
oninput={(e: InputEvent) => {
|
|
||||||
this.isLoadingPage = true;
|
|
||||||
this.query = (e?.target as HTMLInputElement)?.value;
|
|
||||||
this.throttledSearch();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>,
|
|
||||||
<p className="UserListPage-totalUsers">{app.translator.trans('core.admin.users.total_users', { count: this.userCount })}</p>,
|
|
||||||
<section
|
<section
|
||||||
className={classList(['UserListPage-grid', this.isLoadingPage ? 'UserListPage-grid--loadingPage' : 'UserListPage-grid--loaded'])}
|
className={classList(['UserListPage-grid', this.isLoadingPage ? 'UserListPage-grid--loadingPage' : 'UserListPage-grid--loaded'])}
|
||||||
style={{ '--columns': columns.length }}
|
style={{ '--columns': columns.length }}
|
||||||
@@ -243,6 +232,51 @@ export default class UserListPage extends AdminPage {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
headerItems(): ItemList<Mithril.Children> {
|
||||||
|
const items = new ItemList<Mithril.Children>();
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
'search',
|
||||||
|
<div className="Search-input">
|
||||||
|
<input
|
||||||
|
className="FormControl SearchBar"
|
||||||
|
type="search"
|
||||||
|
placeholder={app.translator.trans('core.admin.users.search_placeholder')}
|
||||||
|
oninput={(e: InputEvent) => {
|
||||||
|
this.isLoadingPage = true;
|
||||||
|
this.query = (e?.target as HTMLInputElement)?.value;
|
||||||
|
this.throttledSearch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
100
|
||||||
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
'totalUsers',
|
||||||
|
<p class="UserListPage-totalUsers">{app.translator.trans('core.admin.users.total_users', { count: this.userCount })}</p>,
|
||||||
|
90
|
||||||
|
);
|
||||||
|
|
||||||
|
items.add('actions', <div className="UserListPage-actions">{this.actionItems().toArray()}</div>, 80);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionItems(): ItemList<Mithril.Children> {
|
||||||
|
const items = new ItemList<Mithril.Children>();
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
'createUser',
|
||||||
|
<Button className="Button UserListPage-createUserBtn" icon="fas fa-user-plus" onclick={() => app.modal.show(CreateUserModal)}>
|
||||||
|
{app.translator.trans('core.admin.users.create_user_button')}
|
||||||
|
</Button>,
|
||||||
|
100
|
||||||
|
);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an item list of columns to show for each user.
|
* Build an item list of columns to show for each user.
|
||||||
*
|
*
|
||||||
|
@@ -79,3 +79,23 @@ export function ucfirst(string: string): string {
|
|||||||
export function camelCaseToSnakeCase(str: string): string {
|
export function camelCaseToSnakeCase(str: string): string {
|
||||||
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random string (a-z, 0-9) of a given length.
|
||||||
|
*
|
||||||
|
* Providing a length of less than 0 will result in an error.
|
||||||
|
*
|
||||||
|
* @param length Length of the random string to generate
|
||||||
|
* @returns A random string of provided length
|
||||||
|
*/
|
||||||
|
export function generateRandomString(length: number): string {
|
||||||
|
if (length < 0) throw new Error('Cannot generate a random string with length less than 0.');
|
||||||
|
if (length === 0) return '';
|
||||||
|
|
||||||
|
const arr = new Uint8Array(length / 2);
|
||||||
|
window.crypto.getRandomValues(arr);
|
||||||
|
|
||||||
|
return Array.from(arr, (dec) => {
|
||||||
|
return dec.toString(16).padStart(2, '0');
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
@import "admin/AdminHeader";
|
@import "admin/AdminHeader";
|
||||||
@import "admin/AdminNav";
|
@import "admin/AdminNav";
|
||||||
|
@import "admin/CreateUserModal";
|
||||||
@import "admin/DashboardPage";
|
@import "admin/DashboardPage";
|
||||||
@import "admin/DebugWarningWidget";
|
@import "admin/DebugWarningWidget";
|
||||||
@import "admin/BasicsPage";
|
@import "admin/BasicsPage";
|
||||||
|
6
framework/core/less/admin/CreateUserModal.less
Normal file
6
framework/core/less/admin/CreateUserModal.less
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.CreateUserModal {
|
||||||
|
&-bulkAdd {
|
||||||
|
margin-top: 32px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
}
|
@@ -2,6 +2,20 @@
|
|||||||
// Pad bottom of page to make nav area look less squashed
|
// Pad bottom of page to make nav area look less squashed
|
||||||
padding-bottom: 24px;
|
padding-bottom: 24px;
|
||||||
|
|
||||||
|
&-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
* + * {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&-grid {
|
&-grid {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@@ -52,6 +52,17 @@ core:
|
|||||||
welcome_banner_heading: Welcome Banner
|
welcome_banner_heading: Welcome Banner
|
||||||
welcome_banner_text: Configure the text that displays in the banner on the All Discussions page. Use this to welcome guests to your forum.
|
welcome_banner_text: Configure the text that displays in the banner on the All Discussions page. Use this to welcome guests to your forum.
|
||||||
|
|
||||||
|
# These translations are used in the Create User modal.
|
||||||
|
create_user:
|
||||||
|
email_placeholder: => core.ref.email
|
||||||
|
email_confirmed_label: Require user to confirm this email
|
||||||
|
password_placeholder: => core.ref.password
|
||||||
|
submit_and_create_another_button: Create and add another
|
||||||
|
submit_button: Create user
|
||||||
|
title: Create new user
|
||||||
|
use_random_password: Generate random password
|
||||||
|
username_placeholder: => core.ref.username
|
||||||
|
|
||||||
# These translations are used in the Dashboard page.
|
# These translations are used in the Dashboard page.
|
||||||
dashboard:
|
dashboard:
|
||||||
clear_cache_button: Clear Cache
|
clear_cache_button: Clear Cache
|
||||||
@@ -250,6 +261,7 @@ core:
|
|||||||
|
|
||||||
# These translations are used for the users list on the admin dashboard.
|
# These translations are used for the users list on the admin dashboard.
|
||||||
users:
|
users:
|
||||||
|
create_user_button: New User
|
||||||
description: A paginated list of all users on your forum.
|
description: A paginated list of all users on your forum.
|
||||||
|
|
||||||
grid:
|
grid:
|
||||||
@@ -785,7 +797,7 @@ core:
|
|||||||
all_discussions: All Discussions
|
all_discussions: All Discussions
|
||||||
change_email: Change Email
|
change_email: Change Email
|
||||||
change_password: Change Password
|
change_password: Change Password
|
||||||
color: Color # Referenced by flarum-tags.yml
|
color: Color # Referenced by flarum-tags.yml
|
||||||
confirm_password: Confirm Password
|
confirm_password: Confirm Password
|
||||||
confirm_email: Confirm Email
|
confirm_email: Confirm Email
|
||||||
confirmation_email_sent: "We've sent a confirmation email to {email}. If it doesn't arrive soon, check your spam folder."
|
confirmation_email_sent: "We've sent a confirmation email to {email}. If it doesn't arrive soon, check your spam folder."
|
||||||
@@ -795,7 +807,7 @@ core:
|
|||||||
custom_header_title: Edit Custom Header
|
custom_header_title: Edit Custom Header
|
||||||
delete: Delete
|
delete: Delete
|
||||||
delete_forever: Delete Forever
|
delete_forever: Delete Forever
|
||||||
discussions: Discussions # Referenced by flarum-statistics.yml
|
discussions: Discussions # Referenced by flarum-statistics.yml
|
||||||
edit: Edit
|
edit: Edit
|
||||||
edit_user: Edit User
|
edit_user: Edit User
|
||||||
email: Email
|
email: Email
|
||||||
@@ -812,27 +824,27 @@ core:
|
|||||||
new_token: New Token
|
new_token: New Token
|
||||||
next_page: Next Page
|
next_page: Next Page
|
||||||
notifications: Notifications
|
notifications: Notifications
|
||||||
okay: OK # Referenced by flarum-tags.yml
|
okay: OK # Referenced by flarum-tags.yml
|
||||||
password: Password
|
password: Password
|
||||||
posts: Posts # Referenced by flarum-statistics.yml
|
posts: Posts # Referenced by flarum-statistics.yml
|
||||||
previous_page: Previous Page
|
previous_page: Previous Page
|
||||||
remove: Remove
|
remove: Remove
|
||||||
rename: Rename
|
rename: Rename
|
||||||
reply: Reply # Referenced by flarum-mentions.yml
|
reply: Reply # Referenced by flarum-mentions.yml
|
||||||
reset_your_password: Reset Your Password
|
reset_your_password: Reset Your Password
|
||||||
restore: Restore
|
restore: Restore
|
||||||
save_changes: Save Changes
|
save_changes: Save Changes
|
||||||
search_users: Search users # Referenced by flarum-suspend.yml, flarum-tags.yml
|
search_users: Search users # Referenced by flarum-suspend.yml, flarum-tags.yml
|
||||||
security: Security
|
security: Security
|
||||||
settings: Settings
|
settings: Settings
|
||||||
sign_up: Sign Up
|
sign_up: Sign Up
|
||||||
some_others: "{count, plural, one {# other} other {# others}}" # Referenced by flarum-likes.yml, flarum-mentions.yml
|
some_others: "{count, plural, one {# other} other {# others}}" # Referenced by flarum-likes.yml, flarum-mentions.yml
|
||||||
start_a_discussion: Start a Discussion
|
start_a_discussion: Start a Discussion
|
||||||
username: Username
|
username: Username
|
||||||
users: Users # Referenced by flarum-statistics.yml
|
users: Users # Referenced by flarum-statistics.yml
|
||||||
view: View
|
view: View
|
||||||
write_a_reply: Write a Reply...
|
write_a_reply: Write a Reply...
|
||||||
you: You # Referenced by flarum-likes.yml, flarum-mentions.yml
|
you: You # Referenced by flarum-likes.yml, flarum-mentions.yml
|
||||||
|
|
||||||
##
|
##
|
||||||
# GROUP NAMES - These keys are translated at the back end.
|
# GROUP NAMES - These keys are translated at the back end.
|
||||||
|
Reference in New Issue
Block a user