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..c0e21d5127 --- /dev/null +++ b/framework/core/js/src/admin/components/CreateUserModal.tsx @@ -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 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 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', +
+ +
, + 100 + ); + + items.add( + 'email', +
+ +
, + 80 + ); + + items.add( + 'password', +
+ +
, + 60 + ); + + items.add( + 'emailConfirmation', +
+ this.requireEmailConfirmation(checked)} + disabled={this.loading} + > + {emailConfirmationLabel} + +
, + 40 + ); + + items.add( + 'useRandomPassword', +
+ { + this.password(enabled ? null : ''); + }} + disabled={this.loading} + > + {useRandomPasswordLabel} + +
, + 20 + ); + + items.add( + 'submit', +
+ +
, + 0 + ); + + items.add( + 'submitAndAdd', +
+ +
, + -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(''); + } +} diff --git a/framework/core/js/src/admin/components/UserListPage.tsx b/framework/core/js/src/admin/components/UserListPage.tsx index d8d2ac4049..aa06afbfc1 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/js/src/common/utils/string.ts b/framework/core/js/src/common/utils/string.ts index e11c84fd1d..fadc6bba10 100644 --- a/framework/core/js/src/common/utils/string.ts +++ b/framework/core/js/src/common/utils/string.ts @@ -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(''); +} 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 c021fa1f9e..6d6a5db2b1 100644 --- a/framework/core/locale/core.yml +++ b/framework/core/locale/core.yml @@ -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: @@ -781,7 +793,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." @@ -791,7 +803,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 @@ -808,27 +820,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.