feat: add user creation to users list page (#3744)

This commit is contained in:
David Wheatley 2023-05-07 18:38:37 +01:00 committed by GitHub
parent 9363682e1c
commit 21b483625e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 360 additions and 23 deletions

View File

@ -35,6 +35,7 @@ import EditGroupModal from './components/EditGroupModal';
import routes from './routes';
import AdminApplication from './AdminApplication';
import generateElementId from './utils/generateElementId';
import CreateUserModal from './components/CreateUserModal';
export default Object.assign(compat, {
'utils/saveSettings': saveSettings,
@ -70,6 +71,7 @@ export default Object.assign(compat, {
'components/AdminHeader': AdminHeader,
'components/EditCustomCssModal': EditCustomCssModal,
'components/EditGroupModal': EditGroupModal,
'components/CreateUserModal': CreateUserModal,
routes: routes,
AdminApplication: AdminApplication,
});

View 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('');
}
}

View File

@ -1,4 +1,4 @@
import type Mithril from 'mithril';
import Mithril from 'mithril';
import app from '../../admin/app';
@ -17,6 +17,7 @@ import classList from '../../common/utils/classList';
import extractText from '../../common/utils/extractText';
import AdminPage from './AdminPage';
import { debounce } from '../../common/utils/throttleDebounce';
import CreateUserModal from './CreateUserModal';
type ColumnData = {
/**
@ -116,19 +117,7 @@ export default class UserListPage extends AdminPage {
const columns = this.columns().toArray();
return [
<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>,
<p className="UserListPage-totalUsers">{app.translator.trans('core.admin.users.total_users', { count: this.userCount })}</p>,
<div className="UserListPage-header">{this.headerItems().toArray()}</div>,
<section
className={classList(['UserListPage-grid', this.isLoadingPage ? 'UserListPage-grid--loadingPage' : 'UserListPage-grid--loaded'])}
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.
*

View File

@ -79,3 +79,23 @@ export function ucfirst(string: string): string {
export function camelCaseToSnakeCase(str: string): string {
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('');
}

View File

@ -2,6 +2,7 @@
@import "admin/AdminHeader";
@import "admin/AdminNav";
@import "admin/CreateUserModal";
@import "admin/DashboardPage";
@import "admin/DebugWarningWidget";
@import "admin/BasicsPage";

View File

@ -0,0 +1,6 @@
.CreateUserModal {
&-bulkAdd {
margin-top: 32px;
margin-bottom: 24px;
}
}

View File

@ -2,6 +2,20 @@
// Pad bottom of page to make nav area look less squashed
padding-bottom: 24px;
&-header {
margin-bottom: 16px;
}
&-actions {
display: flex;
align-items: center;
justify-content: space-between;
* + * {
margin-left: 8px;
}
}
&-grid {
width: 100%;
position: relative;

View File

@ -52,6 +52,17 @@ core:
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.
# 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.
dashboard:
clear_cache_button: Clear Cache
@ -250,6 +261,7 @@ core:
# These translations are used for the users list on the admin dashboard.
users:
create_user_button: New User
description: A paginated list of all users on your forum.
grid:
@ -785,7 +797,7 @@ core:
all_discussions: All Discussions
change_email: Change Email
change_password: Change Password
color: Color # Referenced by flarum-tags.yml
color: Color # Referenced by flarum-tags.yml
confirm_password: Confirm Password
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."
@ -795,7 +807,7 @@ core:
custom_header_title: Edit Custom Header
delete: Delete
delete_forever: Delete Forever
discussions: Discussions # Referenced by flarum-statistics.yml
discussions: Discussions # Referenced by flarum-statistics.yml
edit: Edit
edit_user: Edit User
email: Email
@ -812,27 +824,27 @@ core:
new_token: New Token
next_page: Next Page
notifications: Notifications
okay: OK # Referenced by flarum-tags.yml
okay: OK # Referenced by flarum-tags.yml
password: Password
posts: Posts # Referenced by flarum-statistics.yml
posts: Posts # Referenced by flarum-statistics.yml
previous_page: Previous Page
remove: Remove
rename: Rename
reply: Reply # Referenced by flarum-mentions.yml
reply: Reply # Referenced by flarum-mentions.yml
reset_your_password: Reset Your Password
restore: Restore
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
settings: Settings
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
username: Username
users: Users # Referenced by flarum-statistics.yml
users: Users # Referenced by flarum-statistics.yml
view: View
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.