Skip to content

Commit 21b4836

Browse files
authored
feat: add user creation to users list page (#3744)
1 parent 9363682 commit 21b4836

8 files changed

Lines changed: 360 additions & 23 deletions

File tree

framework/core/js/src/admin/compat.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import EditGroupModal from './components/EditGroupModal';
3535
import routes from './routes';
3636
import AdminApplication from './AdminApplication';
3737
import generateElementId from './utils/generateElementId';
38+
import CreateUserModal from './components/CreateUserModal';
3839

3940
export default Object.assign(compat, {
4041
'utils/saveSettings': saveSettings,
@@ -70,6 +71,7 @@ export default Object.assign(compat, {
7071
'components/AdminHeader': AdminHeader,
7172
'components/EditCustomCssModal': EditCustomCssModal,
7273
'components/EditGroupModal': EditGroupModal,
74+
'components/CreateUserModal': CreateUserModal,
7375
routes: routes,
7476
AdminApplication: AdminApplication,
7577
});
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import app from '../../admin/app';
2+
import Modal, { IInternalModalAttrs } from '../../common/components/Modal';
3+
import Button from '../../common/components/Button';
4+
import extractText from '../../common/utils/extractText';
5+
import ItemList from '../../common/utils/ItemList';
6+
import Stream from '../../common/utils/Stream';
7+
import type Mithril from 'mithril';
8+
import Switch from '../../common/components/Switch';
9+
import { generateRandomString } from '../../common/utils/string';
10+
11+
export interface ICreateUserModalAttrs extends IInternalModalAttrs {
12+
username?: string;
13+
email?: string;
14+
password?: string;
15+
token?: string;
16+
provided?: string[];
17+
}
18+
19+
export type SignupBody = {
20+
username: string;
21+
email: string;
22+
isEmailConfirmed: boolean;
23+
password: string;
24+
};
25+
26+
export default class CreateUserModal<CustomAttrs extends ICreateUserModalAttrs = ICreateUserModalAttrs> extends Modal<CustomAttrs> {
27+
/**
28+
* The value of the username input.
29+
*/
30+
username!: Stream<string>;
31+
32+
/**
33+
* The value of the email input.
34+
*/
35+
email!: Stream<string>;
36+
37+
/**
38+
* The value of the password input.
39+
*/
40+
password!: Stream<string | null>;
41+
42+
/**
43+
* Whether email confirmation is required after signing in.
44+
*/
45+
requireEmailConfirmation!: Stream<boolean>;
46+
47+
/**
48+
* Keeps the modal open after the user is created to facilitate creating
49+
* multiple users at once.
50+
*/
51+
bulkAdd!: Stream<boolean>;
52+
53+
oninit(vnode: Mithril.Vnode<CustomAttrs, this>) {
54+
super.oninit(vnode);
55+
56+
this.username = Stream('');
57+
this.email = Stream('');
58+
this.password = Stream<string | null>('');
59+
this.requireEmailConfirmation = Stream(false);
60+
this.bulkAdd = Stream(false);
61+
}
62+
63+
className() {
64+
return 'Modal--small CreateUserModal';
65+
}
66+
67+
title() {
68+
return app.translator.trans('core.admin.create_user.title');
69+
}
70+
71+
content() {
72+
return (
73+
<>
74+
<div className="Modal-body">{this.body()}</div>
75+
</>
76+
);
77+
}
78+
79+
body() {
80+
return (
81+
<>
82+
<div className="Form Form--centered">{this.fields().toArray()}</div>
83+
</>
84+
);
85+
}
86+
87+
fields() {
88+
const items = new ItemList();
89+
90+
const usernameLabel = extractText(app.translator.trans('core.admin.create_user.username_placeholder'));
91+
const emailLabel = extractText(app.translator.trans('core.admin.create_user.email_placeholder'));
92+
const emailConfirmationLabel = extractText(app.translator.trans('core.admin.create_user.email_confirmed_label'));
93+
const useRandomPasswordLabel = extractText(app.translator.trans('core.admin.create_user.use_random_password'));
94+
const passwordLabel = extractText(app.translator.trans('core.admin.create_user.password_placeholder'));
95+
96+
items.add(
97+
'username',
98+
<div className="Form-group">
99+
<input
100+
className="FormControl"
101+
name="username"
102+
type="text"
103+
placeholder={usernameLabel}
104+
aria-label={usernameLabel}
105+
bidi={this.username}
106+
disabled={this.loading}
107+
/>
108+
</div>,
109+
100
110+
);
111+
112+
items.add(
113+
'email',
114+
<div className="Form-group">
115+
<input
116+
className="FormControl"
117+
name="email"
118+
type="email"
119+
placeholder={emailLabel}
120+
aria-label={emailLabel}
121+
bidi={this.email}
122+
disabled={this.loading}
123+
/>
124+
</div>,
125+
80
126+
);
127+
128+
items.add(
129+
'password',
130+
<div className="Form-group">
131+
<input
132+
className="FormControl"
133+
name="password"
134+
type="password"
135+
autocomplete="new-password"
136+
placeholder={passwordLabel}
137+
aria-label={passwordLabel}
138+
bidi={this.password}
139+
disabled={this.loading || this.password() === null}
140+
/>
141+
</div>,
142+
60
143+
);
144+
145+
items.add(
146+
'emailConfirmation',
147+
<div className="Form-group">
148+
<Switch
149+
name="emailConfirmed"
150+
state={this.requireEmailConfirmation()}
151+
onchange={(checked: boolean) => this.requireEmailConfirmation(checked)}
152+
disabled={this.loading}
153+
>
154+
{emailConfirmationLabel}
155+
</Switch>
156+
</div>,
157+
40
158+
);
159+
160+
items.add(
161+
'useRandomPassword',
162+
<div className="Form-group">
163+
<Switch
164+
name="useRandomPassword"
165+
state={this.password() === null}
166+
onchange={(enabled: boolean) => {
167+
this.password(enabled ? null : '');
168+
}}
169+
disabled={this.loading}
170+
>
171+
{useRandomPasswordLabel}
172+
</Switch>
173+
</div>,
174+
20
175+
);
176+
177+
items.add(
178+
'submit',
179+
<div className="Form-group">
180+
<Button className="Button Button--primary Button--block" type="submit" loading={this.loading}>
181+
{app.translator.trans('core.admin.create_user.submit_button')}
182+
</Button>
183+
</div>,
184+
0
185+
);
186+
187+
items.add(
188+
'submitAndAdd',
189+
<div className="Form-group">
190+
<Button className="Button Button--block" onclick={() => this.bulkAdd(true) && this.onsubmit()} disabled={this.loading}>
191+
{app.translator.trans('core.admin.create_user.submit_and_create_another_button')}
192+
</Button>
193+
</div>,
194+
-20
195+
);
196+
197+
return items;
198+
}
199+
200+
onready() {
201+
this.$('[name=username]').trigger('select');
202+
}
203+
204+
onsubmit(e: SubmitEvent | null = null) {
205+
e?.preventDefault();
206+
207+
this.loading = true;
208+
209+
app
210+
.request({
211+
url: app.forum.attribute('apiUrl') + '/users',
212+
method: 'POST',
213+
body: { data: { attributes: this.submitData() } },
214+
errorHandler: this.onerror.bind(this),
215+
})
216+
.then(() => {
217+
if (this.bulkAdd()) {
218+
this.resetData();
219+
} else {
220+
this.hide();
221+
}
222+
})
223+
.finally(() => {
224+
this.bulkAdd(false);
225+
this.loaded();
226+
});
227+
}
228+
229+
/**
230+
* Get the data that should be submitted in the sign-up request.
231+
*/
232+
submitData(): SignupBody {
233+
const data = {
234+
username: this.username(),
235+
email: this.email(),
236+
isEmailConfirmed: !this.requireEmailConfirmation(),
237+
password: this.password() ?? generateRandomString(32),
238+
};
239+
240+
return data;
241+
}
242+
243+
resetData() {
244+
this.username('');
245+
this.email('');
246+
this.password('');
247+
}
248+
}

framework/core/js/src/admin/components/UserListPage.tsx

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type Mithril from 'mithril';
1+
import Mithril from 'mithril';
22

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

@@ -17,6 +17,7 @@ import classList from '../../common/utils/classList';
1717
import extractText from '../../common/utils/extractText';
1818
import AdminPage from './AdminPage';
1919
import { debounce } from '../../common/utils/throttleDebounce';
20+
import CreateUserModal from './CreateUserModal';
2021

2122
type ColumnData = {
2223
/**
@@ -116,19 +117,7 @@ export default class UserListPage extends AdminPage {
116117
const columns = this.columns().toArray();
117118

118119
return [
119-
<div className="Search-input">
120-
<input
121-
className="FormControl SearchBar"
122-
type="search"
123-
placeholder={app.translator.trans('core.admin.users.search_placeholder')}
124-
oninput={(e: InputEvent) => {
125-
this.isLoadingPage = true;
126-
this.query = (e?.target as HTMLInputElement)?.value;
127-
this.throttledSearch();
128-
}}
129-
/>
130-
</div>,
131-
<p className="UserListPage-totalUsers">{app.translator.trans('core.admin.users.total_users', { count: this.userCount })}</p>,
120+
<div className="UserListPage-header">{this.headerItems().toArray()}</div>,
132121
<section
133122
className={classList(['UserListPage-grid', this.isLoadingPage ? 'UserListPage-grid--loadingPage' : 'UserListPage-grid--loaded'])}
134123
style={{ '--columns': columns.length }}
@@ -243,6 +232,51 @@ export default class UserListPage extends AdminPage {
243232
];
244233
}
245234

235+
headerItems(): ItemList<Mithril.Children> {
236+
const items = new ItemList<Mithril.Children>();
237+
238+
items.add(
239+
'search',
240+
<div className="Search-input">
241+
<input
242+
className="FormControl SearchBar"
243+
type="search"
244+
placeholder={app.translator.trans('core.admin.users.search_placeholder')}
245+
oninput={(e: InputEvent) => {
246+
this.isLoadingPage = true;
247+
this.query = (e?.target as HTMLInputElement)?.value;
248+
this.throttledSearch();
249+
}}
250+
/>
251+
</div>,
252+
100
253+
);
254+
255+
items.add(
256+
'totalUsers',
257+
<p class="UserListPage-totalUsers">{app.translator.trans('core.admin.users.total_users', { count: this.userCount })}</p>,
258+
90
259+
);
260+
261+
items.add('actions', <div className="UserListPage-actions">{this.actionItems().toArray()}</div>, 80);
262+
263+
return items;
264+
}
265+
266+
actionItems(): ItemList<Mithril.Children> {
267+
const items = new ItemList<Mithril.Children>();
268+
269+
items.add(
270+
'createUser',
271+
<Button className="Button UserListPage-createUserBtn" icon="fas fa-user-plus" onclick={() => app.modal.show(CreateUserModal)}>
272+
{app.translator.trans('core.admin.users.create_user_button')}
273+
</Button>,
274+
100
275+
);
276+
277+
return items;
278+
}
279+
246280
/**
247281
* Build an item list of columns to show for each user.
248282
*

framework/core/js/src/common/utils/string.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,23 @@ export function ucfirst(string: string): string {
7979
export function camelCaseToSnakeCase(str: string): string {
8080
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
8181
}
82+
83+
/**
84+
* Generate a random string (a-z, 0-9) of a given length.
85+
*
86+
* Providing a length of less than 0 will result in an error.
87+
*
88+
* @param length Length of the random string to generate
89+
* @returns A random string of provided length
90+
*/
91+
export function generateRandomString(length: number): string {
92+
if (length < 0) throw new Error('Cannot generate a random string with length less than 0.');
93+
if (length === 0) return '';
94+
95+
const arr = new Uint8Array(length / 2);
96+
window.crypto.getRandomValues(arr);
97+
98+
return Array.from(arr, (dec) => {
99+
return dec.toString(16).padStart(2, '0');
100+
}).join('');
101+
}

framework/core/less/admin.less

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
@import "admin/AdminHeader";
44
@import "admin/AdminNav";
5+
@import "admin/CreateUserModal";
56
@import "admin/DashboardPage";
67
@import "admin/DebugWarningWidget";
78
@import "admin/BasicsPage";
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.CreateUserModal {
2+
&-bulkAdd {
3+
margin-top: 32px;
4+
margin-bottom: 24px;
5+
}
6+
}

0 commit comments

Comments
 (0)