Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add user creation to users list page #3744

Merged
merged 6 commits into from
May 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions framework/core/js/src/admin/compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
248 changes: 248 additions & 0 deletions framework/core/js/src/admin/components/CreateUserModal.tsx
Original file line number Diff line number Diff line change
@@ -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('');
}
}
62 changes: 48 additions & 14 deletions framework/core/js/src/admin/components/UserListPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type Mithril from 'mithril';
import Mithril from 'mithril';

import app from '../../admin/app';

Expand All @@ -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 = {
/**
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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.
*
Expand Down
20 changes: 20 additions & 0 deletions framework/core/js/src/common/utils/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
}
1 change: 1 addition & 0 deletions framework/core/less/admin.less
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

@import "admin/AdminHeader";
@import "admin/AdminNav";
@import "admin/CreateUserModal";
@import "admin/DashboardPage";
@import "admin/DebugWarningWidget";
@import "admin/BasicsPage";
Expand Down
6 changes: 6 additions & 0 deletions framework/core/less/admin/CreateUserModal.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.CreateUserModal {
&-bulkAdd {
margin-top: 32px;
margin-bottom: 24px;
}
}
Loading