From 2dbf330d7ba7ea91f2c199daf35a1e3c698258dd Mon Sep 17 00:00:00 2001 From: David Wheatley Date: Wed, 22 Feb 2023 15:34:58 +0000 Subject: [PATCH 1/5] feat: add user creation to users list page --- framework/core/js/src/admin/compat.ts | 2 + .../src/admin/components/CreateUserModal.tsx | 228 ++++++++++++++++++ .../js/src/admin/components/UserListPage.tsx | 62 +++-- framework/core/less/admin.less | 1 + .../core/less/admin/CreateUserModal.less | 6 + framework/core/less/admin/UsersListPage.less | 14 ++ framework/core/locale/core.yml | 10 + 7 files changed, 309 insertions(+), 14 deletions(-) create mode 100644 framework/core/js/src/admin/components/CreateUserModal.tsx create mode 100644 framework/core/less/admin/CreateUserModal.less diff --git a/framework/core/js/src/admin/compat.ts b/framework/core/js/src/admin/compat.ts index 9ff8d9b58b..0121321425 100644 --- a/framework/core/js/src/admin/compat.ts +++ b/framework/core/js/src/admin/compat.ts @@ -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, }); diff --git a/framework/core/js/src/admin/components/CreateUserModal.tsx b/framework/core/js/src/admin/components/CreateUserModal.tsx new file mode 100644 index 0000000000..f525eb0d70 --- /dev/null +++ b/framework/core/js/src/admin/components/CreateUserModal.tsx @@ -0,0 +1,228 @@ +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 Checkbox from '../../common/components/Checkbox'; + +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 extends Modal { + /** + * The value of the username input. + */ + username!: Stream; + + /** + * The value of the email input. + */ + email!: Stream; + + /** + * The value of the password input. + */ + password!: Stream; + + /** + * Whether email confirmation is required after signing in. + */ + requireEmailConfirmation!: Stream; + + /** + * Keeps the modal open after the user is created to facilitate creating + * multiple users at once. + */ + bulkAdd!: Stream; + + oninit(vnode: Mithril.Vnode) { + super.oninit(vnode); + + this.username = Stream(''); + this.email = Stream(''); + this.password = Stream(''); + 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 ( + <> +
{this.body()}
+ + ); + } + + body() { + return ( + <> +
{this.fields().toArray()}
+ + ); + } + + 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 passwordLabel = extractText(app.translator.trans('core.admin.create_user.password_placeholder')); + + items.add( + 'username', +
+ +
, + 100 + ); + + items.add( + 'email', +
+ +
, + 80 + ); + + items.add( + 'emailConfirmation', +
+ this.requireEmailConfirmation(checked)} + disabled={this.loading} + > + {emailConfirmationLabel} + +
, + 60 + ); + + items.add( + 'password', +
+ +
, + 40 + ); + + items.add( + 'bulkAdd', +
+ this.bulkAdd(checked)}> + {app.translator.trans('core.admin.create_user.bulk_add_label')} + +
, + 0 + ); + + items.add( + 'submit', +
+ +
, + -10 + ); + + return items; + } + + onready() { + this.$('[name=username]').trigger('select'); + } + + onsubmit(e: SubmitEvent) { + 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(() => { + this.loaded(); + + if (this.bulkAdd()) { + this.resetData(); + } else { + this.hide(); + } + }); + } + + /** + * 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(), + }; + + return data; + } + + resetData() { + this.username(''); + this.email(''); + this.password(''); + } +} diff --git a/framework/core/js/src/admin/components/UserListPage.tsx b/framework/core/js/src/admin/components/UserListPage.tsx index 1ab06e69e3..47f192fe45 100644 --- a/framework/core/js/src/admin/components/UserListPage.tsx +++ b/framework/core/js/src/admin/components/UserListPage.tsx @@ -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 [ -
- { - this.isLoadingPage = true; - this.query = (e?.target as HTMLInputElement)?.value; - this.throttledSearch(); - }} - /> -
, -

{app.translator.trans('core.admin.users.total_users', { count: this.userCount })}

, +
{this.headerItems().toArray()}
,
{ + const items = new ItemList(); + + items.add( + 'search', +
+ { + this.isLoadingPage = true; + this.query = (e?.target as HTMLInputElement)?.value; + this.throttledSearch(); + }} + /> +
, + 100 + ); + + items.add( + 'totalUsers', +

{app.translator.trans('core.admin.users.total_users', { count: this.userCount })}

, + 90 + ); + + items.add('actions',
{this.actionItems().toArray()}
, 80); + + return items; + } + + actionItems(): ItemList { + const items = new ItemList(); + + items.add( + 'createUser', + , + 100 + ); + + return items; + } + /** * Build an item list of columns to show for each user. * diff --git a/framework/core/less/admin.less b/framework/core/less/admin.less index 2b1c658d9e..b335ab52ab 100644 --- a/framework/core/less/admin.less +++ b/framework/core/less/admin.less @@ -2,6 +2,7 @@ @import "admin/AdminHeader"; @import "admin/AdminNav"; +@import "admin/CreateUserModal"; @import "admin/DashboardPage"; @import "admin/DebugWarningWidget"; @import "admin/BasicsPage"; diff --git a/framework/core/less/admin/CreateUserModal.less b/framework/core/less/admin/CreateUserModal.less new file mode 100644 index 0000000000..f589a8f14c --- /dev/null +++ b/framework/core/less/admin/CreateUserModal.less @@ -0,0 +1,6 @@ +.CreateUserModal { + &-bulkAdd { + margin-top: 32px; + margin-bottom: 24px; + } +} diff --git a/framework/core/less/admin/UsersListPage.less b/framework/core/less/admin/UsersListPage.less index 907f2d8be0..dd0cd61850 100644 --- a/framework/core/less/admin/UsersListPage.less +++ b/framework/core/less/admin/UsersListPage.less @@ -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; diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index c41df63665..5611543cda 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -51,6 +51,16 @@ 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: + bulk_add_label: Keep modal open after creation? + email_placeholder: => core.ref.email + email_confirmed_label: Require user to confirm this email? + password_placeholder: => core.ref.password + submit_button: Create user + title: Create new user + username_placeholder: => core.ref.username + # These translations are used in the Dashboard page. dashboard: clear_cache_button: Clear Cache From f0269579f1ed32cb095abfcb949ae29fedcedac2 Mon Sep 17 00:00:00 2001 From: David Wheatley Date: Fri, 24 Feb 2023 09:43:08 +0000 Subject: [PATCH 2/5] feat: add option to generate a random password when creating a user --- .../src/admin/components/CreateUserModal.tsx | 26 ++++++++++++++----- framework/core/js/src/common/utils/string.ts | 20 ++++++++++++++ framework/core/locale/core.yml | 23 +++++++--------- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/framework/core/js/src/admin/components/CreateUserModal.tsx b/framework/core/js/src/admin/components/CreateUserModal.tsx index f525eb0d70..af88c7b420 100644 --- a/framework/core/js/src/admin/components/CreateUserModal.tsx +++ b/framework/core/js/src/admin/components/CreateUserModal.tsx @@ -7,6 +7,7 @@ import Stream from '../../common/utils/Stream'; import type Mithril from 'mithril'; import Switch from '../../common/components/Switch'; import Checkbox from '../../common/components/Checkbox'; +import { generateRandomString } from '../../common/utils/string'; export interface ICreateUserModalAttrs extends IInternalModalAttrs { username?: string; @@ -37,7 +38,7 @@ export default class CreateUserModal; + password!: Stream; /** * Whether email confirmation is required after signing in. @@ -55,7 +56,7 @@ export default class CreateUserModal(''); this.requireEmailConfirmation = Stream(false); this.bulkAdd = Stream(false); } @@ -90,6 +91,7 @@ export default class CreateUserModal + { + this.password(enabled ? null : ''); + }} + disabled={this.loading} + > + {useRandomPasswordLabel} + + , 40 @@ -196,13 +209,14 @@ export default class CreateUserModal { - this.loaded(); - if (this.bulkAdd()) { this.resetData(); } else { this.hide(); } + }) + .finally(() => { + this.loaded(); }); } @@ -214,7 +228,7 @@ export default class CreateUserModal `_${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(''); +} diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index 5611543cda..7f997b7759 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -1,12 +1,10 @@ core: - ## # UNIQUE KEYS - The following keys are used in only one location each. ## # Translations in this namespace are used by the admin interface. admin: - # These translations are used in the Appearance page. appearance: colored_header_label: Colored Header @@ -59,6 +57,7 @@ core: password_placeholder: => core.ref.password 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. @@ -300,7 +299,6 @@ core: # Translations in this namespace are used by the forum user interface. forum: - # These translations are used in the Change Email modal dialog. change_email: confirm_password_placeholder: => core.ref.confirm_password @@ -722,7 +720,6 @@ core: # Translations in this namespace are used in emails sent by the forum. email: - # These translations are used in emails sent when users register new accounts. activate_account: subject: Activate Your New Account @@ -783,7 +780,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." @@ -793,7 +790,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 @@ -809,27 +806,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. From d382ebc978d21327f267f7acff504796daf3a166 Mon Sep 17 00:00:00 2001 From: David Wheatley Date: Fri, 24 Feb 2023 10:38:49 +0000 Subject: [PATCH 3/5] chore: remove unused import --- framework/core/js/src/admin/components/CreateUserModal.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/framework/core/js/src/admin/components/CreateUserModal.tsx b/framework/core/js/src/admin/components/CreateUserModal.tsx index af88c7b420..9ed7843f37 100644 --- a/framework/core/js/src/admin/components/CreateUserModal.tsx +++ b/framework/core/js/src/admin/components/CreateUserModal.tsx @@ -6,7 +6,6 @@ import ItemList from '../../common/utils/ItemList'; import Stream from '../../common/utils/Stream'; import type Mithril from 'mithril'; import Switch from '../../common/components/Switch'; -import Checkbox from '../../common/components/Checkbox'; import { generateRandomString } from '../../common/utils/string'; export interface ICreateUserModalAttrs extends IInternalModalAttrs { From 5d8d4482988f4cf441e4e6067fbbd4f9ac2fca4e Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Sun, 7 May 2023 17:57:03 +0100 Subject: [PATCH 4/5] chore: revert formatting Signed-off-by: Sami Mazouz --- framework/core/locale/core.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index 93c4d5d2d9..885da185e5 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -1,10 +1,12 @@ core: + ## # UNIQUE KEYS - The following keys are used in only one location each. ## # Translations in this namespace are used by the admin interface. admin: + # These translations are used in the Appearance page. appearance: colored_header_label: Colored Header @@ -58,7 +60,7 @@ core: password_placeholder: => core.ref.password submit_button: Create user title: Create new user - use_random_password: Generate random password? + use_random_password: Generate random password username_placeholder: => core.ref.username # These translations are used in the Dashboard page. @@ -306,6 +308,7 @@ core: # Translations in this namespace are used by the forum user interface. forum: + # These translations are used in the Change Email modal dialog. change_email: confirm_password_placeholder: => core.ref.confirm_password @@ -728,6 +731,7 @@ core: # Translations in this namespace are used in emails sent by the forum. email: + # These translations are used in emails sent when users register new accounts. activate_account: subject: Activate Your New Account From e5406b92f6f894a30fdef0da79506dc6acc289eb Mon Sep 17 00:00:00 2001 From: Sami Mazouz Date: Sun, 7 May 2023 18:28:30 +0100 Subject: [PATCH 5/5] chore(review): review amendments Signed-off-by: Sami Mazouz --- .../src/admin/components/CreateUserModal.tsx | 57 +++++++++++-------- framework/core/locale/core.yml | 5 +- 2 files changed, 35 insertions(+), 27 deletions(-) diff --git a/framework/core/js/src/admin/components/CreateUserModal.tsx b/framework/core/js/src/admin/components/CreateUserModal.tsx index 9ed7843f37..c0e21d5127 100644 --- a/framework/core/js/src/admin/components/CreateUserModal.tsx +++ b/framework/core/js/src/admin/components/CreateUserModal.tsx @@ -125,6 +125,23 @@ export default class CreateUserModal + + , + 60 + ); + items.add( 'emailConfirmation',
@@ -137,11 +154,11 @@ export default class CreateUserModal
, - 60 + 40 ); items.add( - 'password', + 'useRandomPassword',
{useRandomPasswordLabel} - -
, - 40 + 20 ); items.add( - 'bulkAdd', -
- this.bulkAdd(checked)}> - {app.translator.trans('core.admin.create_user.bulk_add_label')} - + 'submit', +
+
, 0 ); items.add( - 'submit', + 'submitAndAdd',
-
, - -10 + -20 ); return items; @@ -195,8 +201,8 @@ export default class CreateUserModal { + this.bulkAdd(false); this.loaded(); }); } diff --git a/framework/core/locale/core.yml b/framework/core/locale/core.yml index 885da185e5..6d6a5db2b1 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -54,10 +54,10 @@ core: # These translations are used in the Create User modal. create_user: - bulk_add_label: Keep modal open after creation? email_placeholder: => core.ref.email - email_confirmed_label: Require user to confirm this 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 @@ -261,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: