diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs b/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs index eb2cb5153b..5de32db328 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs +++ b/services/web/app/src/Features/Collaborators/CollaboratorsController.mjs @@ -157,6 +157,7 @@ const transferOwnershipSchema = z.object({ }), body: z.object({ user_id: zz.objectId(), + skipEmails: z.boolean().optional(), }), }) @@ -173,6 +174,7 @@ async function transferOwnership(req, res, next) { allowTransferToNonCollaborators: hasAdminAccess(sessionUser), sessionUserId: new ObjectId(sessionUser._id), ipAddress: req.ip, + skipEmails: body.skipEmails } ) res.sendStatus(204) diff --git a/services/web/app/src/Features/Project/ProjectDeleter.mjs b/services/web/app/src/Features/Project/ProjectDeleter.mjs index 34804a4589..02d667559a 100644 --- a/services/web/app/src/Features/Project/ProjectDeleter.mjs +++ b/services/web/app/src/Features/Project/ProjectDeleter.mjs @@ -79,7 +79,7 @@ async function unmarkAsDeletedByExternalSource(projectId) { ).exec() } -async function deleteUsersProjects(userId) { +async function deleteUsersProjects(userId, options = {}) { const projects = await Project.find({ owner_ref: userId }).exec() logger.info( { userId, projectCount: projects.length }, @@ -87,6 +87,7 @@ async function deleteUsersProjects(userId) { ) await promiseMapWithLimit(5, projects, project => deleteProject(project._id, { + ...options, deletedReason: DeletedProjectReasons.ACCOUNT_DELETION, }) ) @@ -235,6 +236,7 @@ async function deleteProject(projectId, options = {}) { }) } + const deleterData = { deletedAt: new Date(), deleterId: @@ -306,7 +308,7 @@ async function undeleteProject(projectId, options = {}) { // if we're undeleting, we want the document to show up restored.name = await ProjectDetailsHandler.promises.generateUniqueName( deletedProject.deleterData.deletedProjectOwnerId, - restored.name + ' (Restored)' + restored.name + (options.suffix ?? ' (Restored)') ) restored.archived = undefined @@ -330,6 +332,7 @@ async function undeleteProject(projectId, options = {}) { await db.projects.insertOne(restored) await DeletedProject.deleteOne({ _id: deletedProject._id }).exec() + return restored } async function expireDeletedProject(projectId) { diff --git a/services/web/app/src/Features/User/UserDeleter.mjs b/services/web/app/src/Features/User/UserDeleter.mjs index 3ba04c7ed6..e3f7bfb151 100644 --- a/services/web/app/src/Features/User/UserDeleter.mjs +++ b/services/web/app/src/Features/User/UserDeleter.mjs @@ -63,7 +63,7 @@ async function deleteUser(userId, options) { logger.info({ userId }, 'creating deleted user record') await _createDeletedUser(user, options) logger.info({ userId }, 'deleting user projects') - await ProjectDeleter.promises.deleteUsersProjects(user._id) + await ProjectDeleter.promises.deleteUsersProjects(user._id, options) if (options.skipEmail) { logger.info({ userId }, 'skipping sending deletion email to user') } else { diff --git a/services/web/app/src/Features/User/UserRegistrationHandler.mjs b/services/web/app/src/Features/User/UserRegistrationHandler.mjs index 0011774c70..6cc445bf79 100644 --- a/services/web/app/src/Features/User/UserRegistrationHandler.mjs +++ b/services/web/app/src/Features/User/UserRegistrationHandler.mjs @@ -15,10 +15,12 @@ import OError from '@overleaf/o-error' const UserRegistrationHandler = { _registrationRequestIsValid(body) { const invalidEmail = AuthenticationManager.validateEmail(body.email || '') + if (invalidEmail) throw new OError('InvalidEmailError') const invalidPassword = AuthenticationManager.validatePassword( body.password || '', body.email ) + if (invalidPassword) throw new OError('InvalidPasswordError') return !(invalidEmail || invalidPassword) }, diff --git a/services/web/app/views/layout/navbar-marketing.pug b/services/web/app/views/layout/navbar-marketing.pug index 70ca637319..61ff0728ac 100644 --- a/services/web/app/views/layout/navbar-marketing.pug +++ b/services/web/app/views/layout/navbar-marketing.pug @@ -66,7 +66,7 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg( +dropdown-menu-link-item(href='/admin') Manage Site +dropdown-menu-link-item(href='/admin/user') Manage Users if canDisplayProjectUrlLookup - +dropdown-menu-link-item(href='/admin/project') Project URL Lookup + +dropdown-menu-link-item(href='/admin/project') Manage Projects if canDisplayAdminRedirect +dropdown-menu-link-item(href=settings.adminUrl) Switch to Admin if canDisplaySplitTestMenu diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index d10f0f3be8..db64788b7f 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -1093,13 +1093,13 @@ module.exports = { 'history-v1', 'launchpad', 'server-ce-scripts', - 'user-activate', 'sandboxed-compiles', 'symbol-palette', 'track-changes', 'authentication/ldap', 'authentication/saml', 'authentication/oidc', + 'admin-tools', // import after authentication 'template-gallery', ], viewIncludes: {}, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 923a7165ae..4439651822 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -22,6 +22,7 @@ "a_new_reference_was_added_to_file_from_provider": "", "a_new_version_of_the_rolling_texlive_build_released": "", "about_to_archive_projects": "", + "about_to_delete_accounts": "", "about_to_delete_cert": "", "about_to_delete_projects": "", "about_to_delete_tag": "", @@ -32,8 +33,17 @@ "about_to_enable_managed_users": "", "about_to_leave_project": "", "about_to_leave_projects": "", + "about_to_permanently_delete_accounts": "", + "about_to_permanently_delete_projects": "", "about_to_remove_user_preamble": "", + "about_to_resend_activation_email": "", + "about_to_restore_accounts": "", + "about_to_restore_projects": "", + "about_to_resume_accounts": "", + "about_to_set_admin_accounts": "", + "about_to_suspend_accounts": "", "about_to_trash_projects": "", + "about_to_unset_admin_accounts": "", "abstract": "", "accept_all_cookies": "", "accept_and_continue": "", @@ -59,12 +69,14 @@ "account_has_been_link_to_institution_account": "", "account_has_past_due_invoice_change_plan_warning": "", "account_help": "", + "account_information": "", "account_managed_by_group_administrator": "", "account_managed_by_group_teamname": "", "account_not_linked_to_dropbox": "", "account_settings": "", "acct_linked_to_institution_acct_2": "", "actions": "", + "activation_link": "", "active": "", "add": "", "add_a_recovery_email_address": "", @@ -144,6 +156,7 @@ "all_projects": "", "all_projects_will_be_transferred_immediately": "", "all_templates": "", + "all_users": "", "all_these_experiments_are_available_exclusively": "", "allocate_license": "", "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "", @@ -201,6 +214,7 @@ "back_to_my_projects": "", "back_to_pdf": "", "back_to_subscription": "", + "back_to_user_list": "", "back_to_your_projects": "", "basic_compile_time": "", "before_you_use_error_assistant": "", @@ -439,6 +453,7 @@ "delete_account": "", "delete_account_confirmation_label": "", "delete_account_warning_message_3": "", + "delete_accounts": "", "delete_acct_no_existing_pw": "", "delete_and_leave": "", "delete_and_leave_projects": "", @@ -469,6 +484,7 @@ "deleted_by_id": "", "deleted_by_ip": "", "deleted_by_on": "", + "deleted_projects": "", "deleted_user": "", "deleting": "", "demonstrating_git_integration": "", @@ -688,6 +704,7 @@ "files_cannot_include_invalid_characters": "", "files_selected": "", "filter_projects": "", + "filter_users": "", "find": "", "find_and_check_citations": "", "find_out_how_to_get_the_most_out_of_your_new_subscription": "", @@ -945,6 +962,7 @@ "imported_from_zotero_at_date": "", "importing": "", "importing_and_merging_changes_in_github": "", + "inactive_projects": "", "in_order_to_match_institutional_metadata_2": "", "in_order_to_match_institutional_metadata_associated": "", "include_caption": "", @@ -1238,11 +1256,13 @@ "need_to_add_new_primary_before_remove": "", "need_to_leave": "", "neither_agree_nor_disagree": "", + "never": "", "new_compile_domain_notice": "", "new_compiles_in_this_project_will_automatically_use_the_newest_version": "", "new_file": "", "new_folder": "", "new_name": "", + "new_owner": "", "new_password": "", "new_project": "", "new_reference": "", @@ -1290,6 +1310,7 @@ "no_symbols_found": "", "no_templates_found": "", "no_thanks_cancel_now": "", + "no_users": "", "non_blinking_cursor": "", "normal": "", "normally_x_price_per_month": "", @@ -1303,6 +1324,7 @@ "notification_personal_and_group_subscriptions": "", "notification_project_invite_accepted_message": "", "notification_project_invite_message": "", + "notify_users_about_account_deletion": "", "number_of_users": "", "numbered_list": "", "oauth_orcid_description": "", @@ -1360,7 +1382,9 @@ "overwrite": "", "overwriting_the_original_folder": "", "owned_by_x": "", + "owned_projects": "", "owner": "", + "ownership_of_projects_will_be_transferred": "", "page_current": "", "page_x_of_n": "", "pagination_navigation": "", @@ -1420,6 +1444,8 @@ "per_month_x_annually": "", "per_year": "", "percent_is_the_percentage_of_the_line_width": "", + "permanently_delete_accounts": "", + "permanently_delete_projects": "", "permanently_disables_the_preview": "", "personal_library": "", "pick_up_where_you_left_off": "", @@ -1538,6 +1564,7 @@ "publisher_account": "", "publishing": "", "pull_github_changes_into_sharelatex": "", + "purge": "", "push_sharelatex_changes_to_github": "", "push_to_github_pull_to_overleaf": "", "quoted_text": "", @@ -1636,6 +1663,7 @@ "repository_visibility": "", "republish": "", "resend": "", + "resend_activation_email": "", "resend_confirmation_code": "", "resend_group_invite": "", "resend_invite": "", @@ -1648,6 +1676,8 @@ "resolve_comment_error_title": "", "resolved_comments": "", "restore": "", + "restore_accounts": "", + "restore_projects": "", "restore_file": "", "restore_file_confirmation_message": "", "restore_file_confirmation_title": "", @@ -1657,6 +1687,8 @@ "restore_project_to_this_version": "", "restore_this_version": "", "restoring": "", + "resume": "", + "resume_account": "", "resync_completed": "", "resync_message": "", "resync_project_history": "", @@ -1748,6 +1780,7 @@ "select_all": "", "select_all_entries": "", "select_all_projects": "", + "select_all_users": "", "select_an_output_file": "", "select_an_output_file_figure_modal": "", "select_bib_file": "", @@ -1764,6 +1797,7 @@ "select_image_from_project_files": "", "select_project": "", "select_projects": "", + "select_users": "", "select_size": "", "select_tag": "", "select_tax_id_type": "", @@ -1776,6 +1810,7 @@ "send": "", "send_confirmation_code": "", "send_message": "", + "send_notification_emails_to_users": "", "send_request": "", "sending": "", "sentence_completion": "", @@ -1788,6 +1823,8 @@ "session_expired_redirecting_to_login": "", "sessions": "", "set_as_main_document": "", + "set_admin": "", + "set_admin_account": "", "set_color": "", "set_column_width": "", "set_up_single_sign_on": "", @@ -1803,6 +1840,7 @@ "sharing_permissions": "", "shortcut_to_open_advanced_reference_search": "", "show_all_projects": "", + "show_all_users": "", "show_breadcrumbs": "", "show_document_preamble": "", "show_equation_preview": "", @@ -1816,6 +1854,7 @@ "show_outline": "", "show_version_history": "", "show_x_more_projects": "", + "show_x_more_users": "", "showing_1_result": "", "showing_1_result_of_total": "", "showing_pdf_preview_with_inverted_colors": "", @@ -1825,6 +1864,7 @@ "showing_x_results_of_total": "", "sidebar": "", "sign_up": "", + "signed_up": "", "simple_search_mode": "", "single_sign_on_sso": "", "size": "", @@ -1959,6 +1999,9 @@ "sure_you_want_to_change_plan": "", "sure_you_want_to_delete": "", "sure_you_want_to_leave_group": "", + "suspend": "", + "suspend_account": "", + "switch_between_dark_and_light_mode": "", "switch_compile_mode_for_faster_draft_compilation": "", "switch_to_editor": "", "switch_to_pdf": "", @@ -2045,6 +2088,7 @@ "they_will_retain_ownership_of_projects_currently_owned_by_them_and_collaborators_will_become_read_only": "", "they_will_retain_their_existing_account_on_the_free_plan": "", "they_wont_be_able_to_log_in_with_sso_they_will_need_to_set_password": "", + "this_action_can_be_undone_within_limited_period": "", "this_action_cannot_be_reversed": "", "this_action_cannot_be_undone": "", "this_action_will_also_disable_domain_capture": "", @@ -2166,6 +2210,7 @@ "track_changes": "", "tracked_change_added": "", "tracked_change_deleted": "", + "transfer_all_projects_to": "", "transfer_management_of_your_account": "", "transfer_management_of_your_account_to_x": "", "transfer_management_resolve_following_issues": "", @@ -2177,6 +2222,7 @@ "trashed": "", "trashed_projects": "", "trashing_projects_wont_affect_collaborators": "", + "trashing_projects_wont_affect_user_collaborators": "", "trial_last_day": "", "trial_remaining_days": "", "tried_to_log_in_with_email": "", @@ -2242,6 +2288,8 @@ "unpausing": "", "unpublish": "", "unpublishing": "", + "unset_admin": "", + "unset_admin_account": "", "unsubscribe": "", "until_then_you_can_still": "", "untrash": "", @@ -2288,6 +2336,17 @@ "used_latex_response_occasionally": "", "used_latex_response_often": "", "used_when_referring_to_the_figure_elsewhere_in_the_document": "", + "user_activity": "", + "user_categories": "", + "user_category_admin": "", + "user_category_all": "", + "user_category_deleted": "", + "user_category_inactive": "", + "user_category_ldap": "", + "user_category_local": "", + "user_category_oidc": "", + "user_category_saml": "", + "user_category_suspended": "", "user_deletion_error": "", "user_deletion_password_reset_tip": "", "user_email_attribute": "", @@ -2296,6 +2355,7 @@ "user_last_name_attribute": "", "user_management": "", "user_sessions": "", + "users_list": "", "using_latex": "", "using_premium_features": "", "using_the_overleaf_editor": "", diff --git a/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 b/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 index 5ca6ba1da4..e6f9caf21a 100644 Binary files a/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 and b/services/web/frontend/fonts/material-symbols/MaterialSymbolsRoundedUnfilledPartialSlice.woff2 differ diff --git a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs index 2ea68a635b..84c58caf99 100644 --- a/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs +++ b/services/web/frontend/fonts/material-symbols/unfilled-symbols.mjs @@ -5,6 +5,7 @@ export default /** @type {const} */ ([ 'account_balance', + 'add_moderator', 'arrow_back_ios_new', 'arrow_circle_up', 'auto_delete', @@ -28,6 +29,7 @@ export default /** @type {const} */ ([ 'domain', 'edit', 'edit_square', + 'edit', 'error', 'experiment', 'find_replace', @@ -39,7 +41,7 @@ export default /** @type {const} */ ([ 'help', 'image', 'info', - 'info', + 'info_i', 'integration_instructions', 'lightbulb', 'lightbulb_2', @@ -53,12 +55,16 @@ export default /** @type {const} */ ([ 'notifications', 'open_in_new', 'password', + 'pause_circle', 'person', 'person_edit', 'picture_as_pdf', + 'play_circle', 'push_pin', 'rate_review', + 'remove_moderator', 'report', + 'restore_from_trash', 'search', 'settings', 'shuffle', diff --git a/services/web/frontend/js/shared/components/dropdown/dropdown-menu.tsx b/services/web/frontend/js/shared/components/dropdown/dropdown-menu.tsx index 291a2ac07d..37dbaf7173 100644 --- a/services/web/frontend/js/shared/components/dropdown/dropdown-menu.tsx +++ b/services/web/frontend/js/shared/components/dropdown/dropdown-menu.tsx @@ -32,6 +32,7 @@ function DropdownItem( description, leadingIcon, trailingIcon, + unfilled, ...props }: DropdownItemProps, ref: React.ForwardedRef @@ -41,7 +42,7 @@ function DropdownItem( if (typeof leadingIcon === 'string') { leadingIconComponent = ( ) @@ -61,7 +62,7 @@ function DropdownItem( trailingIconComponent = ( ) diff --git a/services/web/frontend/js/shared/components/navbar/admin-menu.tsx b/services/web/frontend/js/shared/components/navbar/admin-menu.tsx index 9876e4a769..d44ee6c1b3 100644 --- a/services/web/frontend/js/shared/components/navbar/admin-menu.tsx +++ b/services/web/frontend/js/shared/components/navbar/admin-menu.tsx @@ -45,7 +45,7 @@ export default function AdminMenu({ ) : null} {canDisplayProjectUrlLookup ? ( - Project URL Lookup + Manage Projects ) : null} {canDisplayAdminRedirect && adminUrl ? ( diff --git a/services/web/frontend/stylesheets/modules/admin-panel/user-list-ds-nav.scss b/services/web/frontend/stylesheets/modules/admin-panel/user-list-ds-nav.scss new file mode 100644 index 0000000000..0deefe096c --- /dev/null +++ b/services/web/frontend/stylesheets/modules/admin-panel/user-list-ds-nav.scss @@ -0,0 +1,326 @@ +.user-ds-nav-page { + display: flex; + flex-direction: column; + height: 100vh; + height: 100dvh; + color: var(--content-secondary); + + // NOTE-AC: This code can be eliminated when we remove sidebar-navigation-ui-update + --navbar-btn-padding-h: var(--spacing-06); + --navbar-subdued-padding: calc( + var(--navbar-btn-padding-v) + var(--navbar-btn-border-width) + ) + calc(var(--navbar-btn-padding-h) + 1px); + + .navbar-default { + position: relative; + + --navbar-toggler-expanded-bg: none; + --navbar-toggler-expanded-color: var(--content-secondary); + + .navbar-header .navbar-logo { + @include media-breakpoint-up(md) { + position: relative; + top: var(--spacing-05); + } + } + + .nav-item-users { + display: none !important; + } + + @include media-breakpoint-up(lg) { + .nav-item-account, + .nav-item-help { + display: none; + } + } + + @include media-breakpoint-down(lg) { + --navbar-hamburger-submenu-item-hover-color: var(--content-primary); + --navbar-hamburger-submenu-item-hover-bg: var(--bg-light-secondary); + --navbar-subdued-hover-color: var(--content-primary); + --navbar-link-hover-color: var(--content-primary); + --navbar-link-hover-bg: var(--bg-light-secondary); + --navbar-subdued-hover-bg: var(--bg-light-secondary); + --navbar-hamburger-submenu-bg: none; + + .nav-item-help::before { + content: ''; + display: block; + border-top: 1px solid var(--border-divider); + margin: var(--spacing-07) var(--spacing-06); + } + + #navbar-main-collapse { + padding: var(--spacing-09) var(--spacing-06) 0; + + .navbar-nav { + > li { + > a, + > .dropdown-toggle, + > .nav-link { + border-radius: var(--border-radius-medium); + + &.show { + background-color: var(--bg-accent-03); + color: var(--green-60); + } + } + } + } + } + } + } + + .navbar-nav > li > .dropdown-toggle::after { + display: none; + } + + .user-list-wrapper { + flex-grow: 1; + display: flex; + overflow-y: hidden; + + .user-list-sidebar-wrapper-react { + position: relative; + display: flex; + flex-direction: column; + flex: 0 0 15%; + max-width: 320px; + min-width: 200px; + margin-top: var(--spacing-03); + padding: var(--spacing-08) 0 0 0; + + .create-account-button-wrapper { + padding: 0 var(--spacing-08) var(--spacing-05) var(--spacing-05); + border-bottom: solid 1px transparent; + } + + nav { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + } + + .user-list-sidebar-scroll { + flex: 1 1 auto; + overflow: auto; + margin: 0 var(--spacing-02) 0 0; + padding: var(--spacing-05) var(--spacing-07) var(--spacing-05) + var(--spacing-05); + } + + .ds-nav-sidebar-lower { + padding: var(--spacing-05) var(--spacing-08) 0 var(--spacing-05); + border-top: solid 1px transparent; + + &.show-shadow { + border-top-color: var(--border-divider); + } + } + + .user-list-sidebar-survey-link { + color: var(--content-secondary) !important; + } + + .survey-notification { + background-color: var(--bg-light-secondary); + color: var(--content-secondary); + box-shadow: none; + border-radius: var(--border-radius-large); + position: relative; + + .user-notification-close { + border: none; + padding: 0; + background: none; + position: absolute; + top: var(--spacing-07); + right: var(--spacing-07); + color: inherit; + } + + p { + margin-bottom: var(--spacing-03); + } + } + + .user-list-sidebar-survey-wrapper .user-notifications { + margin-bottom: var(--spacing-05); + } + } + + ul.user-list-filters { + margin-bottom: 0; + + hr { + margin: var(--spacing-05) 0; + } + + > li { + > button { + width: 100%; + text-align: left; + color: var(--content-secondary); + background: none; + border-radius: var(--border-radius-medium); + border: none; + padding: var(--spacing-04) var(--spacing-05); + } + + &:hover button { + background-color: var(--bg-light-secondary); + } + + &.active button { + background-color: var(--bg-accent-03); + color: var(--green-60); + font-weight: bold; + } + } + + .dropdown-header { + font-weight: bold; + } + + > li.tag { + button.tag-name { + padding-right: var(--spacing-08) !important; + } + } + + .tag-menu { + button.dropdown-toggle { + border-radius: var(--border-radius-full); + border: none; + color: var(--content-secondary); + height: 20px; + width: 20px; + padding: 0; + + &::after { + display: none; + } + + &:hover, + &:active, + &[aria-expanded='true'] { + background-color: var(--neutral-20); + } + } + } + } + + .user-ds-nav-main { + min-height: 100%; + padding: var(--spacing-08) var(--spacing-06); + + @include media-breakpoint-up(md) { + padding: var(--spacing-08); + } + } + + .user-dash-table { + .btn-link { + color: var(--content-secondary); + height: var(--spacing-08); + width: var(--spacing-08); + border-radius: 100%; + padding: var(--spacing-01) 0 0; + vertical-align: middle; + + &:hover, + &:focus { + background-color: #d9d9d9 !important; + } + } + + .dash-cell-name a { + color: var(--content-secondary) !important; + } + } + + .user-ds-nav-content-and-messages { + flex-grow: 1; + display: flex; + flex-direction: column; + + > * { + @include media-breakpoint-up(md) { + border-left: 1px solid var(--border-divider); + } + } + + .user-ds-nav-content { + flex-grow: 1; + overflow-y: auto; + position: relative; + background-color: var(--bg-light-secondary); + + @include media-breakpoint-up(md) { + border-top-left-radius: var(--border-radius-large); + border-top: 1px solid var(--border-divider); + } + } + + .cookie-banner { + position: static; + background-color: var(--bg-light-primary); + + // Remove the parts of the shadow that stick out of the sides + clip-path: inset(-13px 0 0 0); + + // Prevent the cookie banner being overlaid on top of the navigation + z-index: auto; + } + } + } + + .ds-nav-icon-dropdown { + .dropdown-toggle { + color: var(--content-secondary); + background: none; + height: 44px; + width: 44px; + padding: 0; + overflow: hidden; + + &:hover { + background-color: var(--bg-light-secondary); + } + + &.show { + background-color: var(--bg-accent-03); + color: var(--green-60); + } + + &::after { + display: none; + } + + > div { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + + .material-symbols { + vertical-align: middle; + } + } + } + + .ds-nav-ds-name { + margin-bottom: var(--spacing-05); + + span { + text-transform: uppercase; + font-weight: bold; + + @include body-xs; + } + } +} diff --git a/services/web/frontend/stylesheets/modules/admin-panel/user-list.scss b/services/web/frontend/stylesheets/modules/admin-panel/user-list.scss new file mode 100644 index 0000000000..6e7a6f6640 --- /dev/null +++ b/services/web/frontend/stylesheets/modules/admin-panel/user-list.scss @@ -0,0 +1,588 @@ +.user-list-empty-col { + display: flex; + height: 100%; + flex-flow: column nowrap; + + .row:first-child { + flex-grow: 1; /* fill vertical space so notifications are pushed to bottom */ + } + + .card-body { + // h2 + .card-thin top padding + padding-bottom: calc(var(--line-height-03) + var(--line-height-03) / 2); + } +} + +.action-btn { + padding: 0 var(--spacing-02); +} + +#user-list-root .user-notifications ul { + margin-bottom: 0; +} + +.user-list-sidebar-wrapper-react { + button { + white-space: normal; + word-wrap: anywhere; + + // prevents buttons from expanding sidebar width + } + + .create-account-button-wrapper { + width: 100%; + .create-account-button { + width: 100%; + } + } +} + +.create-account-button.dropdown-toggle::after { + display: none; +} + +.user-list-header-row { + display: flex; + align-items: center; + margin-bottom: var(--spacing-05); + min-height: 36px; + + .user-list-title { + @include heading-sm; + + color: $content-secondary; + font-weight: bold; + min-width: 0; + } +} + +.user-tools { + flex-shrink: 0; + margin-left: auto; + min-height: 38px; + display: flex; + align-items: center; +} + +@include media-breakpoint-down(md) { + .user-tools { + float: left; + margin-left: initial; + } +} + +.users-toolbar { + display: flex; + align-items: center; + + .dropdown, + .dropdown-toggle { + max-width: 100%; + } + + .dropdown { + min-width: 0; + } +} + +.users-sort-dropdown { + flex-shrink: 0; + margin-left: auto; +} + +.user-menu-item-edit-btn { + position: absolute; + top: 0; + right: var(--spacing-09); + width: initial; + background-color: transparent; +} + +.user-menu-item-tag-name { + padding-right: var(--spacing-13); +} + +ul.user-list-filters { + .subdued { + color: var(--content-disabled); + } + + > li { + cursor: pointer; + position: relative; + + > button { + &:hover, + &:focus { + text-decoration: none; + } + } + + &.separator { + padding: var(--spacing-03) var(--spacing-06); + cursor: auto; + } + } + + .dropdown-header { + @include body-sm; + + padding: var(--spacing-05) var(--spacing-06); + text-transform: uppercase; + } + + > li.active { + border-radius: 0; + + > button { + font-weight: 700; + } + } + + h2 { + font-size: var(--font-size-02); + margin-bottom: var(--spacing-00); + color: var(--content-disabled); + text-transform: uppercase; + padding: var(--spacing-03) var(--spacing-00); + } + + > li.tag { + &.active, + &:focus-within { + .tag-menu { + display: block; + } + } + + &.untagged { + button.tag-name { + span.name { + font-style: italic; + padding-left: 0; + } + } + } + + &:hover { + .tag-menu { + display: block; + } + } + + button.tag-name { + position: relative; + padding: var(--spacing-03) var(--spacing-09) var(--spacing-03) + var(--spacing-06); + display: flex; + align-items: center; + word-wrap: anywhere; + + .tag-list-icon { + vertical-align: sub; + font-weight: bold; + } + + span.name { + padding-left: 0.5em; + line-height: 1.4; + } + } + } + + .tag-menu { + button.dropdown-toggle { + background-color: transparent; + display: flex; + align-items: center; + justify-content: center; + position: relative; + + &::after { + margin: 0; + } + } + + display: none; + width: auto; + position: absolute; + top: 50%; + margin-top: -8px; // Half the element height. + right: 4px; + + &.open { + display: block; + } + + button.tag-action { + border-radius: unset; + width: 100%; + background-color: transparent; + border-color: transparent; + color: var(--neutral-70); + text-align: left; + font-weight: normal; + + &:hover { + color: var(--content-primary-dark); + background-color: var(--bg-accent-01); + } + + &:active { + outline: none; + } + } + } +} + +.user-dash-table { + width: 100%; + table-layout: fixed; + + @include media-breakpoint-down(md) { + tr:not(:last-child) { + border-bottom: 1px solid $table-border-color; + } + + td { + border-bottom-width: 0; + } + } + + tbody { + tr.no-users:hover { + td { + box-shadow: none; + } + } + } + + .table-header-sort-btn { + border: 0; + text-align: left; + color: var(--content-secondary); + background-color: transparent; + padding: 0; + font-weight: bold; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + text-decoration: none; + + &:hover, + &:focus { + color: var(--content-secondary); + text-decoration: none; + } + + .material-symbols { + vertical-align: bottom; + font-size: var(--font-size-06); + } + } + + .dash-row-admin { + font-weight: bold; + td { + a { + color: darkred !important; + --bs-link-color: darkred !important; + } + } + } + + .dash-cell-name { + hyphens: auto; + width: 30%; + word-break: break-word; + } + + .dash-cell-email { + width: 40%; + overflow: hidden; + text-overflow: ellipsis; + } + + .dash-cell-date-signup { + width: 25%; + } + .dash-cell-date-active { + width: 25%; + } + + .dash-cell-actions { + display: none; + text-align: right; + + .btn { + text-decoration: none; + } + } + + .dash-cell-email-date { + font-size: $font-size-sm; + + @include text-truncate; + } + + .dash-cell-tag, + .clone-user-tag { + .badge-tag { + margin-top: var(--spacing-06); + margin-bottom: var(--spacing-06); + + &:first-child { + margin-left: initial !important; + } + } + } + + @include media-breakpoint-up(sm) { + .dash-cell-checkbox { + width: 4%; + } + + .dash-cell-name { + width: 36%; + } + + .dash-cell-email { + width: 36%; + } + + .dash-cell-date-signup { + width: 12%; + } + + .dash-cell-date-active { + width: 12%; + } + + .dash-cell-actions { + width: 0%; + } + } + + @include media-breakpoint-up(md) { + .dash-cell-checkbox { + width: 3%; + } + + .dash-cell-name { + width: 23%; + } + + .dash-cell-email { + width: 28%; + } + + .dash-cell-date-signup { + width: 17%; + } + + .dash-cell-date-active { + width: 17%; + } + + .dash-cell-actions { + display: table-cell; + width: 12%; + } + + .user-tools { + float: none; + } + } + + @include media-breakpoint-up(lg) { + .dash-cell-checkbox { + width: 3%; + } + + .dash-cell-name { + width: 24%; + } + + .dash-cell-email { + width: 29%; + } + + .dash-cell-date-signup { + width: 17%; + } + + .dash-cell-date-active { + width: 17%; + } + + .dash-cell-actions { + width: 12%; + } + + tbody { + .dash-cell-actions { + white-space: nowrap; + } + } + } + + @include media-breakpoint-up(xl) { + .dash-cell-checkbox { + width: 3%; + } + + .dash-cell-name { + width: 24%; + } + + .dash-cell-email { + width: 29%; + } + + .dash-cell-date-signup { + width: 17%; + } + + .dash-cell-date-active { + width: 17%; + } + + .dash-cell-actions { + width: 12%; + } + } + + @include media-breakpoint-up(xxl) { + .dash-cell-checkbox { + width: 3%; + } + + .dash-cell-name { + width: 24%; + } + + .dash-cell-email { + width: 29%; + } + + .dash-cell-date-signup { + width: 17%; + } + + .dash-cell-date-active { + width: 17%; + } + + .dash-cell-actions { + width: 12; + } + } + + @include media-breakpoint-down(md) { + tr { + position: relative; + display: flex; + flex-direction: column; + + td { + padding-top: var(--spacing-02); + padding-bottom: var(--spacing-02); + } + + td:not(.dash-cell-actions) { + padding-right: 55px; + } + } + + .dash-cell-name, + .dash-cell-email, + .dash-cell-date-signed, + .dash-cell-date-active, + .dash-cell-actions { + display: block; + width: auto; + } + + .dash-cell-actions { + position: absolute; + top: var(--spacing-04); + right: var(--spacing-04); + padding: 0 !important; + } + } +} + +/* stylelint-disable selector-class-pattern */ +.user-list-upload-user-modal-uppy-dashboard .uppy-Root { + .uppy-Dashboard-AddFiles-title { + display: flex; + flex-direction: column; + color: var(--neutral-60); + white-space: pre-line; + + @include body-base; + + button.uppy-Dashboard-browse { + @extend .btn; + @extend .btn-lg; + @extend .btn-primary; + + margin-bottom: var(--spacing-07); + } + } +} + +.survey-notification { + display: flex; + flex-wrap: wrap; + padding: var(--spacing-06); + background-color: var(--bg-dark-tertiary); + border-color: transparent; + color: var(--neutral-20); + box-shadow: 2px 4px 6px rgb(0 0 0 / 25%); + border-radius: var(--border-radius-base); + + @include media-breakpoint-up(md) { + flex-wrap: nowrap; + } + + button.close { + @extend .text-white; + + padding: 0; + } +} + +.user-list-sidebar-survey-wrapper { + .survey-notification { + font-size: var(--font-size-02); + + a { + text-decoration: none; + } + } + + @include media-breakpoint-down(md) { + .survey-notification { + font-size: unset; + + .user-list-sidebar-survey-link { + display: block; + align-items: center; + min-width: 48px; + min-height: 48px; + padding-top: var(--spacing-07); + } + } + } +} + +.user-list-load-more-button { + margin-bottom: var(--spacing-05); +} + +form.user-search { + .form-group { + margin-bottom: 0; + } +} diff --git a/services/web/frontend/stylesheets/modules/all.scss b/services/web/frontend/stylesheets/modules/all.scss index 01d58c8c20..805ae653a2 100644 --- a/services/web/frontend/stylesheets/modules/all.scss +++ b/services/web/frontend/stylesheets/modules/all.scss @@ -9,3 +9,5 @@ @import 'symbol-palette'; @import 'writefull'; @import 'labs'; +@import 'admin-panel/user-list'; +@import 'admin-panel/user-list-ds-nav'; diff --git a/services/web/locales/de.json b/services/web/locales/de.json index 890ab9508d..1e6860cbaa 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -25,6 +25,7 @@ "a_new_version_of_the_rolling_texlive_build_released": "Eine neue Version des Rolling TeX Live Build wurde veröffentlicht.", "about": "Über uns", "about_to_archive_projects": "Du bist dabei, die folgenden Projekte zu archivieren:", + "about_to_delete_accounts": "Du bist dabei, die Konten der folgenden Benutzer einschließlich aller ihrer Projekte zu löschen:", "about_to_delete_cert": "Du bist dabei, das folgende Zertifikat zu löschen:", "about_to_delete_projects": "Du bist dabei, die folgenden Projekte zu löschen:", "about_to_delete_tag": "Du bist dabei, das folgende Stichwort zu löschen (darin enthaltene Projekte werden nicht gelöscht):", @@ -35,8 +36,17 @@ "about_to_enable_managed_users": "Wenn du Verwaltete Benutzer aktivierst, werden alle Mitglieder deines Abonnements eingeladen, verwaltet zu werden. Dies gibt dir Administratorenrechte für ihre Konten. Du kannst dann auch neue verwaltete Mitglieder einladen.", "about_to_leave_project": "Du bist dabei, dieses Projekt zu verlassen.", "about_to_leave_projects": "Du bist dabei, die folgenden Projekte zu verlassen:", + "about_to_permanently_delete_accounts": "Du bist dabei, die folgenden Konten endgültig zu löschen:", + "about_to_permanently_delete_projects": "Du bist dabei, die folgenden Projekte endgültig zu löschen:", "about_to_remove_user_preamble": "Du bist dabei, __userName__ (__userEmail__) zu entfernen. Das bedeutet:", + "about_to_resend_activation_email": "Du bist dabei, die Aktivierungs-E-Mail an die folgenden Nutzer erneut zu senden:", + "about_to_restore_accounts": "Du bist dabei, die folgenden Benutzerkonten wiederherzustellen:", + "about_to_restore_projects": "Du bist dabei, die folgenden Projekte wiederherzustellen:", + "about_to_resume_accounts": "Du bist dabei, den Zugriff für die folgenden Benutzer wieder freizugeben:", + "about_to_set_admin_accounts": "Du bist dabei, Administratorrechte für die folgenden Benutzer zu vergeben:", + "about_to_suspend_accounts": "Du bist dabei, die folgenden Konten zu sperren:", "about_to_trash_projects": "Du bist dabei, die folgenden Projekte in den Papierkorb zu verschieben:", + "about_to_unset_admin_accounts": "Du bist dabei, Administratorrechte bei den folgenden Benutzern zu entziehen:", "abstract": "Abstrakt", "accept": "Akzeptieren", "accept_all_cookies": "Alle Cookies akzeptieren", @@ -62,6 +72,7 @@ "account_has_been_link_to_institution_account": "Dein __appName__-Konto mit der E-Mail-Adresse __email__ wurde mit dem Konto deiner Institution (__institutionName__) verknüpft.", "account_has_past_due_invoice_change_plan_warning": "Für dein Konto ist eine Rechnung überfällig. Bitte begleiche sie, bevor du dein Abonnement änderst.", "account_help": "Konto und Hilfe", + "account_information": "Kontoinformationen", "account_linking": "Kontoverknüpfung", "account_managed_by_group_administrator": "Dein Konto wird von deinem Gruppenverwalter (__admin__) verwaltet.", "account_managed_by_group_teamname": "Dieses __appName__-Konto wird von <0>__teamName__ verwaltet.", @@ -73,6 +84,7 @@ "activate": "Aktivieren", "activate_account": "Aktiviere dein Konto", "activating": "Aktivierung", + "activation_link": "Aktivierungslink", "activation_token_expired": "Dein Aktivierungs-Token ist abgelaufen, bitte fordere einen neuen an.", "active": "Aktiv", "add": "Hinzufügen", @@ -163,6 +175,7 @@ "all_templates": "Alle Vorlagen", "all_the_pros_of_our_standard_plan_plus_unlimited_collab": "Alle Vorteile unseres Standard-Abonnements, plus unbegrenzt viele Mitarbeiter pro Projekt.", "all_these_experiments_are_available_exclusively": "All diese Experimente sind exklusiv für Mitglieder des Labs-Programms verfügbar. Wenn du dich anmeldest, kannst du auswählen, welche Experimente du ausprobieren möchtest.", + "all_users": "Alle Benutzer", "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "Ermöglicht die Suche nach Autor, Titel usw. Ergebnisse können direkt aus deinem Literaturverwaltungsprogramm (falls verbunden) übernommen werden.", "already_have_an_account": "Hast du bereits ein Konto?", "already_have_sl_account": "Hast du bereits ein __appName__-Konto?", @@ -225,6 +238,7 @@ "back_to_editor": "Zurück zum Editor", "back_to_log_in": "Zurück zur Anmeldung", "back_to_subscription": "Zurück zum Abonnement", + "back_to_user_list": "Zurück zur Benutzerliste", "back_to_your_projects": "Zurück zu deinen Projekten", "beta": "Beta", "beta_feature_badge": "Betafunktionsmerkmal", @@ -393,6 +407,7 @@ "coupon_code_is_not_valid_for_selected_plan": "Der Gutscheincode ist nicht gültig für das gewählte Abonnement", "coupons_not_included": "Dies beinhaltet nicht deine aktuellen Rabatte, die automatisch vor deiner nächsten Zahlung angewandt werden", "create": "Erstellen", + "create_account": "Konto erstellen", "create_a_new_password_for_your_account": "Erstelle ein neues Passwort für dein Konto", "create_a_new_project": "Erstelle ein neues Projekt", "create_account": "Konto erstellen", @@ -430,6 +445,7 @@ "delete_account": "Konto löschen", "delete_account_confirmation_label": "Ich verstehe, dass dadurch alle Projekte in meinem __appName__-Konto mit der E-Mail-Adresse <0>__userDefaultEmail__ gelöscht werden", "delete_account_warning_message_3": "Du bist dabei, alle Kontodaten permanent zu löschen, inklusive Projekte und Einstellungen. Bitte gib die E-Mail-Adresse und das Passwort deines Kontos in die Felder ein um fortzufahren.", + "delete_accounts": "Konten löschen", "delete_acct_no_existing_pw": "Bitte verwende das Formular zum Zurücksetzen des Passworts, um ein Passwort festzulegen, bevor du dein Konto löschst", "delete_and_leave": "Löschen/Verlassen", "delete_and_leave_projects": "Projekte löschen und verlassen", @@ -438,14 +454,15 @@ "delete_certificate": "Zertifikat löschen", "delete_comment": "Kommentar löschen", "delete_figure": "Abbildung löschen", - "delete_projects": "Projekte löschen", "delete_tag": "Stichwort löschen", "delete_template": "Vorlage löschen", "delete_token": "Token löschen", + "delete_projects": "Projekte löschen", "delete_user": "Nutzer löschen", "delete_your_account": "Lösche dein Konto", "deleted_at": "Gelöscht am", "deleted_by_on": "Gelöscht von __name__ am __date__", + "deleted_projects": "Gelöschte Projekte", "deleting": "Löschen", "demonstrating_git_integration": "Demonstration der Git-Integration", "department": "Abteilung", @@ -600,6 +617,7 @@ "files_cannot_include_invalid_characters": "Der Dateiname ist leer oder enthält ungültige Zeichen", "files_selected": "Dateien ausgewählt.", "filter_projects": "Projekte filtern", + "filter_users": "Benutzer filtern", "filters": "Filter", "find_out_more": "Finde mehr heraus", "find_out_more_about_institution_login": "Erfahre mehr über den institutionellen Login", @@ -776,6 +794,7 @@ "in_order_to_have_a_secure_account_make_sure_your_password": "Um dein Konto abzusichern, stelle sicher, dass dein Passwort", "in_order_to_match_institutional_metadata_2": "Um deine institutionellen Metadaten abzugleichen, haben wir dein Konto mit <0>__email__ verknüpft.", "in_order_to_match_institutional_metadata_associated": "Um deine institutionellen Metadaten abzugleichen, wird dein Konto mit der E-Mail-Adresse __email__ verknüpft.", + "inactive_projects": "Inaktive Projekte", "include_caption": "Beschriftung anzeigen", "include_label": "Label anzeigen", "increased_compile_timeout": "Zeitlimit beim Kompilieren erhöhen", @@ -1016,10 +1035,12 @@ "need_contact_group_admin_to_make_changes": "Du musst deinen Gruppenadministrator kontaktieren, wenn du bestimmte Änderungen an deinem Konto vornehmen möchtest. <0>Erfahre mehr über verwaltete Benutzer.", "need_to_add_new_primary_before_remove": "Du musst eine neue primäre E-Mail-Adresse hinzufügen, bevor du diese entfernen kannst.", "need_to_leave": "Du musst gehen?", + "never": "Nie", "new_editor": "Neuer Editor", "new_file": "Neue Datei", "new_folder": "Neuer Ordner", "new_name": "Neuer Name", + "new_owner": "Neuer Besitzer", "new_password": "Neues Passwort", "new_project": "Neues Projekt", "new_snippet_project": "Ohne Titel", @@ -1057,6 +1078,7 @@ "no_templates_found": "Keine Vorlagen gefunden.", "no_thanks_cancel_now": "Nein, danke - Ich möchte nach wie vor jetzt stornieren", "no_update_email": "Nein, E-Mail-Adresse aktualisieren", + "no_users": "Keine Benutzer", "non_blinking_cursor": "Nicht blinkender Cursor", "normal": "Normal", "normally_x_price_per_month": "Normalerweise __price__ pro Monat", @@ -1072,6 +1094,7 @@ "notification_personal_subscription_not_required_due_to_affiliation": "Gute Nachrichten! Deine angeschlossene Organisation __institutionName__ hat eine Partnerschaft mit Overleaf und du hast jetzt über deine Zugehörigkeit Zugriff auf die „Professionell“-Funktionen von Overleaf. Du kannst dein persönliches Abonnement kündigen, o", "notification_project_invite_accepted_message": "Du bist __projectName__ beigetreten", "notification_project_invite_message": "__userName__ möchte, dass du __projectName__ beitrittst", + "notify_users_about_account_deletion": "Benutzer über die Kontolöschung benachrichtigen", "november": "November", "number_collab": "Anzahl der Mitarbeiter", "number_of_users": "Nutzeranzahl", @@ -1106,7 +1129,9 @@ "overleaf_learning_center": "Overleaf-Lernzentrum", "overview": "Überblick", "owned_by_x": "Besitzer: __x__", + "owned_projects": "Eigene Projekte", "owner": "Besitzer", + "ownership_of_projects_will_be_transferred": "Der Besitz der folgenden Projekte wird auf einen anderen Benutzer übertragen:", "page_current": "Seite __page__, Aktuelle Seite", "page_not_found": "Seite nicht gefunden", "pagination_navigation": "Seitenumbruch-Navigation", @@ -1136,6 +1161,8 @@ "per_month": "pro Monat", "per_user_year": "pro Nutzer / Jahr", "per_year": "pro Jahr", + "permanently_delete_accounts": "Konten endgültig löschen", + "permanently_delete_projects": "Projekte endgültig löschen:", "personal": "Persönlich", "personalized_onboarding": "Personalisiertes Onboarding", "pl": "Polnisch", @@ -1211,6 +1238,7 @@ "publish_as_template": "Als Vorlage veröffentlichen", "publishing": "Veröffentlichen", "pull_github_changes_into_sharelatex": "GitHub-Änderungen nach __appName__ ziehen", + "purge": "Vernichten", "push_sharelatex_changes_to_github": "__appName__-Änderungen an GitHub senden", "raw_logs": "Raw Logs", "raw_logs_description": "Raw Logs vom LaTeX-Compiler", @@ -1278,6 +1306,7 @@ "requesting_password_reset": "Zurücksetzen des Passworts anfordern", "required": "Erforderlich", "resend": "Sende erneut", + "resend_activation_email": "Aktivierungs-E-Mail erneut senden", "resend_confirmation_code": "Bestätigungscode erneut senden", "resend_managed_user_invite": "Einladung zu Verwaltete Benutzer erneut senden", "resending_confirmation_code": "Bestätigungscode wird erneut gesendet", @@ -1286,9 +1315,13 @@ "resolve": "Lösen", "resolved_comments": "Gelöste Kommentare", "restore": "Wiederherstellen", + "restore_accounts": "Konten wiederherstellen", + "restore_projects": "Projekte wiederherzustellen:", "restoring": "Wiederherstellen", "restricted": "Geschützt", "restricted_no_permission": "Entschuldigung, du hast nicht die Berechtigung, diese Seite anzuzeigen.", + "resume": "Freigeben", + "resume_account": "Konto wieder freigeben", "return_to_login_page": "Zurück zur Login-Seite", "reverse_x_sort_order": "Sortierreihenfolge für __x__ umkehren", "revert_pending_plan_change": "Abonnement-Änderung rückgängig machen", @@ -1328,8 +1361,10 @@ "security": "Sicherheit", "see_your_current_location_in_the_project": "Zeige deinen aktuellen Standort im Projekt an", "select_a_file": "Datei auswählen", + "select_a_new_owner_for_projects": "Neuen Besitzer für die Projekte dieses Benutzers auswählen", "select_a_project": "Projekt auswählen", "select_all_projects": "Alle Projekte auswählen", + "select_all_users": "Alle Benutzer auswählen", "select_an_output_file": "Ausgabedatei auswählen", "select_color": "Farbe __name__ auswählen", "select_from_output_files": "aus Ausgabedateien auswählen", @@ -1338,9 +1373,12 @@ "select_project": "__project__ auswählen", "select_projects": "Projekte auswählen", "select_tag": "Stichwort __tagName__ auswählen", + "select_user": "Benutzer auswählen", + "select_users": "Benutzer auswählen", "selected": "Ausgewählt", "selected_by_overleaf_staff": "Ausgewählt von Overleaf-Mitarbeitern", "send": "Absenden", + "send_notification_emails_to_users": "Eine Benachrichtigung an den aktuellen und den neuen Besitzer senden", "send_confirmation_code": "Bestätigungscode senden", "send_test_email": "Test-Mail senden", "sending": "Wird gesendet", @@ -1351,6 +1389,8 @@ "session_error": "Sitzungsfehler. Bitte überprüfe, ob Cookies aktiviert sind. Wenn das Problem weiterhin besteht, versuche, deinen Cache und deine Cookies zu löschen.", "session_expired_redirecting_to_login": "Sitzung abgelaufen. Du wirst in __seconds__ Sekunden auf die Anmeldungsseite umgeleitet", "sessions": "Sessions", + "set_admin": "Admin setzen", + "set_admin_account": "Administratorrechte vergeben", "set_color": "Farbe festlegen", "set_new_password": "Neues Passwort eingeben", "set_password": "Passwort setzen", @@ -1361,19 +1401,23 @@ "shared_with_you": "Mit dir geteilt", "sharelatex_beta_program": "__appName__ Beta-Programm", "show_all_projects": "Alle Projekte anzeigen", + "show_all_users": "Alle Benutzer anzeigen", "show_in_code": "Im Code anzeigen", "show_in_pdf": "Im PDF anzeigen", "show_less": "Weniger anzeigen", "show_live_equation_previews_while_typing": "Live-Gleichungsvorschau beim Tippen anzeigen", "show_outline": "Dateigliederung anzeigen", "show_x_more_projects": "__x__ weitere Projekte anzeigen", + "show_x_more_users": "__x__ weitere Benutzer anzeigen", "showing_1_result": "1 Ergebnis wird angezeigt", "showing_1_result_of_total": "Zeige 1 Ergebnis von __total__", "showing_x_out_of_n_projects": "Es werden __x__ von __n__ Projekten angezeigt.", + "showing_x_out_of_n_users": "Es werden __x__ von __n__ Benutzern angezeigt", "showing_x_results": "Es werden __x__ Ergebnisse angezeigt", "showing_x_results_of_total": "Es werden __x__ Ergebnisse von __total__ angezeigt", "single_sign_on_sso": "Single Sign-On (SSO)", "site_description": "Ein einfach bedienbarer Online-LaTeX-Editor. Keine Installation notwendig, Zusammenarbeit in Echtzeit, Versionskontrolle, Hunderte von LaTeX-Vorlagen und mehr", + "signed_up": "Angemeldet", "skip_to_content": "Zum Inhalt springen", "solutions": "Lösungen", "something_went_wrong_canceling_your_subscription": "Beim Kündigen deines Abonnements ist etwas schief gelaufen. Bitte wende dich an den Support.", @@ -1429,6 +1473,8 @@ "sure_you_want_to_change_plan": "Bist du sicher, dass du zum Abonnement <0>__planName__ wechseln möchtest?", "sure_you_want_to_delete": "Möchtest du die folgenden Dateien wirklich löschen?", "sure_you_want_to_leave_group": "Bist du sicher, dass du diese Gruppe verlassen möchtest?", + "suspend": "Sperren", + "suspend_account": "Konto sperren", "sv": "Schwedisch", "switch_compile_mode_for_faster_draft_compilation": "Kompiliermodus für schnellere Entwurfskompilierung wechseln", "symbol_palette": "Symbolpalette", @@ -1486,6 +1532,7 @@ "there_was_an_error_opening_your_content": "Beim Erstellen deines Projekts ist ein Fehler aufgetreten", "these_settings_might_change_in_the_future": "Diese Einstellungen können sich in Zukunft ändern.", "thesis": "Doktorarbeit", + "this_action_can_be_undone_within_limited_period": "Diese Aktion kann nur innerhalb eines begrenzten Zeitraums rückgängig gemacht werden.", "this_action_cannot_be_undone": "Diese Aktion kann nicht rückgängig gemacht werden.", "this_field_is_required": "Dieses Feld wird benötigt", "this_grants_access_to_features_2": "Dadurch erhältst du Zugriff auf die <0>__featureType__ Funktionen von <0>__appName__.", @@ -1526,6 +1573,7 @@ "track_changes": "Änderungen verfolgen", "tracked_change_added": "Hinzugefügt", "tracked_change_deleted": "Gelöscht", + "transfer_all_projects_to": "Alle Projekte der Benutzer übertragen an:", "trash": "Löschen", "trash_projects": "Lösche Projekte", "trashed_projects": "Gelöschte Projekte", @@ -1579,6 +1627,8 @@ "unlinking": "Verknüpfung wird aufgehoben", "unpublish": "Veröffentlichung aufheben", "unpublishing": "Veröffentlichung aufheben", + "unset_admin": "Admin entfernen", + "unset_admin_account": "Administratorrechte entziehen", "unsubscribe": "Abbestellen", "unsubscribed": "Abbestellt", "unsubscribing": "Abbestellen läuft", @@ -1602,12 +1652,24 @@ "use_a_different_email": "Verwende eine <0>andere E-Mail-Adresse.", "use_a_different_password": "Bitte verwende ein anderes Passwort", "use_your_own_machine": "Verwende deine eigene Maschine mit deinem eigenen Setup", + "user_activity": "Benutzeraktivität", "user_already_added": "Nutzer bereits hinzugefügt", + "user_categories": "Benutzerkategorien", + "user_category_admin": "Administratoren", + "user_category_all": "Alle Benutzer", + "user_category_deleted": "Gelöschte Benutzer", + "user_category_inactive": "Inaktive Benutzer", + "user_category_ldap": "LDAP-Benutzer", + "user_category_local": "Lokale Benutzer", + "user_category_oidc": "OIDC-Benutzer", + "user_category_saml": "SAML-Benutzer", + "user_category_suspended": "Gesperrte Benutzer", "user_deletion_error": "Entschuldigung, beim Löschen deines Kontos ist etwas schief gelaufen. Bitte versuche es in einer Minute erneut.", "user_deletion_password_reset_tip": "Wenn du dich nicht mehr an dein Passwort erinnern kannst oder wenn du Single-Sign-On mit einem anderen Anbieter verwendest, um dich anzumelden (z.B. ORCID oder Google), <0>setze dein Passwort zurück und versuche es erneut.", "user_management": "Nutzerverwaltung", "user_not_found": "Nutzer wurde nicht gefunden", "user_wants_you_to_see_project": "__username__ möchte, dass Du __projectname__ beitreten", + "users_list": "Benutzerliste", "validation_issue_entry_description": "Ein Validierungsproblem, das die Kompilierung dieses Projekts verhindert hat", "vat": "MwSt.", "vat_number": "Umsatzsteuernummer", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 23bb97bf2f..6fb43afd73 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -26,6 +26,7 @@ "a_new_version_of_the_rolling_texlive_build_released": "A new version of the Rolling TeX Live build has been released.", "about": "About", "about_to_archive_projects": "You are about to archive the following projects:", + "about_to_delete_accounts": "You are about to delete the accounts of the following users, including all their projects:", "about_to_delete_cert": "You are about to delete the following certificate:", "about_to_delete_projects": "You are about to delete the following projects:", "about_to_delete_tag": "You are about to delete the following tag (any projects in them will not be deleted):", @@ -36,8 +37,17 @@ "about_to_enable_managed_users": "By enabling the Managed Users feature, all existing members of your group subscription will be invited to become managed. This will give you admin rights over their account. You will also have the option to invite new members to join the subscription and become managed.", "about_to_leave_project": "You are about to leave this project.", "about_to_leave_projects": "You are about to leave the following projects:", + "about_to_permanently_delete_accounts": "You are about to permanently delete the following accounts:", + "about_to_permanently_delete_projects": "You are about to permanently delete the following projects:", "about_to_remove_user_preamble": "You’re about to remove __userName__ (__userEmail__). Doing this will mean:", + "about_to_resend_activation_email": "You are about to resend the activation email to the following users:", + "about_to_restore_accounts": "You are about to restore the following user accounts:", + "about_to_restore_projects": "You are about to restore the following projects:", + "about_to_resume_accounts": "You are about to resume access for the following users:", + "about_to_set_admin_accounts": "You are about to grant admin privileges to the following users:", + "about_to_suspend_accounts": "You are about to suspend the following accounts:", "about_to_trash_projects": "You are about to trash the following projects:", + "about_to_unset_admin_accounts": "You are about to revoke admin privileges from the following users:", "abstract": "Abstract", "accept": "Accept", "accept_all_cookies": "Accept all cookies", @@ -66,6 +76,7 @@ "account_has_been_link_to_institution_account": "Your __appName__ account on __email__ has been linked to your __institutionName__ institutional account.", "account_has_past_due_invoice_change_plan_warning": "Your account currently has a past due invoice. You will not be able to change your plan until this is resolved.", "account_help": "Account and help", + "account_information": "Account information", "account_linking": "Account linking", "account_managed_by_group_administrator": "Your account is managed by your group administrator (__admin__)", "account_managed_by_group_teamname": "This __appName__ account is managed by <0>__teamName__.", @@ -77,6 +88,7 @@ "activate": "Activate", "activate_account": "Activate your account", "activating": "Activating", + "activation_link": "Activation link", "activation_token_expired": "Your activation token has expired, you will need to get another one sent to you.", "active": "Active", "add": "Add", @@ -185,6 +197,7 @@ "all_templates": "All templates", "all_the_pros_of_our_standard_plan_plus_unlimited_collab": "All the pros of our standard plan, plus unlimited collaborators per project.", "all_these_experiments_are_available_exclusively": "All these experiments are available exclusively to members of the Labs program. If you sign up, you can choose which experiments you want to try.", + "all_users": "All users", "allocate_license": "Allocate license", "allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "Allows to search by author, title, etc. Possible to pull results directly from your reference manager (if connected).", "already_have_an_account": "Already have an account?", @@ -264,6 +277,7 @@ "back_to_my_projects": "Back to my projects", "back_to_pdf": "Back to PDF", "back_to_subscription": "Back to subscription", + "back_to_user_list": "Back to user list", "back_to_your_projects": "Back to your projects", "basic": "Basic", "basic_ai_allowance": "Basic AI allowance", @@ -588,6 +602,7 @@ "delete_account": "Delete account", "delete_account_confirmation_label": "I understand this will delete all projects in my __appName__ account with email address <0>__userDefaultEmail__", "delete_account_warning_message_3": "You are about to permanently delete all of your account data, including your projects and settings. Please type your account email address and password in the boxes below to proceed.", + "delete_accounts": "Delete accounts", "delete_acct_no_existing_pw": "Please use the password reset form to set a password before deleting your account", "delete_and_leave": "Delete / Leave", "delete_and_leave_projects": "Delete and Leave Projects", @@ -618,6 +633,7 @@ "deleted_by_id": "Deleted By ID", "deleted_by_ip": "Deleted By IP", "deleted_by_on": "Deleted by __name__ on __date__", + "deleted_projects": "Deleted projects", "deleted_user": "Deleted user", "deleting": "Deleting", "demonstrating_git_integration": "Demonstrating Git integration", @@ -903,6 +919,7 @@ "files_cannot_include_invalid_characters": "File name is empty or contains invalid characters", "files_selected": "files selected.", "filter_projects": "Filter projects", + "filter_users": "Filter users", "filters": "Filters", "find": "Find", "find_and_check_citations": "Find and check citations", @@ -1216,6 +1233,7 @@ "in_order_to_have_a_secure_account_make_sure_your_password": "To help keep your account secure, make sure your new password:", "in_order_to_match_institutional_metadata_2": "In order to match your institutional metadata, we’ve linked your account using <0>__email__.", "in_order_to_match_institutional_metadata_associated": "In order to match your institutional metadata, your account is associated with the email __email__.", + "inactive_projects": "Inactive projects", "include_caption": "Include caption", "include_label": "Include label", "include_results_from_your_reference_manager": "Include results from your reference manager", @@ -1623,11 +1641,13 @@ "need_to_add_new_primary_before_remove": "You’ll need to add a new primary email address before you can remove this one.", "need_to_leave": "Need to leave?", "neither_agree_nor_disagree": "Neither agree nor disagree", + "never": "Never", "new_compile_domain_notice": "Something might be blocking your browser from accessing Overleaf’s PDF download location, <0>__compilesUserContentDomain__. This could be caused by network blocking or a strict browser plugin rule. Please follow our <1>troubleshooting guide.", "new_compiles_in_this_project_will_automatically_use_the_newest_version": "New compiles in this project will automatically use the newest version. <0>Learn how to change compiler settings", "new_file": "New file", "new_folder": "New folder", "new_name": "New name", + "new_owner": "New owner", "new_password": "New password", "new_project": "New project", "new_reference": "__count__ new reference", @@ -1683,6 +1703,7 @@ "no_templates_found": "No templates found.", "no_thanks_cancel_now": "No thanks, I still want to cancel", "no_update_email": "No, update email", + "no_users": "No users", "non_blinking_cursor": "Non-blinking cursor", "non_deletable_entity": "The specified entity may not be deleted", "normal": "Normal", @@ -1704,6 +1725,7 @@ "notification_personal_subscription_not_required_due_to_affiliation": " Good news! Your affiliated organization __institutionName__ has an Overleaf subscription, and you now have access to Overleaf premium features through your affiliation. You can cancel your individual subscription without losing access to any features.", "notification_project_invite_accepted_message": "You’ve joined __projectName__", "notification_project_invite_message": "__userName__ would like you to join __projectName__", + "notify_users_about_account_deletion": "Notify users about account deletion", "november": "November", "number_collab": "Number of collaborators", "number_collab_info": "The number of people you can invite to work on a project with you. The limit is per project, so you can invite different people to each project.", @@ -1796,7 +1818,9 @@ "overwrite": "Overwrite", "overwriting_the_original_folder": "Overwriting the original folder will delete it and all the files it contains.", "owned_by_x": "owned by __x__", + "owned_projects": "User's projects", "owner": "Owner", + "ownership_of_projects_will_be_transferred": "Ownership of the following projects will be transferred to another user:", "page_current": "Page __page__, Current Page", "page_not_found": "Page Not Found", "page_x_of_n": "Page __x__ of __n__", @@ -1873,6 +1897,8 @@ "per_year": "per year", "percent_is_the_percentage_of_the_line_width": "% is the percentage of the line width", "performance": "Performance", + "permanently_delete_accounts": "Permanently delete accounts", + "permanently_delete_projects": "Permanently delete projects", "permanently_disables_the_preview": "Permanently disables the preview", "personal": "Personal", "personal_library": "Personal library", @@ -2020,6 +2046,7 @@ "publisher_account": "Publisher Account", "publishing": "Publishing", "pull_github_changes_into_sharelatex": "Pull GitHub changes into __appName__", + "purge": "Purge", "push_sharelatex_changes_to_github": "Push __appName__ changes to GitHub", "push_to_github_pull_to_overleaf": "Push to GitHub, pull to __appName__", "quoted_text": "Quoted text", @@ -2145,6 +2172,7 @@ "requesting_password_reset": "Requesting password reset", "required": "Required", "resend": "Resend", + "resend_activation_email": "Resend activation email", "resend_confirmation_code": "Resend confirmation code", "resend_group_invite": "Resend group invite", "resend_invite": "Resend invite", @@ -2163,6 +2191,8 @@ "resolved_comments": "Resolved comments", "resources": "Resources", "restore": "Restore", + "restore_accounts": "Restore accounts", + "restore_projects": "Restore projects", "restore_file": "Restore file", "restore_file_confirmation_message": "Your current file will restore to the version from __date__ at __time__.", "restore_file_confirmation_title": "Restore this version?", @@ -2174,6 +2204,8 @@ "restoring": "Restoring", "restricted": "Restricted", "restricted_no_permission": "Restricted, sorry you don’t have permission to load this page.", + "resume": "Resume", + "resume_account": "Resume account", "resync_completed": "Resync completed!", "resync_message": "Resyncing project history can take several minutes depending on the size of the project.", "resync_project_history": "Resync Project History", @@ -2239,6 +2271,7 @@ "search_command_replace": "Replace", "search_in_all_projects": "Search in all projects", "search_in_archived_projects": "Search in archived projects", + "search_in_deleted_projects": "Search in deleted projects", "search_in_shared_projects": "Search in projects shared with you", "search_in_trashed_projects": "Search in trashed projects", "search_in_your_projects": "Search in your projects", @@ -2279,6 +2312,7 @@ "select_all": "Select all", "select_all_entries": "Select all entries", "select_all_projects": "Select all projects", + "select_all_users": "Select all users", "select_an_output_file": "Select an Output File", "select_an_output_file_figure_modal": "Select an output file", "select_bib_file": "Select .bib file", @@ -2300,6 +2334,7 @@ "select_tax_id_type": "Select tax ID type", "select_user": "Select user", "select_user_role": "Select user role", + "select_users": "Select users", "selected": "Selected", "selected_by_overleaf_staff": "Selected by Overleaf staff", "selected_lowercase": "selected", @@ -2307,6 +2342,7 @@ "selected_plural": "selected", "selection_deleted": "Selection deleted", "send": "Send", + "send_notification_emails_to_users": "Send notification emails to the current and the new owners", "send_confirmation_code": "Send confirmation code", "send_message": "Send message", "send_request": "Send request", @@ -2326,6 +2362,8 @@ "session_expired_redirecting_to_login": "Session Expired. Redirecting to login page in __seconds__ seconds", "sessions": "Sessions", "set_as_main_document": "Set as main document", + "set_admin": "Set admin", + "set_admin_account": "Grant admin privileges", "set_color": "set color", "set_column_width": "Set column width", "set_new_password": "Set new password", @@ -2344,6 +2382,7 @@ "sharing_permissions": "Sharing permissions", "shortcut_to_open_advanced_reference_search": "(__ctrlSpace__ or __altSpace__)", "show_all_projects": "Show all projects", + "show_all_users": "Show all users", "show_breadcrumbs": "Show breadcrumbs", "show_document_preamble": "Show document preamble", "show_equation_preview": "Show equation preview", @@ -2357,6 +2396,7 @@ "show_outline": "Show File outline", "show_version_history": "Show version history", "show_x_more_projects": "Show __x__ more projects", + "show_x_more_users": "Show __x__ more users", "showing_1_result": "Showing 1 result", "showing_1_result_of_total": "Showing 1 result of __total__", "showing_pdf_preview_with_inverted_colors": "Showing PDF preview with inverted colors", @@ -2368,6 +2408,7 @@ "sign_up": "Sign up", "sign_up_for_free": "Sign up for free", "sign_up_for_free_account": "Sign up for a free account and receive regular updates", + "signed_up": "Signed up", "simple_pricing_for_individuals_and_teams": "Simple pricing for individuals and teams", "simple_search_mode": "Simple search", "single_sign_on_sso": "Single Sign-On (SSO)", @@ -2534,6 +2575,8 @@ "sure_you_want_to_change_plan": "Are you sure you want to change plan to <0>__planName__?", "sure_you_want_to_delete": "Are you sure you want to permanently delete the following files?", "sure_you_want_to_leave_group": "Are you sure you want to leave this group?", + "suspend": "Suspend", + "suspend_account": "Suspend account", "sv": "Swedish", "switch_compile_mode_for_faster_draft_compilation": "Switch compile mode for faster draft compilation", "switch_plans_whenever_your_needs_change": "Switch plans whenever your needs change.", @@ -2653,6 +2696,7 @@ "they_will_retain_ownership_of_projects_currently_owned_by_them_and_collaborators_will_become_read_only": "They will retain ownership of projects currently owned by them and any collaborators on those projects will become read-only.", "they_will_retain_their_existing_account_on_the_free_plan": "They will retain their existing account on the __appName__ free plan.", "they_wont_be_able_to_log_in_with_sso_they_will_need_to_set_password": "They won’t be able to log in with SSO (if you have this enabled). They will need to set an __appName__ password.", + "this_action_can_be_undone_within_limited_period": "This action can be undone only within a limited period.", "this_action_cannot_be_reversed": "This action cannot be reversed.", "this_action_cannot_be_undone": "This action cannot be undone.", "this_action_will_also_disable_domain_capture": "This action will also disable domain capture.", @@ -2781,6 +2825,7 @@ "track_changes_explanation": "Make and see track changes.", "tracked_change_added": "Added", "tracked_change_deleted": "Deleted", + "transfer_all_projects_to": "Transfer all projects of the users to:", "transfer_management_of_your_account": "Transfer management of your Overleaf account", "transfer_management_of_your_account_to_x": "Transfer management of your Overleaf account to __groupName__", "transfer_management_resolve_following_issues": "To transfer the management of your account, you need to resolve the following issues:", @@ -2792,6 +2837,7 @@ "trashed": "Trashed", "trashed_projects": "Trashed projects", "trashing_projects_wont_affect_collaborators": "Trashing projects won’t affect your collaborators.", + "trashing_projects_wont_affect_user_collaborators": "Trashing projects won’t affect user collaborators.", "trial_last_day": "This is the last day of your Overleaf Premium trial", "trial_remaining_days": "__days__ more days on your Overleaf Premium trial", "tried_to_log_in_with_email": "You’ve tried to log in with __email__.", @@ -2867,6 +2913,8 @@ "unpausing": "Unpausing", "unpublish": "Unpublish", "unpublishing": "Unpublishing", + "unset_admin": "Unset admin", + "unset_admin_account": "Revoke admin privileges", "unsubscribe": "Unsubscribe", "unsubscribed": "Unsubscribed", "unsubscribing": "Unsubscribing", @@ -2919,10 +2967,21 @@ "used_latex_response_occasionally": "I’ve used it occasionally", "used_latex_response_often": "I use it often", "used_when_referring_to_the_figure_elsewhere_in_the_document": "Used when referring to the figure elsewhere in the document", + "user_activity": "User activity", "user_administration": "User administration", "user_administration_and_usage_metrics": "User administration and usage metrics", "user_administration_explanation": "Dashboard for adding and removing users on a subscription, and usage metrics", "user_already_added": "User already added", + "user_categories": "User categories", + "user_category_admin": "Administrators", + "user_category_all": "All users", + "user_category_deleted": "Deleted users", + "user_category_inactive": "Inactive users", + "user_category_ldap": "LDAP users", + "user_category_local": "Local users", + "user_category_oidc": "OIDC users", + "user_category_saml": "SAML users", + "user_category_suspended": "Suspended users", "user_deletion_error": "Sorry, something went wrong deleting your account. Please try again in a minute.", "user_deletion_password_reset_tip": "If you cannot remember your password, or if you are using Single-Sign-On with another provider to sign in (such as ORCID or Google), please <0>reset your password and try again.", "user_email_attribute": "User email attribute", @@ -2935,6 +2994,7 @@ "user_not_found": "User not found", "user_sessions": "User Sessions", "user_wants_you_to_see_project": "__username__ would like you to join __projectname__", + "users_list": "Users list", "using_latex": "Using LaTeX", "using_premium_features": "Using premium features", "using_the_overleaf_editor": "Using the __appName__ Editor", diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json index edc5b4a77b..04373b1214 100644 --- a/services/web/locales/ru.json +++ b/services/web/locales/ru.json @@ -17,6 +17,7 @@ "a_more_comprehensive_list_of_keyboard_shortcuts": "Более полный лист горячих клавиш находится в <0>этом шаблоне проекта __appName__", "about": "О", "about_to_archive_projects": "Вы собираетесь архивировать следующие проекты:", + "about_to_delete_accounts": "Вы собираетесь удалить аккаунты следующих пользователей, включая все их проекты:", "about_to_delete_cert": "Вы собираетесь удалить следующий сертификат:", "about_to_delete_projects": "Следующие проекты будут удалены:", "about_to_delete_template": "Следующий шаблон будет удален:", @@ -24,6 +25,16 @@ "about_to_delete_the_following_projects": "Вы собираетесь удалить следующие проекты:", "about_to_leave_project": "Вы собираетесь покинуть этот проект:", "about_to_leave_projects": "Вы собираетесь покинуть следующие проекты:", + "about_to_permanently_delete_accounts": "Вы собираетесь окончательно удалить следующие аккаунты:", + "about_to_permanently_delete_projects": "Вы собираетесь окончательно удалить следующие проекты:", + "about_to_resend_activation_email": "Вы собираетесь повторно отправить письмо активации следующим пользователям:", + "about_to_restore_accounts": "Вы собираетесь восстановить следующие учетные записи:", + "about_to_restore_projects": "Вы собираетесь восстановить следующие проекты:", + "about_to_resume_accounts": "Вы собираетесь возобновить доступ к следующим учетным записям:", + "about_to_set_admin_accounts": "Вы собираетесь предоставить административные права следующим пользователям:", + "about_to_suspend_accounts": "Вы собираетесь заблокировать следующие учетные записи:", + "about_to_trash_projects": "Следующие проекты будут перемещены в корзину:", + "about_to_unset_admin_accounts": "Вы собираетесь отозвать административные права у следующих пользователей:", "abstract": "Аннотация", "accept": "Принять", "accept_and_continue": "Принять и продолжить", @@ -31,12 +42,14 @@ "accept_invitation": "Принять приглашение", "accepting_invite_as": "Вы принимаете приглашение как", "account": "Аккаунт", + "account_information": "Информация об аккаунте", "account_not_linked_to_dropbox": "Ваш аккаунт не синхронизирован с Dropbox", "account_settings": "Настройки профиля", "actions": "Действия", "activate": "Активировать", "activate_account": "Активируйте Ваш аккаунт", "activating": "Активация", + "activation_link": "Ссылка для активации", "activation_token_expired": "Срок действия Вашего ключа истёк. Вам необходимо запросить новый ключ активации.", "add": "Добавить", "added": "добавлены", @@ -45,6 +58,7 @@ "admin": "администратор", "all_projects": "Все проекты", "all_templates": "Все шаблоны", + "all_users": "Все пользователи", "already_have_sl_account": "Уже есть аккаунт __appName__?", "and": "и", "annual": "Цена за год", @@ -54,6 +68,7 @@ "auto_complete": "Автодополнение", "autocomplete": "Автозавершение", "autocomplete_references": "Автодополнение ссылок (внутри блока \\cite{})", + "back_to_user_list": "Назад к списку пользователей", "back_to_your_projects": "Назад к списку проектов", "basic": "Базовый", "basic_compile_timeout_on_fast_servers": "Базовый таймаут компиляции на быстрых серверах", @@ -112,6 +127,7 @@ "country": "Страна", "coupon_code": "код купона", "create": "Создать", + "create_account": "Создать аккаунт", "create_new_subscription": "Создать новую подписку", "create_project_in_github": "Создать проект на GitHub", "creating": "Создание", @@ -125,10 +141,13 @@ "delete": "Удалить", "delete_account": "Удалить аккаунт", "delete_account_warning_message_3": "Вы собираетесь удалить все данные Вашего аккаунта, включая все Ваши проекты и настройки. Пожалуйста, введите адрес электронной почты и пароль Вашего аккаунта в форму внизу для продолжения.", + "delete_accounts": "Удалить аккаунты", "delete_and_leave_projects": "Удалить или оставить проекты", "delete_projects": "Удалить проекты", "delete_template": "Удалить шаблон", "delete_your_account": "Удалить аккаунт", + "deleted_at": "Удалено", + "deleted_projects": "Удаленные проекты", "deleting": "Удаление", "disconnected": "Разъединен", "do_you_want_to_overwrite_it": "Перезаписать?", @@ -142,7 +161,8 @@ "dropbox_sync_description": "Синхронизируйте Ваши __appName__ проекты с Вашим Dropbox. Изменения в __appName__ автоматически сохраняются в Вашем Dropbox, и наоборот.", "edit_template": "Редактировать шаблон", "editing": "Редактор", - "email": "Адрес электронной почты", + "email": "E-mail", + "email_address": "Адрес электронной почты", "email_already_registered": "Этот адрес уже зарегистрирован.", "email_link_expired": "Срок действия ссылки истёк. Пожалуйста, повторите запрос!", "email_or_password_wrong_try_again": "Неверный адрес электронной почты или пароль. Пожалуйста, попробуйте снова", @@ -157,6 +177,8 @@ "features": "Возможности", "february": "Февраль", "files_cannot_include_invalid_characters": "Файлы не могут содержать символы ’*’ и ’/’", + "filter_projects": "Фильтровать проекты", + "filter_users": "Фильтровать пользователей", "first_name": "Имя", "folders": "Папки", "footer_about_us": "О нас", @@ -193,6 +215,7 @@ "import_to_sharelatex": "Импортировать в __appName__", "importing": "Импорт", "importing_and_merging_changes_in_github": "Импорт и слияние изменений в GitHub", + "inactive_projects": "Неактивные проекты", "info": "Информация", "institution": "Организация", "invalid_file_name": "Неверное имя файла", @@ -258,9 +281,11 @@ "nearly_activated": "Вы в одном шаге от активации Вашего аккаунта для __appName__!", "need_anything_contact_us_at": "Если у Вас есть какие-либо вопросы и пожелания, пожалуйста, пишите нам по адресу", "need_to_leave": "Удалить аккаунт?", + "never": "Никогда", "new_file": "Новый файл", "new_folder": "Новая папка", "new_name": "Введите название", + "new_owner": "Новый владелец", "new_password": "Новый пароль", "new_project": "Создать проект", "next_payment_of_x_collectected_on_y": "Следующий платёж в размере <0>__paymentAmmount__ будет списан <1>__collectionDate__", @@ -275,8 +300,10 @@ "no_search_results": "Ничего не найдено", "no_thanks_cancel_now": "Нет, спасибо - я хочу удалить сейчас", "no_templates_found": "Шаблоны не найдены.", + "no_users": "Нет пользователей", "normal": "нормальный", "not_now": "Не сейчас", + "notify_users_about_account_deletion": "Уведомить пользователей об удалении аккаунтов", "november": "Ноябрь", "october": "Октябрь", "off": "Откл.", @@ -288,7 +315,9 @@ "or": "или", "other_logs_and_files": "Другие логи и файлы", "over": "свыше", + "owned_projects": "Собственные проекты", "owner": "Владелец", + "ownership_of_projects_will_be_transferred": "Владение следующими проектами будет передано другому пользователю:", "page_not_found": "Страница не найдена", "password": "Пароль", "password_reset": "Сбросить пароль", @@ -296,6 +325,8 @@ "password_reset_token_expired": "Ваш код восстановления пароля истёк. Пожалуйста, запросите восстановление пароля по почте ещё раз и перейдите по ссылке в письме.", "pdf_viewer": "Просмотрщик PDF", "pending": "В ожидании", + "permanently_delete_accounts": "Окончательно удалить аккаунты", + "permanently_delete_projects": "Окончательно удалить проекты", "personal": "Личный", "pl": "Польский", "planned_maintenance": "Плановые работы", @@ -317,6 +348,7 @@ "problem_with_subscription_contact_us": "Возникли проблемы с Вашей подпиской. Пожалуйста, свяжитесь с нами, чтобы узнать подробности.", "processing": "обработка", "professional": "Профессионал", + "project_categories_tags": "Категории и теги проектов", "project_last_published_at": "В последний раз проект был опубликован", "project_name": "Название проекта", "project_not_linked_to_github": "Этот проект не связан ни с одним проектом на GitHub. Вы можете создать для него проект на GitHub:", @@ -324,12 +356,14 @@ "project_too_large_please_reduce": "В этом проекте слишком много текста. Пожалуйста, попробуйте уменьшить количество.", "project_url": "URL проекта", "projects": "Проекты", + "projects_list": "Список проектов", "pt": "Португальский", "public": "Открытый", "publish": "Опубликовать", "publish_as_template": "Создать шаблон", "publishing": "Публикация", "pull_github_changes_into_sharelatex": "Скачать изменения с GitHub в __appName__", + "purge": "Уничтожить", "push_sharelatex_changes_to_github": "Загрузить изменения из __appName__ на GitHub", "recent_commits_in_github": "Последние коммиты на GitHub", "recompile": "Компилировать", @@ -352,13 +386,18 @@ "republish": "Переопубликовать", "request_sent_thank_you": "Спасибо, Ваш запрос отправлен!", "required": "обязательно", - "resend": "Отправить еще раз", + "resend": "Отправить повторно", + "resend_activation_email": "Повторно отправить письмо активации", "reset_password": "Сбросить пароль", "reset_your_password": "Сбросить пароль", "restore": "Восстановить", + "restore_accounts": "Восстановить учетные записи", + "restore_projects": "Восстановить проекты", "restoring": "Восстановление", "restricted": "Доступ ограничен", "restricted_no_permission": "Извините, у Вас недостаточно прав для просмотра данной страницы.", + "resume": "Возобновить", + "resume_account": "Возобновить доступ к учетной записи", "return_to_login_page": "Вернуться на страницу входа", "revoke_invite": "Отозвать приглашение", "ro": "Румынский", @@ -366,23 +405,41 @@ "ru": "Русский", "saving": "Сохранение", "saving_notification_with_seconds": "Сохранение __docname__... (__seconds__ секунд с последнего сохранения)", + "search": "Поиск", "search_bib_files": "Поиск по автору, названию, году", "search_projects": "Поиск по проектам", "search_references": "Поиск .bib файлов в проекте", "security": "Безопасность", + "select_a_new_owner_for_projects": "Выберите нового владельца для проектов этого пользователя", + "select_all_projects": "Выбрать все проекты", + "select_all_users": "Выбрать всех пользователей", "select_github_repository": "Выберите проект на GitHub для импорта в __appName__", + "select_project": "Выбрать __project__", + "select_projects": "Выбрать проекты", + "select_user": "Выбрать пользователя", + "select_users": "Выбрать пользователей", + "send_notification_emails_to_users": "Отправить уведовление текущему и новому владельцам", "september": "Сентябрь", "server_error": "Ошибка сервера", "services": "Сервисы", "session_created_at": "Сессия создана", "session_expired_redirecting_to_login": "Срок сессии истёк. Перенаправление на страницу входа через __seconds__ секунд(ы)", "sessions": "Сессии", + "set_admin": "Сделать админом", + "set_admin_account": "Предоставить права администратора", "set_new_password": "Введите новый пароль", "set_password": "Установить пароль", "settings": "Настройки", "share": "Открыть доступ", "share_project": "Открыть доступ к проекту", "shared_with_you": "Доступные мне", + "show_all_projects": "Показать все проекты", + "show_all_users": "Показать всех пользователей", + "show_x_more_projects": "Показать ещё __x__ проектов", + "show_x_more_users": "Показать ещё __x__ пользователей", + "showing_x_out_of_n_projects": "Показано __x__ из __n__ проектов", + "showing_x_out_of_n_users": "Показано __x__ из __n__ пользователей", + "signed_up": "Зарегистрирован", "site_description": "Простой в использовании онлайн редактор LaTeX. Не требует установки, поддерживает совместную работу в реальном времени, контроль версий, сотни шаблонов LaTeX и многое другое.", "somthing_went_wrong_compiling": "К сожалению, что-то пошло не так и мы не смогли скомпИлировать Ваш проект. Попробуйте еще раз через пару минут.", "source": "Исходный код", @@ -399,6 +456,8 @@ "sure_you_want_to_change_plan": "Вы уверены, что хотите сменить тарифный план на <0>__planName__?", "sure_you_want_to_delete": "Вы уверены, что хотите перманентно удалить следующие файлы?", "sure_you_want_to_leave_group": "Вы уверены, что хотите покинуть группу?", + "suspend": "Заблокировать", + "suspend_account": "Заблокировать учетную запись", "sv": "Шведский", "sync": "Синхронизация", "sync_project_to_github_explanation": "Все изменения, сделанные Вами в __appName__ будут интегрированы (commit и merge) со всеми обновлениями на GitHub.", @@ -418,6 +477,8 @@ "thanks_settings_updated": "Спасибо, изменения сохранены", "theme": "Тема", "thesis": "Диссертация", + "this_action_can_be_undone_within_limited_period": "Эта операция может быть отменена только в течение ограниченного периода.", + "this_action_cannot_be_undone": "Эту операцию нельзя отменить.", "this_is_your_template": "Это шаблон из Вашего проекта", "this_project_is_public": "Это открытый проект. Он может быть изменен любым человеком, знающим адрес (URL)", "this_project_is_public_read_only": "Этот проект открыт для всех, у кого есть ссылка (но без возможности редактирования)", @@ -431,6 +492,11 @@ "too_recently_compiled": "Этот проект был скомпилирован совсем недавно, поэтому компиляция была пропущена.", "total_words": "Количество слов", "tr": "Турецкий", + "transfer_all_projects_to": "Передать все проекты пользователей новому владельцу:", + "trash_projects": "Переместить проекты в корзину", + "trashed_projects": "Проекты в корзине", + "trashing_projects_wont_affect_collaborators": "Перемещение проекта в корзину не повлияет на других разработчиков проекта.", + "trashing_projects_wont_affect_user_collaborators": "Перемещение проекта в корзину не повлияет на других разработчиков проекта.", "try_now": "Попробуйте", "try_recompile_project": "Попробуйте скомпилировать проект заново.", "uk": "Украинский", @@ -442,6 +508,8 @@ "unlink_github_warning": "Все проекты, которые Вы синхронизировали с GitHub, будут отсоединены и больше не будут синхронизироваться с GitHub. Вы уверены, что хотите отсоединить Ваш GitHub аккаунт?", "unpublish": "Отменить публикацию", "unpublishing": "Отмена публикации", + "unset_admin": "Убрать админина", + "unset_admin_account": "Отозвать права администратора", "unsubscribe": "Отменить подписку", "unsubscribed": "Не подписан", "unsubscribing": "Отмена подписки", @@ -456,7 +524,19 @@ "upload_file": "Загрузить файл", "upload_project": "Загрузить проект", "upload_zipped_project": "Загрузить архив проекта (*.zip)", + "user_activity": "Активность пользователя", + "user_categories": "Категории пользователей", + "user_category_admin": "Администраторы", + "user_category_all": "Все пользователи", + "user_category_deleted": "Удалённые пользователи", + "user_category_inactive": "Неактивные пользователи", + "user_category_ldap": "Пользователи LDAP", + "user_category_local": "Локальные пользователи", + "user_category_oidc": "Пользователи OIDC", + "user_category_saml": "Пользователи SAML", + "user_category_suspended": "Заблокированные пользователи", "user_wants_you_to_see_project": "__username__ приглашает вас к просмотру проекта __projectname__", + "users_list": "Список пользователей", "vat_number": "Номер плательщика НДС", "view_all": "Показать все", "view_in_template_gallery": "Посмотреть в галерее шаблонов", diff --git a/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs b/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs new file mode 100644 index 0000000000..39e6605a17 --- /dev/null +++ b/services/web/modules/admin-tools/app/src/AdminToolsRouter.mjs @@ -0,0 +1,77 @@ +import logger from '@overleaf/logger' +import UserListController from './UserListController.mjs' +import ProjectListController from './ProjectListController.mjs' +import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs' + +export default { + apply(webRouter) { + logger.debug({}, 'Init AdminTools router') + + webRouter.get('/user/activate', UserListController.activateAccountPage) + AuthenticationController.addEndpointToLoginWhitelist('/user/activate') + + webRouter.get('/admin/user', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.manageUsersPage + ) + webRouter.post( + '/admin/user/create', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.registerNewUser + ) + webRouter.post('/admin/user/:userId/send-activation', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.sendActivationEmail + ) + webRouter.get('/admin/user/:userId/info', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.getAdditionalUserInfo, + ) + webRouter.post('/admin/users', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.getUsersJson + ) + webRouter.post('/admin/user/:userId/delete', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.deleteUser + ) + webRouter.post('/admin/user/:userId/update', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.updateUser, + ) + webRouter.delete('/admin/user/:userId', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.purgeDeletedUser + ) + webRouter.post('/admin/user/:userId/restore', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + UserListController.restoreDeletedUser + ) + webRouter.post('/admin/user/:userId/projects', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + ProjectListController.getProjectsJson + ) + + webRouter.get('/admin/project', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + ProjectListController.manageProjectsPage + ) + webRouter.post('/admin/project/:project_id/trash', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + ProjectListController.trashProjectForUser + ) + webRouter.post('/admin/project/:project_id/untrash', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + ProjectListController.untrashProjectForUser + ) + webRouter.delete('/admin/project/:project_id/purge', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + ProjectListController.purgeDeletedProject + ) + webRouter.post('/admin/project/:project_id/undelete', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + ProjectListController.undeleteProject + ) + }, +} diff --git a/services/web/modules/admin-tools/app/src/ProjectListController.mjs b/services/web/modules/admin-tools/app/src/ProjectListController.mjs new file mode 100644 index 0000000000..c7e37c722e --- /dev/null +++ b/services/web/modules/admin-tools/app/src/ProjectListController.mjs @@ -0,0 +1,232 @@ +import _ from 'lodash' +import Path from 'node:path' +import { fileURLToPath } from 'node:url' +import { expressify } from '@overleaf/promise-utils' +import logger from '@overleaf/logger' +import Metrics from '@overleaf/metrics' +import ProjectHelper from '../../../../app/src/Features/Project/ProjectHelper.js' +import ProjectGetter from '../../../../app/src/Features/Project/ProjectGetter.mjs' +import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.js' +import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.js' +import UserGetter from '../../../../app/src/Features/User/UserGetter.js' +import { OError } from '../../../../app/src/Features/Errors/Errors.js' +import { User } from '../../../../app/src/models/User.js' +import { Project } from '../../../../app/src/models/Project.js' +import { DeletedProject } from '../../../../app/src/models/DeletedProject.js' +import ProjectDeleter from '../../../../app/src/Features/Project/ProjectDeleter.mjs' +import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.js' + +const __dirname = Path.dirname(fileURLToPath(import.meta.url)) + +async function manageProjectsPage(req, res, next) { + const projectsBlobPending = _getProjects().catch(err => { + logger.err({ err }, 'projects listing in background failed') + return undefined + }) + + const prefetchedProjectsBlob = await projectsBlobPending + + Metrics.inc('project-list-prefetch-projects', 1, { + status: prefetchedProjectsBlob ? 'success' : 'error', + }) + + res.render(Path.resolve(__dirname, '../views/manage-projects-react'), { + title: 'Manage Projects', + prefetchedProjectsBlob, + }) +} + +async function getProjectsJson(req, res) { + const { filters, page, sort } = req.body + const { userId } = req.params + + const projectsPage = await _getProjects(userId, filters, sort, page) + res.json(projectsPage) +} + +async function _getProjects( + userId = null, + filters = {}, + sort = { by: 'lastUpdated', order: 'desc' }, + page = { size: 20 } +) { + + const projection = { + _id: 1, + name: 1, + lastUpdated: 1, + lastUpdatedBy: 1, + lastOpened: 1, + trashed: 1, + owner_ref: 1, + } + + const actualProjects = await Project.find( + userId == null ? {} : { owner_ref: userId }, + projection, + ).lean().exec() + + const delProjection = Object.fromEntries( + Object.keys(projection).map(k => [`project.${k}`, 1]) + ) + delProjection['deleterData.deletedAt'] = 1 + delProjection['deleterData.deleterId'] = 1 + + const deletedProjects = await DeletedProject.find( + userId == null ? { project: { $type: 'object' } } : { 'project.owner_ref': userId }, + delProjection + ).lean().exec() + + const formattedActualProjects = _formatProjects(actualProjects, _formatProjectInfo) + const formattedDeletedProjects = _formatProjects(deletedProjects, _formatDeletedProjectInfo) + const formattedProjects = [...formattedActualProjects, ...formattedDeletedProjects] + const filteredProjects = _applyFilters(formattedProjects, filters) + const projects = _sortAndPaginate(filteredProjects, sort, page) + + return { + totalSize: filteredProjects.length, + projects, + } +} + +function _formatProjects(projects, formatProjectInfo) { + const yearAgo = new Date() + yearAgo.setFullYear(yearAgo.getFullYear() - 1) + const formattedProjects = [] + + for (const project of projects) { + formattedProjects.push( + formatProjectInfo(project, yearAgo) + ) + } + return formattedProjects +} + +function _applyFilters(projects, filters) { + if (!_hasActiveFilter(filters)) { + return projects + } + return projects.filter(project => _matchesFilters(project, filters)) +} + +function _sortAndPaginate(projects, sort, page) { + if ( + (sort.by && !['lastUpdated', 'title', 'deletedAt'].includes(sort.by)) || + (sort.order && !['asc', 'desc'].includes(sort.order)) + ) { + throw new OError('Invalid sorting criteria', { sort }) + } + const sortedProjects = _.orderBy( + projects, + [sort.by || 'lastUpdated'], + [sort.order || 'desc'] + ) + return sortedProjects +} + +function _formatProjectInfo(project, maxDate) { + const owner_ref = project.owner_ref + const trashed = owner_ref ? ProjectHelper.isTrashed(project, owner_ref) : false + + return { + id: project._id.toString(), + name: project.name, + owner: project.owner_ref, + lastUpdated: project.lastUpdated?.toISOString(), + lastUpdatedBy: project.lastUpdatedBy, + inactive: project.lastOpened < maxDate, + trashed, + deleted: false, + } +} + +function _formatDeletedProjectInfo(deletedProject, maxDate) { + const project = deletedProject.project + const owner_ref = project.owner_ref + const trashed = owner_ref ? ProjectHelper.isTrashed(project, owner_ref) : false + + return { + id: project._id.toString(), + name: project.name, + owner: owner_ref, + lastUpdated: project.lastUpdated?.toISOString(), + lastUpdatedBy: project.lastUpdatedBy, + inactive: project.lastOpened < maxDate, + trashed, + deleted: true, + deletedAt: deletedProject.deleterData?.deletedAt?.toISOString(), + deletedBy: deletedProject.deleterData?.deleterId, + } +} + +function _matchesFilters(project, filters) { + if (filters.owned && (project.trashed || project.deleted)) { + return false + } + if (filters.trashed && (!project.trashed || project.deleted)) { + return false + } + if (filters.deleted && !project.deleted) { + return false + } + if (filters.inactive && (project.trashed || project.deleted || !project.inactive)) { + return false + } + if ( + filters.search?.length && + project.name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 + ) { + return false + } + return true +} + +function _hasActiveFilter(filters) { + return Boolean( + filters.owned || + filters.inactive || + filters.trashed || + filters.deleted || + filters.search?.length + ) +} + +async function trashProjectForUser(req, res) { + const projectId = req.params.project_id + const { userId } = req.body + await ProjectDeleter.promises.trashProject(projectId, userId) + res.sendStatus(200) +} + +async function untrashProjectForUser(req, res) { + const projectId = req.params.project_id + const { userId } = req.body + await ProjectDeleter.promises.untrashProject(projectId, userId) + res.sendStatus(200) +} + +async function undeleteProject(req, res) { + const projectId = req.params.project_id + const { userId } = req.body + const undelededProject = await ProjectDeleter.promises.undeleteProject(projectId, { userId }) + await ProjectDeleter.promises.untrashProject(projectId, userId) + + return res.json({ + name: undelededProject.name, + }) +} + +async function purgeDeletedProject(req, res) { + const projectId = req.params.project_id + await ProjectDeleter.promises.expireDeletedProject(projectId) + res.sendStatus(200) +} + +export default { + manageProjectsPage: expressify(manageProjectsPage), + getProjectsJson: expressify(getProjectsJson), + undeleteProject: expressify(undeleteProject), + purgeDeletedProject: expressify(purgeDeletedProject), + trashProjectForUser: expressify(trashProjectForUser), + untrashProjectForUser: expressify(untrashProjectForUser), +} diff --git a/services/web/modules/admin-tools/app/src/UserListController.mjs b/services/web/modules/admin-tools/app/src/UserListController.mjs new file mode 100644 index 0000000000..c5087cdb63 --- /dev/null +++ b/services/web/modules/admin-tools/app/src/UserListController.mjs @@ -0,0 +1,570 @@ +import Path from 'node:path' +import { fileURLToPath } from 'node:url' +import _ from 'lodash' +import crypto from 'crypto' +import Settings from '@overleaf/settings' +import Metrics from '@overleaf/metrics' +import logger from '@overleaf/logger' +import { User } from '../../../../app/src/models/User.js' +import { DeletedUser } from '../../../../app/src/models/DeletedUser.js' +import { DeletedProject } from '../../../../app/src/models/DeletedProject.js' +import { expressify, promiseMapWithLimit } from '@overleaf/promise-utils' +import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.js' +import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.mjs' +import EmailHandler from '../../../../app/src/Features/Email/EmailHandler.js' +import OneTimeTokenHandler from '../../../../app/src/Features/Security/OneTimeTokenHandler.js' +import UserGetter from '../../../../app/src/Features/User/UserGetter.js' +import UserUpdater from '../../../../app/src/Features/User/UserUpdater.js' +import UserDeleter from '../../../../app/src/Features/User/UserDeleter.mjs' +import ProjectDeleter from '../../../../app/src/Features/Project/ProjectDeleter.mjs' +import OwnershipTransferHandler from '../../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs' +import HttpErrorHandler from '../../../../app/src/Features/Errors/HttpErrorHandler.js' +import ErrorController from '../../../../app/src/Features/Errors/ErrorController.mjs' +import Errors, { OError } from '../../../../app/src/Features/Errors/Errors.js' +import { db } from '../../../../app/src/infrastructure/mongodb.js' + +const __dirname = Path.dirname(fileURLToPath(import.meta.url)) + +const externalAuth = process.env.EXTERNAL_AUTH ? process.env.EXTERNAL_AUTH.split(' ') : [] +const availableAuthMethods = ['local', ...externalAuth] + +const userIsAdminUpdatedOnLogin = Object.fromEntries( + availableAuthMethods.map(m => [ + m, + Boolean(Settings[m]?.attAdmin) && Boolean(Settings[m]?.valAdmin) + ]) +) +const userDetailsUpdatedOnLogin = Object.fromEntries( + availableAuthMethods.map(m => [ + m, + Boolean(Settings[m]?.updateUserDetailsOnLogin) + ]) +) + +async function _sendActivationEmail(idString) { + const user = await User.findById(idString, { email: 1, _id: 0 }).lean() + if (!user) { + throw new OError('User does not exist, cannot send activation email', { userId: idString }) + } + const ONE_WEEK = 7 * 24 * 60 * 60 // seconds + const token = await OneTimeTokenHandler.promises.getNewToken( + 'password', + { user_id: idString, email: user.email }, + { expiresIn: ONE_WEEK } + ) + const setNewPasswordUrl = `${Settings.siteUrl}/user/activate?token=${token}&user_id=${idString}` + await EmailHandler.promises.sendEmail('registered', { to: user.email, setNewPasswordUrl }) + .catch(error => { + throw new OError('Failed to send activation email', { error: error.message, email: user.email }) + }) + return setNewPasswordUrl +} + +function cleanupSession(req) { + // cleanup redirects at the end of the redirect chain + delete req.session.postCheckoutRedirect + delete req.session.postLoginRedirect + delete req.session.postOnboardingRedirect +} + +async function manageUsersPage(req, res, next) { + cleanupSession(req) + + const userId = SessionManager.getLoggedInUserId(req.session) + + const usersBlobPending = _getUsers().catch(err => { + logger.err({ err }, 'users listing in background failed') + return undefined + }) + + const prefetchedUsersBlob = await usersBlobPending + + Metrics.inc('user-list-prefetch-users', 1, { + status: prefetchedUsersBlob ? 'success' : 'error', + }) + + res.render(Path.resolve(__dirname, '../views/manage-users-react'), { + title: 'Manage Users', + prefetchedUsersBlob, + availableAuthMethods, + userDetailsUpdatedOnLogin, + userIsAdminUpdatedOnLogin, + }) +} + +async function registerNewUser(req, res, next) { + const { email, isExternal, isAdmin } = req.body + if (email == null || email === '') { + return HttpErrorHandler.unprocessableEntity(req, res, 'Email address is empty') + } + delete req.body.isExternal + req.body.password = crypto.randomBytes(32).toString('hex') + + let user + try { + user = await UserRegistrationHandler.promises.registerNewUser(req.body) + } catch (err) { + if (err.message == 'EmailAlreadyRegistered') { + return HttpErrorHandler.conflict(req, res, 'email_already_registered') + } + if (err.message === 'InvalidEmailError') { + return HttpErrorHandler.unprocessableEntity(req, res, 'email_address_is_invalid') + } + if (err.message === 'InvalidPasswordError') { + return HttpErrorHandler.unprocessableEntity(req, res, 'try_again') + } + OError.tag(err, 'error user registration', { + email, + }) + throw err + } + + try { + const reversedHostname = user.email + .split('@')[1] + .split('') + .reverse() + .join('') + const update = { + $set: { isAdmin, emails: [{ email, reversedHostname, confirmedAt: Date.now() }] }, + } + if (isExternal) { + update.$unset = { hashedPassword: "" } + } else { + await _sendActivationEmail(user._id.toString()) + } + await User.updateOne({ _id: user._id }, update).exec() + } catch (err) { + OError.tag(err, 'error finishing user registration', { + email: user.email, + }) + throw err + } + + const authMethods = isExternal ? [] : ['local'] + const { id, first_name, last_name, signUpDate } = user + const newUser = { id, email, firstName: first_name, lastName: last_name, isAdmin, signUpDate, inactive: true, deleted: false, authMethods } + res.json({ user: newUser }) +} + +async function sendActivationEmail(req, res, next) { + const { userId } = req.params + try { + await _sendActivationEmail(userId) + } catch (err) { + logger.warn({ err }) + return HttpErrorHandler.unprocessableEntity(req, res, 'Error sending activation email') + } + res.sendStatus(200) +} + +async function getUsersJson(req, res) { + const { filters, page, sort } = req.body + const usersPage = await _getUsers(filters, sort, page) + res.json(usersPage) +} + + +async function activateAccountPage(req, res, next) { + // An 'activation' is actually just a password reset on an account that + // was set with a random password originally. + if (req.query.user_id == null || req.query.token == null) { + return ErrorController.notFound(req, res) + } + + if (typeof req.query.user_id !== 'string') { + return ErrorController.forbidden(req, res) + } + + const user = await UserGetter.promises.getUser(req.query.user_id, { + email: 1, + }) + + if (!user) { + return ErrorController.notFound(req, res) + } + + req.session.doLoginAfterPasswordReset = true + + res.render(Path.resolve(__dirname, '../views/activate'), { + title: 'activate_account', + email: user.email, + token: req.query.token, + }) +} + +async function _getUsers( + filters = {}, + sort = { by: 'name', order: 'asc' }, + page = { size: 20 } +) { + const projection = { + _id: 1, + email: 1, + first_name: 1, + last_name: 1, + lastActive: 1, + lastLoggedIn: 1, + signUpDate: 1, + loginCount: 1, + isAdmin: 1, + hashedPassword: 1, + samlIdentifiers: 1, + thirdPartyIdentifiers: 1, + suspended: 1, + } + const projectionDeleted = {}; + for (const key of Object.keys(projection)) { + projectionDeleted[key] = `$user.${key}` + } + projectionDeleted.deletedAt = '$deleterData.deletedAt' + + const activeUsers = await UserGetter.promises.getUsers({}, projection) + const deletedUsers = await DeletedUser.aggregate([ + { $match: { user: { $type: 'object' } } }, + { $project: projectionDeleted }, + ]) + + const allUsers = [...activeUsers, ...deletedUsers] + + const formattedUsers = _formatUsers(allUsers) + const filteredUsers = _applyFilters(formattedUsers, filters) + const users = _sortAndPaginate(filteredUsers, sort, page) + + return { + totalSize: filteredUsers.length, + users, + } +} + +function _formatUsers(users) { + const formattedUsers = [] + const yearAgo = new Date() + yearAgo.setFullYear(yearAgo.getFullYear() - 1) + + for (const user of users) { + formattedUsers.push( + _formatUserInfo(user, yearAgo) + ) + } + + return formattedUsers +} + +function _applyFilters(users, filters) { + if (!_hasActiveFilter(filters)) { + return users + } + return users.filter(user => _matchesFilters(user, filters)) +} + +function _sortAndPaginate(users, sort, page) { + if ( + (sort.by && !['lastActive', 'signUpDate', 'email', 'name', 'deletedAt'].includes(sort.by)) || + (sort.order && !['asc', 'desc'].includes(sort.order)) + ) { + throw new OError('Invalid sorting criteria', { sort }) + } + + const sortedUsers = _.orderBy( + users, + [sort.by || 'signUpDate'], + [sort.order || 'desc'] + ) + return sortedUsers +} + +function _formatUserInfo(user, maxDate) { + let authMethods = [] + if (availableAuthMethods.includes('local') && user.hashedPassword) authMethods.push('local') + if (availableAuthMethods.includes('saml') && user.samlIdentifiers.length > 0) authMethods.push('saml') + if (availableAuthMethods.includes('oidc') && user.thirdPartyIdentifiers.length > 0) authMethods.push('oidc') +// If none of the above, mark as LDAP + if (availableAuthMethods.includes('ldap') && authMethods.length === 0 && user.loginCount !== 0) authMethods.push('ldap') + +// if not all user's authentications methods update a property on login, allow admin to update that property + const allowUpdateDetails = authMethods.length === 0 || !authMethods.every(m => userDetailsUpdatedOnLogin[m]) + const allowUpdateIsAdmin = authMethods.length === 0 || !authMethods.every(m => userIsAdminUpdatedOnLogin[m]) + + return { + id: user._id.toString(), + email: user.email, + firstName: user.first_name, + lastName: user.last_name, + isAdmin: user.isAdmin, + loginCount: user.loginCount, + signUpDate: user.signUpDate, + lastActive: user.lastActive, + lastLoggedIn: user.lastLoggedIn, + authMethods, + allowUpdateDetails, + allowUpdateIsAdmin, + ...(user.suspended && { suspended: user.suspended }), + inactive: !user.lastActive || user.lastActive < maxDate, + ...(user.deletedAt && { deletedAt: user.deletedAt }), + deleted: Boolean(user.deletedAt), + } +} + +function _matchesFilters(user, filters) { + if ( + filters.search?.length && + user.email.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 && + user.first_name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 && + user.last_name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 + ) { return false } + // Deleted users only match the 'deleted' filter + if (user.deleted) return Boolean(filters.deleted) + if (filters.all) return true + if (filters.admin) return user.isAdmin + if (filters.inactive && !user.inactive) return false + if (filters.suspended && !user.suspended) return false + for (const method of availableAuthMethods) { + if (filters[method] && !user.authMethods.includes(method)) { + return false + } + } + return true +} + +function _hasActiveFilter(filters) { + return Boolean( + filters.deleted || + filters.all || + filters.admin || + filters.inactive || + filters.suspended || + filters.local || + filters.saml || + filters.oidc || + filters.ldap || + filters.search?.length + ) +} + +async function deleteUser(req, res, next) { + const deleterUserId = SessionManager.getLoggedInUserId(req.session) + const { userId } = req.params + const { sendEmail, toUserId } = req.body + + logger.debug({ deleterUserId, userId }, 'admin is trying to delete user account') + + if (toUserId) { + try { + await OwnershipTransferHandler.promises.transferAllProjectsToUser({ + fromUserId: userId, + toUserId, + ipAddress: '0.0.0.0', + }) + } catch (err) { + logger.warn({ userId, toUserId }, err.message) + const message = 'Failed to transfer projects ownership' + return HttpErrorHandler.unprocessableEntity(req, res, message) + } + } + + try { + await UserDeleter.promises.deleteUser(userId, { + deleterUser: { '_id': deleterUserId }, + ipAddress: req.ip, + skipEmail: !sendEmail, + }) + } catch (err) { + logger.warn({ deleterUser, userId }, err.message) + if (toUserId) { + try { // failed to delete user, try to transfer all projects back + await OwnershipTransferHandler.promises.transferAllProjectsToUser({ + toUserId, + fromUserId: userId, + ipAddress: '0.0.0.0', + }) + } catch (e) { + logger.warn({ toUserId, userId }, 'Failed to transfer the projects ownership back: ' + e.message) + } + } + const message = 'Something went wrong. Does the account still exist?' + return HttpErrorHandler.unprocessableEntity(req, res, message) + } + + const deletedUser = await DeletedUser.findOne( + { 'user._id': userId }, { 'deleterData.deletedAt': 1 } + ).lean() + + res.json({ deletedAt: deletedUser.deleterData.deletedAt }) +} + +async function purgeDeletedUser(req, res, next) { + const deleterUserId = SessionManager.getLoggedInUserId(req.session) + const userId = req.params.userId + + logger.debug({ deleterUserId, userId }, 'admin is trying to purge deleted user account') + try { + UserDeleter.promises.expireDeletedUser(userId) + } catch (err) { + logger.warn({ restorerId, userId }, err.message) + const message = 'Something went wrong. The user is already deleted?' + return HttpErrorHandler.unprocessableEntity(req, res, message) + } + res.sendStatus(200) +} + +async function restoreDeletedUser(req, res, next) { + const restorerId = SessionManager.getLoggedInUserId(req.session) + const userId = req.params.userId + + logger.debug({ restorerId, userId }, 'admin is trying to restore deleted user') + + let userData + try { + const deletedEntry = await DeletedUser.findOne( { "user._id": userId }).lean() + userData = deletedEntry?.user + if (!userData) { + const message = 'Something went wrong. The user is purged?' + return HttpErrorHandler.unprocessableEntity(req, res, message) + } + + const exists = await User.findOne({ email: userData.email }, { _id: 1 }).lean() + if (exists) { + const message = req.i18n.translate('email_already_registered') + return HttpErrorHandler.conflict(req, res, message) + } + + userData.suspended = false + await User.create(userData) + await DeletedUser.deleteOne({ "user._id": userId }) + + } catch (err) { + const message = req.i18n.translate('generic_something_went_wrong') + return HttpErrorHandler.legacyInternal( + req, res, message, + OError.tag(err, 'problem restoring deleted user', { + userId, + }) + ) + } + + try { + const projects = await DeletedProject.find({ "project.owner_ref": userId }).exec() + logger.info( + { userId, projectCount: projects.length }, + 'found user projects to restore' + ) + await promiseMapWithLimit(5, projects, project => + ProjectDeleter.promises.undeleteProject(project.deleterData.deletedProjectId, { suffix: "" })) + } catch (err) { + logger.info({ userId }, err.message) + } + + return res.json({ + restoredId: userData._id.toString(), + email: userData.email, + }) +} + +async function updateUser(req, res, next) { + const userId = req.params.userId + const actorUserId = SessionManager.getLoggedInUserId(req.session) + req.logger.addFields({ actorUserId }) + const { body } = req + + const projection = Object.fromEntries(Object.keys(body).map(k => [k, 1])) + const user = await User.findById(userId, projection).exec() + + if (user == null) { + throw new OError('problem updating user settings', { userId }) + } + + let emailIsUpdated = false + const newEmail = body.email?.trim().toLowerCase() + if (newEmail != null && newEmail !== user.email) { // email is updated + if (newEmail.indexOf('@') === -1) { + const message = req.i18n.translate('email_address_is_invalid') + return HttpErrorHandler.unprocessableEntity(req, res, message) + } + const auditLog = { initiatorId: actorUserId, ipAddress: req.ip } + + try { + await UserUpdater.promises.changeEmailAddress(userId, newEmail, auditLog) + emailIsUpdated = true + } catch (err) { + if (err instanceof Errors.EmailExistsError) { + const message = req.i18n.translate('email_already_registered') + return HttpErrorHandler.conflict(req, res, message) + } else { + const message = req.i18n.translate('problem_changing_email_address') + return HttpErrorHandler.legacyInternal( + req, res, message, + OError.tag(err, 'problem changing email address', { + userId, + newEmail, + }) + ) + } + } + if (userId == actorUserId) { + SessionManager.setInSessionUser(req.session, { + email: newEmail, + }) + } + } + + const update = {} + for (const [key, value] of Object.entries(body)) { + if (key === "email") continue + if (value === user[key]) continue + update[key] = typeof value === "string" ? value.trim() : value + } + Object.assign(user, update) + try { + await user.save() + } catch (err) { + throw new OError('problem updating user settings', { userId }) + } + if (userId == actorUserId) { + const sessionUpdate = {} + if (update.first_name != null) sessionUpdate.first_name = update.first_name + if (update.last_name != null) sessionUpdate.last_name = update.last_name + SessionManager.setInSessionUser(req.session, sessionUpdate) + } + if (emailIsUpdated) update["email"] = newEmail + + return res.json(update) +} + +async function _getActivationLink(userId) { + try { + const tokenDoc = await db.tokens.findOne({ + use: 'password', + 'data.user_id': userId, + expiresAt: { $gt: new Date() }, + usedAt: { $exists: false }, + peekCount: { $not: { $gte: OneTimeTokenHandler.MAX_PEEKS } }, + }) + if (!tokenDoc) { + return null + } + return `${Settings.siteUrl}/user/activate?token=${tokenDoc.token}&user_id=${userId}` + } catch (err) { + logger.warn({ userId }, 'Failed to get activation link' + err.message) + return null + } +} + +async function getAdditionalUserInfo(req, res, next) { + const { userId } = req.params + const activationLink = await _getActivationLink(userId) + res.json({ activationLink }) +} + +export default { + manageUsersPage: expressify(manageUsersPage), + getUsersJson: expressify(getUsersJson), + getAdditionalUserInfo: expressify(getAdditionalUserInfo), + registerNewUser: expressify(registerNewUser), + activateAccountPage: expressify(activateAccountPage), + sendActivationEmail: expressify(sendActivationEmail), + deleteUser: expressify(deleteUser), + restoreDeletedUser: expressify(restoreDeletedUser), + purgeDeletedUser: expressify(purgeDeletedUser), + updateUser: expressify(updateUser), +} diff --git a/services/web/modules/admin-tools/app/src/tsconfig.json b/services/web/modules/admin-tools/app/src/tsconfig.json new file mode 100644 index 0000000000..ddee721c50 --- /dev/null +++ b/services/web/modules/admin-tools/app/src/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "../../../../tsconfig.backend.json" } diff --git a/services/web/modules/admin-tools/app/views/activate.pug b/services/web/modules/admin-tools/app/views/activate.pug new file mode 100644 index 0000000000..2fa9ec9d54 --- /dev/null +++ b/services/web/modules/admin-tools/app/views/activate.pug @@ -0,0 +1,75 @@ +extends ../../../../app/views/layout-website-redesign + +block vars + - isWebsiteRedesign = true + +include ../../../../app/views/_mixins/material_symbol + +block content + main#main-content.content.content-alt + .container + .col-lg-6.col-xl-4.m-auto + .notification-list + .notification.notification-type-success(aria-live='off' role='alert') + .notification-content-and-cta + .notification-icon + +material-symbol('check_circle') + .notification-content + p + | #{translate("nearly_activated")} + + h1.h3 #{translate("please_set_a_password")} + + form( + name='activationForm' + data-ol-async-form + action='/user/password/set' + method='POST' + ) + +formMessages + + +customFormMessage('token-expired', 'danger') + | #{translate("activation_token_expired")} + + +customFormMessage('invalid-password', 'danger') + | #{translate('invalid_password')} + + +customFormMessage('password-must-be-different', 'danger') + | #{translate('password_change_password_must_be_different')} + + input(name='_csrf' type='hidden' value=csrfToken) + input(name='passwordResetToken' type='hidden' value=token) + + .form-group + label(for='emailField') #{translate("email")} + input#emailField.form-control( + name='email' + aria-label='email' + type='email' + placeholder='email@example.com' + autocomplete='username' + value=email + required + disabled + ) + .form-group + label(for='passwordField') #{translate("password")} + input#passwordField.form-control( + name='password' + type='password' + placeholder='********' + autocomplete='new-password' + autofocus + required + minlength=settings.passwordStrengthOptions.length.min + ) + .actions + button.btn.btn-primary( + type='submit' + data-ol-disabled-inflight + aria-label=translate('activate') + ) + span(data-ol-inflight='idle') + | #{translate('activate')} + span(hidden data-ol-inflight='pending') + | #{translate('activating')}… diff --git a/services/web/modules/admin-tools/app/views/manage-projects-react.pug b/services/web/modules/admin-tools/app/views/manage-projects-react.pug new file mode 100644 index 0000000000..e90bf0cf35 --- /dev/null +++ b/services/web/modules/admin-tools/app/views/manage-projects-react.pug @@ -0,0 +1,28 @@ +extends ../../../../app/views/layout-react + +block entrypointVar + - entrypoint = 'modules/admin-tools/pages/manage-projects' + +block vars + - const suppressNavContentLinks = true + - const suppressNavbar = true + - const suppressFooter = true + - const suppressPugCookieBanner = true + +block append meta + meta( + name='ol-prefetchedProjectsBlob' + data-type='json' + content=prefetchedProjectsBlob + ) + if suggestedLanguageSubdomainConfig + meta( + name='ol-suggestedLanguage' + data-type='json' + content=Object.assign(suggestedLanguageSubdomainConfig, { + lngName: translate(suggestedLanguageSubdomainConfig.lngCode), + imgUrl: buildImgPath('flags/24/' + suggestedLanguageSubdomainConfig.lngCode + '.png'), + }) + ) +block content + #manage-projects-root diff --git a/services/web/modules/admin-tools/app/views/manage-users-react.pug b/services/web/modules/admin-tools/app/views/manage-users-react.pug new file mode 100644 index 0000000000..fa77f9e2ce --- /dev/null +++ b/services/web/modules/admin-tools/app/views/manage-users-react.pug @@ -0,0 +1,29 @@ +extends ../../../../app/views/layout-react + +block entrypointVar + - entrypoint = 'modules/admin-tools/pages/manage-users' + +block vars + - const suppressNavContentLinks = true + - const suppressNavbar = true + - const suppressFooter = true + - const suppressPugCookieBanner = true + +block append meta + meta(name='ol-availableAuthMethods' data-type='json' content=availableAuthMethods) + meta( + name='ol-prefetchedUsersBlob' + data-type='json' + content=prefetchedUsersBlob + ) + if suggestedLanguageSubdomainConfig + meta( + name='ol-suggestedLanguage' + data-type='json' + content=Object.assign(suggestedLanguageSubdomainConfig, { + lngName: translate(suggestedLanguageSubdomainConfig.lngCode), + imgUrl: buildImgPath('flags/24/' + suggestedLanguageSubdomainConfig.lngCode + '.png'), + }) + ) +block content + #manage-users-root diff --git a/services/web/modules/admin-tools/frontend/js/manage-projects-root.tsx b/services/web/modules/admin-tools/frontend/js/manage-projects-root.tsx new file mode 100644 index 0000000000..d8338ca3b0 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/manage-projects-root.tsx @@ -0,0 +1,31 @@ +import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n' +import withErrorBoundary from '@/infrastructure/error-boundary' +import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback' +import { SplitTestProvider } from '@/shared/context/split-test-context' +import { UserSettingsProvider } from '@/shared/context/user-settings-context' +import { UserListProvider } from './user-list/context/user-list-context' +import { ProjectListProvider } from './project-list/context/project-list-context' +import ProjectListRoot from './project-list/components/project-list-root' + +function ManageProjectsRoot() { + const { isReady } = useWaitForI18n() + + if (!isReady) return null + + return ( + + + + + + + + + + ) +} + +export default withErrorBoundary(ManageProjectsRoot, () => ( + +)) + diff --git a/services/web/modules/admin-tools/frontend/js/manage-users-root.tsx b/services/web/modules/admin-tools/frontend/js/manage-users-root.tsx new file mode 100644 index 0000000000..55b1f941b2 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/manage-users-root.tsx @@ -0,0 +1,45 @@ +import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n' +import withErrorBoundary from '@/infrastructure/error-boundary' +import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback' +import { SplitTestProvider } from '@/shared/context/split-test-context' +import { UserSettingsProvider } from '@/shared/context/user-settings-context' +import { UsersPageProvider, useUsersPageContext } from './users-page-context.tsx' +import { UserListProvider } from './user-list/context/user-list-context' +import UserListRoot from './user-list/components/user-list-root' +import { ProjectListProvider } from './project-list/context/project-list-context' +import ProjectListRoot from './project-list/components/project-list-root' + +function UsersPageSelector() { + const { page } = useUsersPageContext() + + if (page.type === 'projects') { + return ( + + + + ) + } + return +} + +function ManageUsersRoot() { + const { isReady } = useWaitForI18n() + + if (!isReady) return null + + return ( + + + + + + + + + + ) +} + +export default withErrorBoundary(ManageUsersRoot, () => ( + +)) diff --git a/services/web/modules/admin-tools/frontend/js/pages/manage-projects.tsx b/services/web/modules/admin-tools/frontend/js/pages/manage-projects.tsx new file mode 100644 index 0000000000..1c0f311217 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/pages/manage-projects.tsx @@ -0,0 +1,8 @@ +import { createRoot } from 'react-dom/client' +import ManageProjectsRoot from '../manage-projects-root' + +const element = document.getElementById('manage-projects-root') +if (element) { + const root = createRoot(element) + root.render() +} diff --git a/services/web/modules/admin-tools/frontend/js/pages/manage-users.tsx b/services/web/modules/admin-tools/frontend/js/pages/manage-users.tsx new file mode 100644 index 0000000000..29a982abb0 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/pages/manage-users.tsx @@ -0,0 +1,8 @@ +import { createRoot } from 'react-dom/client' +import ManageUsersRoot from '../manage-users-root' + +const element = document.getElementById('manage-users-root') +if (element) { + const root = createRoot(element) + root.render() +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/actions-dropdown.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/actions-dropdown.tsx new file mode 100644 index 0000000000..2ae610ae5d --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/actions-dropdown.tsx @@ -0,0 +1,144 @@ +import { useTranslation } from 'react-i18next' +import { + Dropdown, + DropdownItem, + DropdownMenu, + DropdownToggle, +} from '@/shared/components/dropdown/dropdown-menu' +import DownloadProjectButton from '../table/cells/action-buttons/download-project-button' +import TrashProjectButton from '../table/cells/action-buttons/trash-project-button' +import UntrashProjectButton from '../table/cells/action-buttons/untrash-project-button' +import DeleteProjectButton from '../table/cells/action-buttons/delete-project-button' +import RestoreProjectButton from '../table/cells/action-buttons/restore-project-button' +import PurgeProjectButton from '../table/cells/action-buttons/purge-project-button' +import TransferProjectButton from '../table/cells/action-buttons/transfer-project-button' +import { Project } from '../../../../../types/project/api' +import MaterialIcon from '@/shared/components/material-icon' +import OLSpinner from '@/shared/components/ol/ol-spinner' + +type ActionDropdownProps = { + project: Project +} + +function ActionsDropdown({ project }: ActionDropdownProps) { + const { t } = useTranslation() + + return ( + + + + + + + {(text, downloadProject) => ( +
  • + + {text} + +
  • + )} +
    + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + {(text, untrashProject) => ( +
  • + + {text} + +
  • + )} +
    + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + + + + + +
    +
    + ) +} + +export default ActionsDropdown diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/projects-dropdown.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/projects-dropdown.tsx new file mode 100644 index 0000000000..ca8a1c05b1 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/projects-dropdown.tsx @@ -0,0 +1,92 @@ +import { useState, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { + Filter, + useProjectListContext, +} from '../../context/project-list-context' +import { + Dropdown, + DropdownHeader, + DropdownItem, + DropdownMenu, + DropdownToggle, +} from '@/shared/components/dropdown/dropdown-menu' +import BackToUserList from '../back-to-user-list' +import ProjectsFilterMenu from '../projects-filter-menu' + +type ItemProps = { + filter: Filter + text: string + onClick?: () => void +} + +export function Item({ filter, text, onClick }: ItemProps) { + const { selectFilter } = useProjectListContext() + const handleClick = () => { + selectFilter(filter) + onClick?.() + } + + return ( + + {isActive => ( + + {text} + + )} + + ) +} + +function ProjectsDropdown() { + const { t } = useTranslation() + const [title, setTitle] = useState(() => t('all_projects')) + const { filter } = useProjectListContext() + const filterTranslations = useRef>({ + owned: t('all_projects'), + inactive: t('inactive_projects'), + trashed: t('trashed_projects'), + deleted: t('deleted_projects'), + }) + + useEffect(() => { + setTitle(filterTranslations.current[filter]) + }, [filter, t]) + + return ( + + + + {title} + + + +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
    +
    + ) +} + +export default ProjectsDropdown diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/sort-by-dropdown.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/sort-by-dropdown.tsx new file mode 100644 index 0000000000..fee9e3dd9b --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/dropdown/sort-by-dropdown.tsx @@ -0,0 +1,98 @@ +import { useState, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import useSort from '../../hooks/use-sort' +import withContent, { SortBtnProps } from '../sort/with-content' +import { useProjectListContext } from '../../context/project-list-context' +import { Sort } from '../../../../../types/project/api' +import { + Dropdown, + DropdownHeader, + DropdownItem, + DropdownMenu, + DropdownToggle, +} from '@/shared/components/dropdown/dropdown-menu' + +function Item({ onClick, text, iconType }: SortBtnProps) { + return ( + + {text} + + ) +} + +const ItemWithContent = withContent(Item) + +function SortByDropdown() { + const { t } = useTranslation() + const [title, setTitle] = useState(() => t('last_modified')) + const { filter, sort } = useProjectListContext() + const { handleSort } = useSort() + const sortByTranslations = useRef>({ + title: t('title'), + lastUpdated: t('last_modified'), + deletedAt: t('deleted_at'), + }) + + const handleClick = (by: Sort['by']) => { + setTitle(sortByTranslations.current[by]) + handleSort(by) + } + + useEffect(() => { + setTitle(sortByTranslations.current[sort.by]) + }, [sort.by]) + + return ( + + + + {title} + + + + + {t('sort_by')}: + + handleClick('title')} + /> + handleClick('owner')} + /> + { filter !== 'deleted' ? ( + handleClick('lastUpdated')} + /> + ) : ( + handleClick('deletedAt')} + /> + )} + + + ) +} + +export default SortByDropdown diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/load-more.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/load-more.tsx new file mode 100644 index 0000000000..3843a89a19 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/load-more.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from 'react-i18next' +import { useProjectListContext } from '../context/project-list-context' +import OLButton from '@/shared/components/ol/ol-button' + +export default function LoadMore() { + const { + visibleProjects, + hiddenProjectsCount, + loadMoreCount, + showAllProjects, + loadMoreProjects, + } = useProjectListContext() + const { t } = useTranslation() + + return ( +
    + {hiddenProjectsCount > 0 ? ( + <> + loadMoreProjects()} + > + {t('show_x_more_projects', { x: loadMoreCount })} + + + ) : null} +

    + {hiddenProjectsCount > 0 ? ( + <> + + {t('showing_x_out_of_n_projects', { + x: visibleProjects.length, + n: visibleProjects.length + hiddenProjectsCount, + })} + {' '} + showAllProjects()} + className="btn-inline-link" + > + {t('show_all_projects')} + + + ) : ( + + {t('showing_x_out_of_n_projects', { + x: visibleProjects.length, + n: visibleProjects.length, + })} + + )} +

    +
    + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/delete-project-modal.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/delete-project-modal.tsx new file mode 100644 index 0000000000..631d825c1e --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/delete-project-modal.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Notification from '@/shared/components/notification' +import ProjectsActionModal from './projects-action-modal' +import ProjectsList from './projects-list' + +type DeleteProjectModalProps = Pick< + React.ComponentProps, + 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function DeleteProjectModal({ + projects, + actionHandler, + showModal, + handleCloseModal, +}: DeleteProjectModalProps) { + const { t } = useTranslation() + const [projectsToDisplay, setProjectsToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setProjectsToDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projects + }) + } else { + setProjectsToDisplay([]) + } + }, [showModal, projects]) + + return ( + +

    {t('about_to_delete_projects')}

    + + +
    + ) +} + +export default DeleteProjectModal diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/projects-action-modal.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/projects-action-modal.tsx new file mode 100644 index 0000000000..1c68f7a714 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/projects-action-modal.tsx @@ -0,0 +1,142 @@ +import { memo, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Project } from '../../../../../types/project/api' +import { getUserFacingMessage } from '@/infrastructure/fetch-json' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import * as eventTracking from '@/infrastructure/event-tracking' +import { isSmallDevice } from '@/infrastructure/event-tracking' +import Notification from '@/shared/components/notification' +import OLButton from '@/shared/components/ol/ol-button' +import { + OLModal, + OLModalBody, + OLModalFooter, + OLModalHeader, + OLModalTitle, +} from '@/shared/components/ol/ol-modal' + +type ProjectsActionModalProps = { + title?: string + action: 'transfer' | 'trash' | 'delete' | 'restore' | 'purge' + actionHandler: (project: Project, options?: any) => Promise + handleCloseModal: () => void + projects: Array + showModal: boolean + options?: any + children?: React.ReactNode +} + +const greenActions = new Set(['restore', 'transfer']) +const redActions = new Set(['trash', 'delete', 'purge']) + +function ProjectsActionModal({ + title, + action, + actionHandler, + handleCloseModal, + showModal, + projects, + options, + children, +}: ProjectsActionModalProps) { + const { t } = useTranslation() + const [errors, setErrors] = useState>([]) + const [isProcessing, setIsProcessing] = useState(false) + const isMounted = useIsMounted() + + const variant = + redActions.has(action) ? 'danger' : + greenActions.has(action) ? 'primary' : 'secondary' + + const actionLabel = + action === 'transfer' ? t('change_owner') : + t(action) + + async function handleActionForProjects(projects: Array, options?: any) { + const errored = [] + setIsProcessing(true) + setErrors([]) + + for (const project of projects) { + try { + await actionHandler(project, options) + } catch (e) { + errored.push({ projectName: project.name, error: e }) + } + } + + if (isMounted.current) { + setIsProcessing(false) + } + + if (errored.length === 0) { + handleCloseModal() + } else { + setErrors(errored) + } + } + + useEffect(() => { + if (!showModal) { + setErrors([]) + setIsProcessing(false) + } + }, [showModal]) + + useEffect(() => { + if (options) { + setErrors([]) + } + }, [options]) + + useEffect(() => { + if (showModal) { + eventTracking.sendMB('admin-user-project-list-page-interaction', { + action, + isSmallDevice, + }) + } + }, [action, showModal]) + + return ( + + + {title} + + + {children} + {!isProcessing && + errors.length > 0 && + errors.map((error, i) => ( +
    + +
    + ))} +
    + + + {t('cancel')} + + handleActionForProjects(projects, options)} + disabled={isProcessing} + > + {actionLabel} + + +
    + ) +} + +export default memo(ProjectsActionModal) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/projects-list.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/projects-list.tsx new file mode 100644 index 0000000000..6b75fd87b3 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/projects-list.tsx @@ -0,0 +1,28 @@ +import classnames from 'classnames' +import { Project } from '../../../../../types/project/api' + +type ProjectsToDisplayProps = { + projects: Project[] + projectsToDisplay: Project[] +} + +function ProjectsList({ projects, projectsToDisplay }: ProjectsToDisplayProps) { + return ( +
      + {projectsToDisplay.map(project => ( +
    • id === project.id + ), + })} + > + {project.name} +
    • + ))} +
    + ) +} + +export default ProjectsList diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/purge-project-modal.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/purge-project-modal.tsx new file mode 100644 index 0000000000..c4c8efa391 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/purge-project-modal.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Notification from '@/shared/components/notification' +import ProjectsActionModal from './projects-action-modal' +import ProjectsList from './projects-list' + +type PurgeProjectModalProps = Pick< + React.ComponentProps, + 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function PurgeProjectModal({ + projects, + actionHandler, + showModal, + handleCloseModal, +}: PurgeProjectModalProps) { + const { t } = useTranslation() + const [projectsToDisplay, setProjectsToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setProjectsToDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projects + }) + } else { + setProjectsToDisplay([]) + } + }, [showModal, projects]) + + return ( + +

    {t('about_to_permanently_delete_projects')}

    + + +
    + ) +} + +export default PurgeProjectModal diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/restore-project-modal.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/restore-project-modal.tsx new file mode 100644 index 0000000000..6d92b15c9b --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/restore-project-modal.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import ProjectsActionModal from './projects-action-modal' +import ProjectsList from './projects-list' + +type RestoreProjectModalProps = Pick< + React.ComponentProps, + 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function RestoreProjectModal({ + projects, + actionHandler, + showModal, + handleCloseModal, +}: RestoreProjectModalProps) { + const { t } = useTranslation() + const [projectsToDisplay, setProjectsToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setProjectsToDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projects + }) + } else { + setProjectsToDisplay([]) + } + }, [showModal, projects]) + + return ( + +

    {t('about_to_restore_projects')}

    + +
    + ) +} + +export default RestoreProjectModal diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/transfer-project-modal.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/transfer-project-modal.tsx new file mode 100644 index 0000000000..3602d06664 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/transfer-project-modal.tsx @@ -0,0 +1,123 @@ +import { useMemo, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import ProjectsActionModal from './projects-action-modal' +import ProjectsList from './projects-list' +import SelectOwnerForm from '../select-owner-form' +import { useUserListContext } from '../../../user-list/context/user-list-context' +import { useProjectListContext } from '../../context/project-list-context' +import { User } from '../../../../../types/user/api' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import OLForm from '@/shared/components/ol/ol-form' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormLabel from '@/shared/components/ol/ol-form-label' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' +import OLSpinner from '@/shared/components/ol/ol-spinner' +import sortUsers from '../../../user-list/util/sort-users' + +type TransferProjectModalProps = Pick< + React.ComponentProps, + 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function TransferProjectModal({ + projects, + actionHandler, + showModal, + handleCloseModal, +}: TransferProjectModalProps) { + const { t } = useTranslation() + const [projectsToDisplay, setProjectsToDisplay] = useState( + [] + ) + const { loadedUsers } = useUserListContext() + const { projectsOwnerId } = useProjectListContext() + + const potentialOwners = useMemo(() => { + if (!loadedUsers) return null; + const sortedUsers = sortUsers(loadedUsers, { by: 'name', order: 'asc' }) + const result: UserRef[] = [] + for (const user of sortedUsers) { + if (!user.deleted && user.id !== projectsOwnerId) { + result.push({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email + }) + } + } + return result + }, [loadedUsers, projectsOwnerId]) + + const [newOwner, setNewOwner] = useState(null) + const [sendEmails, setSendEmails] = useState(false) + + const options = useMemo(() => { + if (!newOwner) return null + + return { + user_id: newOwner.id, + skipEmails: !sendEmails, + } + }, [newOwner, sendEmails]) + + useEffect(() => { + if (showModal) { + setNewOwner(null) + setSendEmails(false) + } + }, [showModal]) + + useEffect(() => { + if (showModal) { + setProjectsToDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projects + }) + } else { + setProjectsToDisplay([]) + } + }, [showModal, projects]) + + const handleCheckboxChange = (e: React.ChangeEvent) => { + setSendEmails(e.currentTarget.checked) + } + + return ( + +

    {t('ownership_of_projects_will_be_transferred')}

    + + + + + + + + + + + +
    + ) +} + +export default TransferProjectModal diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/trash-project-modal.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/trash-project-modal.tsx new file mode 100644 index 0000000000..6f5d60b87d --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/trash-project-modal.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import ProjectsActionModal from './projects-action-modal' +import ProjectsList from './projects-list' + +type TrashProjectPropsModalProps = Pick< + React.ComponentProps, + 'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function TrashProjectModal({ + projects, + actionHandler, + showModal, + handleCloseModal, +}: TrashProjectPropsModalProps) { + const { t } = useTranslation() + const [projectsToDisplay, setProjectsToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setProjectsToDisplay(displayProjects => { + return displayProjects.length ? displayProjects : projects + }) + } else { + setProjectsToDisplay([]) + } + }, [showModal, projects]) + + return ( + +

    {t('about_to_trash_projects')}

    + +

    + {t('trashing_projects_wont_affect_user_collaborators')} +

    +
    + ) +} + +export default TrashProjectModal diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-ds-nav.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-ds-nav.tsx new file mode 100644 index 0000000000..e6f0a7e0c7 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-ds-nav.tsx @@ -0,0 +1,114 @@ +import { useTranslation } from 'react-i18next' +import ProjectListTable from './table/project-list-table' +import SearchForm from './search-form' +import ProjectsDropdown from './dropdown/projects-dropdown' +import SortByDropdown from './dropdown/sort-by-dropdown' +import ProjectTools from './table/project-tools/project-tools' +import ProjectListTitle from './title/project-list-title' +import LoadMore from './load-more' +import OLCol from '@/shared/components/ol/ol-col' +import OLRow from '@/shared/components/ol/ol-row' +import { TableContainer } from '@/shared/components/table' +import DashApiError from '@/features/project-list/components/dash-api-error' +import getMeta from '@/utils/meta' +import DefaultNavbar from '@/shared/components/navbar/default-navbar' +import Footer from '@/shared/components/footer/footer' +import SidebarDsNav from './sidebar/sidebar-ds-nav' +import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg' +import { getUserName } from '../util/user' +import { useProjectListContext } from '../context/project-list-context' +import { useUserIdentityContext } from '../../user-list/context/user-identity-context' + +export function ProjectListDsNav() { + + const navbarProps = getMeta('ol-navbar') + const footerProps = getMeta('ol-footer') + + const { t } = useTranslation() + const { + error, + searchText, + setSearchText, + selectedProjects, + filter, + projectsOwnerId, + } = useProjectListContext() + const { getUserNameById } = useUserIdentityContext() + + const userName = projectsOwnerId ? getUserNameById(projectsOwnerId) : t('all_users') + const tableTopArea = ( +
    +
    + {userName} +
    + +
    + ) + + return ( +
    + +
    + +
    +
    +
    + {error ? : ''} +
    +
    + +
    +
    + {selectedProjects.length !== 0 && } +
    +
    +
    +
    + + + + + +
    +
    + + +
    +
    +
    + + {tableTopArea} + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-root.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-root.tsx new file mode 100644 index 0000000000..7e0ba79699 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-root.tsx @@ -0,0 +1,35 @@ +import { useEffect } from 'react' +import { useRef } from 'react' +import { + useProjectListContext, +} from '../context/project-list-context' +import * as eventTracking from '@/infrastructure/event-tracking' +import { useTranslation } from 'react-i18next' +import LoadingBranded from '@/shared/components/loading-branded' +import { ProjectListDsNav } from './project-list-ds-nav' +import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav' +import useThemedPage from '@/shared/hooks/use-themed-page' + +export default function ProjectListRoot() { + + useThemedPage('themed-project-dashboard') + const { isLoading, loadProgress } = useProjectListContext() + + const { t } = useTranslation() + + useEffect(() => { + eventTracking.sendMB('loads_v2_dash', {}) + }, []) + + if (isLoading) { + return ( + + ) + } + + return ( + + + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/projects-filter-menu.ts b/services/web/modules/admin-tools/frontend/js/project-list/components/projects-filter-menu.ts new file mode 100644 index 0000000000..fb1e17af46 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/projects-filter-menu.ts @@ -0,0 +1,15 @@ +import { Filter, useProjectListContext } from '../context/project-list-context' + +type ProjectsMenuFilterType = { + children: (isActive: boolean) => React.ReactElement + filter: Filter +} + +function ProjectsFilterMenu({ children, filter }: ProjectsMenuFilterType) { + const { filter: activeFilter } = useProjectListContext() + const isActive = filter === activeFilter + + return children(isActive) +} + +export default ProjectsFilterMenu diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/search-form.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/search-form.tsx new file mode 100644 index 0000000000..5eaddcd26c --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/search-form.tsx @@ -0,0 +1,79 @@ +import { useTranslation } from 'react-i18next' +import * as eventTracking from '@/infrastructure/event-tracking' +import classnames from 'classnames' +import { MergeAndOverride } from '../../../../../../types/utils' +import { isSmallDevice } from '@/infrastructure/event-tracking' +import OLForm from '@/shared/components/ol/ol-form' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLCol from '@/shared/components/ol/ol-col' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import MaterialIcon from '@/shared/components/material-icon' + +type SearchFormOwnProps = { + inputValue: string + setInputValue: (input: string) => void +} + +type SearchFormProps = MergeAndOverride< + React.ComponentProps, + SearchFormOwnProps +> + +function SearchForm({ + inputValue, + setInputValue, + className, + ...props +}: SearchFormProps) { + const { t } = useTranslation() + const placeholderMessage = t('search_projects') + const placeholder = `${placeholderMessage}…` + + const handleChange: React.ComponentProps< + typeof OLFormControl + >['onChange'] = e => { + eventTracking.sendMB('admin-user-project-list-page-interaction', { + action: 'search', + isSmallDevice, + }) + setInputValue(e.target.value) + } + + const handleClear = () => setInputValue('') + + return ( + e.preventDefault()} + {...props} + > + + + } + append={ + inputValue.length > 0 && ( + + ) + } + /> + + + + ) +} + +export default SearchForm diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/select-owner-form.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/select-owner-form.tsx new file mode 100644 index 0000000000..c62da1f2b0 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/select-owner-form.tsx @@ -0,0 +1,194 @@ +import classnames from 'classnames' +import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useCombobox } from 'downshift' +import MaterialIcon from '@/shared/components/material-icon' +import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu' +import OLFormLabel from '@/shared/components/ol/ol-form-label' +import OLSpinner from '@/shared/components/ol/ol-spinner' +import { UserRef } from '../../../../types/project/api' +import { getUserName } from '../util/user' + +const FILTER_DELAY_MS = 200 +const MAX_RESULTS = 100 + +function getDisplayName(user: UserRef) { + return `${getUserName(user)} <${user.email}>` +} + +const SelectOwnerForm = React.forwardRef< + HTMLInputElement, + { + loading: boolean + users: UserRef[] + value: UserRef | null + onChange: (user: UserRef | null) => void + } +>(function SelectOwnerForm( + { loading, users, value, onChange }, + forwardedRef +) { + const { t } = useTranslation() + const inputRef = useRef(null) + + useEffect(() => { + if (!forwardedRef) return + + if (typeof forwardedRef === 'function') { + forwardedRef(inputRef.current) + } else { + forwardedRef.current = inputRef.current + } + }, [forwardedRef]) + + const lastSelectedRef = useRef(value) + + const [inputValue, setInputValue] = useState( + value ? getDisplayName(value) : '' + ) + const [debouncedInput, setDebouncedInput] = useState(inputValue) + + useEffect(() => { + lastSelectedRef.current = value + }, [value]) + + useEffect(() => { + const id = window.setTimeout(() => { + setDebouncedInput(inputValue) + }, FILTER_DELAY_MS) + + return () => { + window.clearTimeout(id) + } + }, [inputValue]) + + const filteredOptions = useMemo(() => { + if (!debouncedInput) { + return users.slice(0, MAX_RESULTS) + } + + const query = debouncedInput.toLowerCase() + const result: UserRef[] = [] + + for (const user of users) { + const label = getDisplayName(user).toLowerCase() + + if (label.includes(query)) { + result.push(user) + if (result.length >= MAX_RESULTS) { + break + } + } + } + + return result + }, [users, debouncedInput]) + + const focusInput = useCallback(() => { + inputRef.current?.focus() + }, []) + + const { + isOpen, + highlightedIndex, + getInputProps, + getItemProps, + getMenuProps, + getLabelProps, + } = useCombobox({ + items: filteredOptions, + selectedItem: value, + inputValue, + itemToString: item => (item ? getDisplayName(item) : ''), + defaultHighlightedIndex: 0, + + onInputValueChange: ({ inputValue }) => { + setInputValue(inputValue ?? '') + }, + + onSelectedItemChange: ({ selectedItem }) => { + if (selectedItem) { + lastSelectedRef.current = selectedItem + onChange(selectedItem) + setInputValue(getDisplayName(selectedItem)) + } + }, + }) + + return ( +
    + + {t('new_owner')} + {loading && } + + +
    +
    + {value && } + + { + const last = lastSelectedRef.current + if (last) { + setInputValue(getDisplayName(last)) + } else { + setInputValue('') + onChange(null) + } + }, + onKeyDown: e => { + if (e.key === 'Enter' && highlightedIndex === -1) { + e.preventDefault() + } + }, + })} + /> +
    + +
      + {isOpen && filteredOptions.length === 0 && ( +
    • + {t('No results')} +
    • + )} + + {isOpen && + filteredOptions.map((item, index) => ( +
    • + + {getDisplayName(item)} + +
    • + ))} +
    +
    +
    + ) +}) + +export default SelectOwnerForm diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/sidebar/sidebar-ds-nav.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/sidebar/sidebar-ds-nav.tsx new file mode 100644 index 0000000000..4ae17f5181 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/sidebar/sidebar-ds-nav.tsx @@ -0,0 +1,158 @@ +import classnames from 'classnames' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Dropdown } from 'react-bootstrap' +import { User as UserIcon } from '@phosphor-icons/react' +import { usePersistedResize } from '@/shared/hooks/use-resize' +import getMeta from '@/utils/meta' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import { AccountMenuItems } from '@/shared/components/navbar/account-menu-items' +import { useFeatureFlag } from '@/shared/context/split-test-context' +import SidebarFilters from './sidebar-filters' +import { getUserName } from '../../util/user' +import { useUserIdentityContext } from '../../../user-list/context/user-identity-context' +import { useProjectListContext } from '../../context/project-list-context' + +import { useScrolled } from '@/features/project-list/components/sidebar/use-scroll' +import { useSendProjectListMB } from '@/features/project-list/components/project-list-events' + +function SidebarDsNav() { + const { t } = useTranslation() + const [showAccountDropdown, setShowAccountDropdown] = useState(false) + const [showHelpDropdown, setShowHelpDropdown] = useState(false) + const { mousePos, getHandleProps, getTargetProps } = usePersistedResize({ + name: 'users-and-projects-sidebar', + }) + const sendMB = useSendProjectListMB() + const { sessionUser } = getMeta('ol-navbar') + const { containerRef, scrolledUp } = useScrolled() + const themedDsNav = useFeatureFlag('themed-project-dashboard') + + const { getUserNameById } = useUserIdentityContext() + const { projectsOwnerId } = useProjectListContext() + + const ownerName = projectsOwnerId ? getUserNameById(projectsOwnerId) : t('all_users') + const handleBack = () => { + if (history.length > 1) { + history.back() + } else { + history.replaceState({ type: 'users' }, '') + } + } + + return ( +
    + +
    + +
    + Extended CE +
    +
    +
    +
    + ) +} + +export default SidebarDsNav diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/sidebar/sidebar-filters.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/sidebar/sidebar-filters.tsx new file mode 100644 index 0000000000..ee76ddc502 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/sidebar/sidebar-filters.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next' +import { + Filter, + useProjectListContext, +} from '../../context/project-list-context' +import ProjectsFilterMenu from '../projects-filter-menu' + +type SidebarFilterProps = { + filter: Filter + text: React.ReactNode +} + +export function SidebarFilter({ filter, text }: SidebarFilterProps) { + const { selectFilter } = useProjectListContext() + + return ( + + {isActive => ( +
  • + +
  • + )} +
    + ) +} + +export default function SidebarFilters() { + const { t } = useTranslation() + + return ( +
      + + + + + +
    + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/sort/with-content.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/sort/with-content.tsx new file mode 100644 index 0000000000..a029004581 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/sort/with-content.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next' +import { Sort } from '../../../../../types/project/api' + +type SortBtnOwnProps = { + column: string + sort: Sort + text: string + onClick: () => void +} + +type WithContentProps = { + iconType?: string + screenReaderText: string +} + +export type SortBtnProps = SortBtnOwnProps & WithContentProps + +function withContent( + WrappedComponent: React.ComponentType +) { + function WithContent(hocProps: T) { + const { t } = useTranslation() + const { column, text, sort } = hocProps + let iconType + + let screenReaderText = t('sort_by_x', { x: text }) + + if (column === sort.by) { + iconType = + sort.order === 'asc' ? 'arrow_upward_alt' : 'arrow_downward_alt' + screenReaderText = t('reverse_x_sort_order', { x: text }) + } + + return ( + + ) + } + + return WithContent +} + +export default withContent diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/delete-project-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/delete-project-button.tsx new file mode 100644 index 0000000000..1514d97967 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/delete-project-button.tsx @@ -0,0 +1,84 @@ +import { memo, useCallback, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Project } from '../../../../../../../types/project/api' +import DeleteProjectModal from '../../../modals/delete-project-modal' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { deleteProject } from '../../../../util/api' +import { useProjectListContext } from '../../../../context/project-list-context' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' + +type DeleteProjectButtonProps = { + project: Project + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function DeleteProjectButton({ project, children }: DeleteProjectButtonProps) { + + if (!project.trashed || project.deleted) return null + + const { toggleSelectedProject, updateProjectViewData } = useProjectListContext() + const { t } = useTranslation() + const text = t('delete') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handleDeleteProject = useCallback(async () => { + await deleteProject(project.id) + toggleSelectedProject(project.id, false) + updateProjectViewData({ + ...project, + deleted: true, + }) + }, [project, toggleSelectedProject, updateProjectViewData]) + + return ( + <> + {children(text, handleOpenModal)} + + + ) +} + +const DeleteProjectButtonTooltip = memo(function DeleteProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(DeleteProjectButton) +export { DeleteProjectButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/download-project-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/download-project-button.tsx new file mode 100644 index 0000000000..ec8dbeb028 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/download-project-button.tsx @@ -0,0 +1,66 @@ +import { useTranslation } from 'react-i18next' +import { memo, useCallback } from 'react' +import { Project } from '../../../../../../../types/project/api' +import * as eventTracking from '@/infrastructure/event-tracking' +import { useLocation } from '@/shared/hooks/use-location' +import { isSmallDevice } from '@/infrastructure/event-tracking' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' + +type DownloadProjectButtonProps = { + project: Project + children: (text: string, downloadProject: () => void) => React.ReactElement +} + +function DownloadProjectButton({ + project, + children, +}: DownloadProjectButtonProps) { + + if (project.deleted) return null + + const { t } = useTranslation() + const text = t('download_zip_file') + const location = useLocation() + + const downloadProject = useCallback(() => { + eventTracking.sendMB('admin-user-project-list-page-interaction', { + action: 'downloadZip', + projectId: project.id, + isSmallDevice, + }) + location.assign(`/project/${project.id}/download/zip`) + }, [project, location]) + + return children(text, downloadProject) +} + +const DownloadProjectButtonTooltip = memo( + function DownloadProjectButtonTooltip({ + project, + }: Pick) { + return ( + + {(text, downloadProject) => ( + + + + )} + + ) + } +) + +export default memo(DownloadProjectButton) +export { DownloadProjectButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/purge-project-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/purge-project-button.tsx new file mode 100644 index 0000000000..19e9d3dd28 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/purge-project-button.tsx @@ -0,0 +1,81 @@ +import { useTranslation } from 'react-i18next' +import { memo, useCallback, useState } from 'react' +import getMeta from '@/utils/meta' +import { Project } from '../../../../../../../types/project/api' +import PurgeProjectModal from '../../../modals/purge-project-modal' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { purgeProject } from '../../../../util/api' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' + +type PurgeProjectButtonProps = { + project: Project + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function PurgeProjectButton({ project, children }: PurgeProjectButtonProps) { + + if (!project.deleted) return null + + const { removeProjectFromView } = useProjectListContext() + const { t } = useTranslation() + const text = t('purge') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handlePurgeProject = useCallback(async () => { + await purgeProject(project.id) + removeProjectFromView(project) + }, [project, removeProjectFromView]) + + return ( + <> + {children(text, handleOpenModal)} + + + ) +} + +const PurgeProjectButtonTooltip = memo(function PurgeProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(PurgeProjectButton) +export { PurgeProjectButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/restore-project-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/restore-project-button.tsx new file mode 100644 index 0000000000..0aaedcbf23 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/restore-project-button.tsx @@ -0,0 +1,91 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { Project } from '../../../../../../../types/project/api' +import RestoreProjectModal from '../../../modals/restore-project-modal' +import { undeleteProject } from '../../../../util/api' + +type RestoreProjectButtonProps = { + project: Project + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function RestoreProjectButton({ + project, + children, +}: RestoreProjectButtonProps) { + const { toggleSelectedProject, updateProjectViewData } = + useProjectListContext() + const { t } = useTranslation() + const text = t('restore') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handleRestoreProject = useCallback(() => { +// const ownerId = project.owner ?? getMeta('ol-user_id') + return undeleteProject(project.id, project.owner).then(data => { + toggleSelectedProject(project.id, false) + updateProjectViewData({ + ...project, + ...data, + deleted: false, + trashed: false, + }) + }) + }, [project, toggleSelectedProject, updateProjectViewData]) + + if (!project.deleted) return null + + return ( + <> + {children(text, handleOpenModal)} + + + ) +} + +const RestoreProjectButtonTooltip = memo(function RestoreProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(RestoreProjectButton) +export { RestoreProjectButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/transfer-project-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/transfer-project-button.tsx new file mode 100644 index 0000000000..96e335081d --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/transfer-project-button.tsx @@ -0,0 +1,93 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { Project } from '../../../../../../../types/project/api' +import TransferProjectModal from '../../../modals/transfer-project-modal' +import { TransferOwnershipOptions, transferProjectOwnership } from '../../../../util/api' + +type TransferProjectButtonProps = { + project: Project + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function TransferProjectButton({ project, children }: TransferProjectButtonProps) { + + if (project.deleted) return null + + const { + removeProjectFromView, + updateProjectViewData, + toggleSelectedProject, + projectsOwnerId } + = useProjectListContext() + const { t } = useTranslation() + const text = t('change_owner') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handleTransferProject = useCallback(async (project: Project, options: TransferOwnershipOptions ) => { + await transferProjectOwnership(project.id, options) + if (!projectsOwnerId) { + updateProjectViewData({ + ...project, + owner: options.user_id, + }) + toggleSelectedProject(project.id, false) + } else { + removeProjectFromView(project) + } + }, [project, removeProjectFromView, updateProjectViewData]) + + return ( + <> + {children(text, handleOpenModal)} + + + ) +} + +const TransferProjectButtonTooltip = memo(function TransferProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(TransferProjectButton) +export { TransferProjectButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/trash-project-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/trash-project-button.tsx new file mode 100644 index 0000000000..34058e42c4 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/trash-project-button.tsx @@ -0,0 +1,86 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Project } from '../../../../../../../types/project/api' +import TrashProjectModal from '../../../modals/trash-project-modal' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { trashProjectForUser } from '../../../../util/api' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' + +type TrashProjectButtonProps = { + project: Project + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function TrashProjectButton({ project, children }: TrashProjectButtonProps) { + + if (project.trashed || project.deleted ) return null + + const { toggleSelectedProject, updateProjectViewData } = + useProjectListContext() + const { t } = useTranslation() + const text = t('trash') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handleTrashProject = useCallback(async () => { + await trashProjectForUser(project.id, project.owner) + toggleSelectedProject(project.id, false) + updateProjectViewData({ + ...project, + trashed: true, + archived: false, + }) + }, [project, toggleSelectedProject, updateProjectViewData]) + + return ( + <> + {children(text, handleOpenModal)} + + + ) +} + +const TrashProjectButtonTooltip = memo(function TrashProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(TrashProjectButton) +export { TrashProjectButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/untrash-project-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/untrash-project-button.tsx new file mode 100644 index 0000000000..8a0a016324 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/action-buttons/untrash-project-button.tsx @@ -0,0 +1,65 @@ +import { useTranslation } from 'react-i18next' +import { memo, useCallback } from 'react' +import getMeta from '@/utils/meta' +import { Project } from '../../../../../../../types/project/api' +import { useProjectListContext } from '../../../../context/project-list-context' +import { untrashProjectForUser } from '../../../../util/api' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' + +type UntrashProjectButtonProps = { + project: Project + children: ( + text: string, + untrashProject: () => Promise + ) => React.ReactElement +} + +function UntrashProjectButton({ + project, + children, +}: UntrashProjectButtonProps) { + + if (!project.trashed || project.deleted) return null + + const { t } = useTranslation() + const text = t('untrash') + const { toggleSelectedProject, updateProjectViewData } = + useProjectListContext() + + const handleUntrashProject = useCallback(async () => { + await untrashProjectForUser(project.id, project.owner) + toggleSelectedProject(project.id, false) + updateProjectViewData({ ...project, trashed: false }) + }, [project, toggleSelectedProject, updateProjectViewData]) + + return children(text, handleUntrashProject) +} + +const UntrashProjectButtonTooltip = memo(function UntrashProjectButtonTooltip({ + project, +}: Pick) { + return ( + + {(text, handleUntrashProject) => ( + + + + )} + + ) +}) + +export default memo(UntrashProjectButton) +export { UntrashProjectButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/actions-cell.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/actions-cell.tsx new file mode 100644 index 0000000000..e362d2206c --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/actions-cell.tsx @@ -0,0 +1,26 @@ +import { Project } from '../../../../../../types/project/api' +import { DownloadProjectButtonTooltip } from './action-buttons/download-project-button' +import { TransferProjectButtonTooltip } from './action-buttons/transfer-project-button' +import { TrashProjectButtonTooltip } from './action-buttons/trash-project-button' +import { UntrashProjectButtonTooltip } from './action-buttons/untrash-project-button' +import { DeleteProjectButtonTooltip } from './action-buttons/delete-project-button' +import { RestoreProjectButtonTooltip } from './action-buttons/restore-project-button' +import { PurgeProjectButtonTooltip } from './action-buttons/purge-project-button' + +type ActionsCellProps = { + project: Project +} + +export default function ActionsCell({ project }: ActionsCellProps) { + return ( + <> + + + + + + + + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/date-cell.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/date-cell.tsx new file mode 100644 index 0000000000..d685d863a6 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/cells/date-cell.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from 'react-i18next' +import { formatDate, fromNowDate } from '@/utils/dates' +import OLTooltip from '@/shared/components/ol/ol-tooltip' + +type DateCellProps = { + projectId: string + actorName: string + date: string +} + +export default function DateCell({ projectId, actorName, date }: DateCellProps) { + const fromNow = fromNowDate(date) + const tooltipText = formatDate(date) + const { t } = useTranslation() + + return ( + + + {t('last_updated_date_by_x', { + lastUpdatedDate: fromNow, + person: actorName, + })} + + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-checkbox.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-checkbox.tsx new file mode 100644 index 0000000000..da1037995d --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-checkbox.tsx @@ -0,0 +1,31 @@ +import { ChangeEvent, memo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { useProjectListContext } from '../../context/project-list-context' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' + +export const ProjectCheckbox = memo<{ projectId: string; projectName: string }>( + ({ projectId, projectName }) => { + const { t } = useTranslation() + const { selectedProjectIds, toggleSelectedProject } = + useProjectListContext() + + const handleCheckboxChange = useCallback( + (event: ChangeEvent) => { + toggleSelectedProject(projectId, event.target.checked) + }, + [projectId, toggleSelectedProject] + ) + + return ( + + ) + } +) + +ProjectCheckbox.displayName = 'ProjectCheckbox' diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-owner-name.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-owner-name.tsx new file mode 100644 index 0000000000..29b8a9ec75 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-owner-name.tsx @@ -0,0 +1,10 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' + +export const ProjectListOwnerName = memo<{ ownerName: string }>( + ({ ownerName }) => { + const { t } = useTranslation() + return — {t('owned_by_x', { x: ownerName })} + } +) +ProjectListOwnerName.displayName = 'ProjectListOwnerName' diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-table-row.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-table-row.tsx new file mode 100644 index 0000000000..f5a6d5ec00 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-table-row.tsx @@ -0,0 +1,62 @@ +import { memo } from 'react' +import OwnerCell from './cells/owner-cell' +import DateCell from './cells/date-cell' +import { Filter } from '../../context/project-list-context' +import ActionsCell from './cells/actions-cell' +import ActionsDropdown from '../dropdown/actions-dropdown' +import { Project } from '../../../../../types/project/api' +import { ProjectCheckbox } from './project-checkbox' +import { ProjectListOwnerName } from './project-list-owner-name' +import { useUserIdentityContext } from '../../../user-list/context/user-identity-context' + +type ProjectListTableRowProps = { + project: Project + selected: boolean + filter: Filter +} +function ProjectListTableRow({ project, selected, filter }: ProjectListTableRowProps) { + const { getUserNameById } = useUserIdentityContext() + const ownerName = getUserNameById(project.owner) + const actorName = filter !== 'deleted' ? + getUserNameById(project.lastUpdatedBy) : + getUserNameById(project.deletedBy) + const eventDate = filter !== 'deleted' ? project.lastUpdated : project.deletedAt + + return ( + + + + + + {project.name} + + + + + + + {ownerName} + + + + + +
    + +
    +
    + +
    + + + ) +} +export default memo(ProjectListTableRow) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-table.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-table.tsx new file mode 100644 index 0000000000..b3566d0446 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-list-table.tsx @@ -0,0 +1,181 @@ +import { useCallback, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import ProjectListTableRow from './project-list-table-row' +import { useProjectListContext } from '../../context/project-list-context' +import useSort from '../../hooks/use-sort' +import withContent, { SortBtnProps } from '../sort/with-content' +import OLTable from '@/shared/components/ol/ol-table' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' +import MaterialIcon from '@/shared/components/material-icon' + +function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) { + return ( + + ) +} + +const SortByButton = withContent(SortBtn) + +function ProjectListTable() { + const { t } = useTranslation() + const { + visibleProjects, + sort, + selectedProjects, + selectOrUnselectAllProjects, + filter, + } = useProjectListContext() + const { handleSort } = useSort() + const checkAllRef = useRef(null) + + const handleAllProjectsCheckboxChange = useCallback( + (event: React.ChangeEvent) => { + selectOrUnselectAllProjects(event.target.checked) + }, + [selectOrUnselectAllProjects] + ) + + useEffect(() => { + if (checkAllRef.current) { + checkAllRef.current.indeterminate = + selectedProjects.length > 0 && + selectedProjects.length !== visibleProjects.length + } + }, [selectedProjects, visibleProjects]) + return ( + + {t('projects_list')} + + + + + + + handleSort('title')} + /> + + + {t('date_and_owner')} + + + handleSort('owner')} + /> + + {filter !== 'deleted' ? ( + + handleSort('lastUpdated')} + /> + + ) : ( + + handleSort('deletedAt')} + /> + + )} + + {t('actions')} + + + + + {visibleProjects.length > 0 ? ( + visibleProjects.map(p => ( + id === p.id)} + key={p.id} + filter={filter} + /> + )) + ) : ( + + + {t('no_projects')} + + + )} + + + ) +} + +export default ProjectListTable diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/delete-projects-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/delete-projects-button.tsx new file mode 100644 index 0000000000..f8e3737fb0 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/delete-projects-button.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react' +import OLButton from '@/shared/components/ol/ol-button' +import { useTranslation } from 'react-i18next' +import DeleteProjectModal from '../../../modals/delete-project-modal' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { deleteProject } from '../../../../util/api' +import { Project } from '../../../../../../../types/project/api' + +function DeleteProjectsButton() { + const { t } = useTranslation() + const { + selectedProjects, + toggleSelectedProject, + updateProjectViewData, + } = useProjectListContext() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = () => { + setShowModal(true) + } + + const handleCloseModal = () => { + if (isMounted.current) { + setShowModal(false) + } + } + + const handleDeleteProject = async (project: Project) => { + await deleteProject(project.id) + toggleSelectedProject(project.id, false) + updateProjectViewData({ + ...project, + deleted: true, + }) + } + + return ( + <> + + {t('delete')} + + + + ) +} + +export default DeleteProjectsButton diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/download-projects-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/download-projects-button.tsx new file mode 100644 index 0000000000..222df01dfd --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/download-projects-button.tsx @@ -0,0 +1,47 @@ +import { memo, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import * as eventTracking from '@/infrastructure/event-tracking' +import { useProjectListContext } from '../../../../context/project-list-context' +import { useLocation } from '@/shared/hooks/use-location' +import { isSmallDevice } from '@/infrastructure/event-tracking' + +function DownloadProjectsButton() { + const { selectedProjects, selectOrUnselectAllProjects } = + useProjectListContext() + const { t } = useTranslation() + const text = t('download') + const location = useLocation() + + const projectIds = selectedProjects.map(p => p.id) + + const handleDownloadProjects = useCallback(() => { + eventTracking.sendMB('admin-user-project-list-page-interaction', { + action: 'downloadZips', + isSmallDevice, + }) + + location.assign(`/project/download/zip?project_ids=${projectIds.join(',')}`) + + const selected = false + selectOrUnselectAllProjects(selected) + }, [projectIds, selectOrUnselectAllProjects, location]) + + return ( + + + + ) +} + +export default memo(DownloadProjectsButton) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/purge-projects-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/purge-projects-button.tsx new file mode 100644 index 0000000000..33bd777150 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/purge-projects-button.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react' +import OLButton from '@/shared/components/ol/ol-button' +import { useTranslation } from 'react-i18next' +import PurgeProjectModal from '../../../modals/purge-project-modal' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { purgeProject } from '../../../../util/api' +import { Project } from '../../../../../../../types/project/api' + +function PurgeProjectsButton() { + const { t } = useTranslation() + const { + selectedProjects, + removeProjectFromView + } = useProjectListContext() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = () => { + setShowModal(true) + } + + const handleCloseModal = () => { + if (isMounted.current) { + setShowModal(false) + } + } + + const handlePurgeProject = async (project: Project) => { + await purgeProject(project.id) + removeProjectFromView(project) + } + + return ( + <> + + {t('purge')} + + + + ) +} + +export default PurgeProjectsButton diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/restore-projects-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/restore-projects-button.tsx new file mode 100644 index 0000000000..dfeaa20460 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/restore-projects-button.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import OLButton from '@/shared/components/ol/ol-button' +import { useProjectListContext } from '../../../../context/project-list-context' +import { undeleteProject } from '../../../../util/api' +import RestoreProjectModal from '../../../modals/restore-project-modal' +import { Project } from '../../../../../../../types/project/api' + +function RestoreProjectsButton() { + const { t } = useTranslation() + const { + selectedProjects, + toggleSelectedProject, + updateProjectViewData, + } = useProjectListContext() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = () => { + setShowModal(true) + } + + const handleCloseModal = () => { + if (isMounted.current) { + setShowModal(false) + } + } + + const handleRestoreProject = (project: Project) => { +// const ownerId = project.owner ?? getMeta('ol-user_id') + const ownerId = project.owner ?? getMeta('ol-user_id') + return undeleteProject(project.id, project.owner).then(data => { + toggleSelectedProject(project.id, false) + updateProjectViewData({ + ...project, + ...data, + deleted: false, + trashed: false, + }) + }) + } + + return ( + <> + + {t('restore')} + + + + ) +} + +export default RestoreProjectsButton diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/transfer-projects-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/transfer-projects-button.tsx new file mode 100644 index 0000000000..279181571d --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/transfer-projects-button.tsx @@ -0,0 +1,73 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import TransferProjectModal from '../../../modals/transfer-project-modal' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { TransferOwnershipOptions, transferProjectOwnership } from '../../../../util/api' +import { Project } from '../../../../../../../types/project/api' + +function TransferProjectsButton() { + const { + selectedProjects, + removeProjectFromView, + updateProjectViewData, + toggleSelectedProject, + projectsOwnerId } + = useProjectListContext() + + const { t } = useTranslation() + const text = t('change_owner') + + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handleTransferProject = async (project: Project, options: TransferOwnershipOptions ) => { + await transferProjectOwnership(project.id, options) + if (!projectsOwnerId) { + updateProjectViewData({ + ...project, + owner: options.user_id, + }) + toggleSelectedProject(project.id, false) + } else { + removeProjectFromView(project) + } + } + + return ( + <> + + + + + + ) +} + +export default memo(TransferProjectsButton) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/trash-projects-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/trash-projects-button.tsx new file mode 100644 index 0000000000..176be9cb15 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/trash-projects-button.tsx @@ -0,0 +1,65 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import TrashProjectModal from '../../../modals/trash-project-modal' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useProjectListContext } from '../../../../context/project-list-context' +import { trashProjectForUser } from '../../../../util/api' +import { Project } from '../../../../../../../types/project/api' + +function TrashProjectsButton() { + const { selectedProjects, toggleSelectedProject, updateProjectViewData } = + useProjectListContext() + const { t } = useTranslation() + const text = t('trash') + + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const handleTrashProject = async (project: Project) => { + await trashProjectForUser(project.id, project.owner) + + toggleSelectedProject(project.id, false) + updateProjectViewData({ + ...project, + trashed: true, + archived: false, + }) + } + + return ( + <> + + + + + + ) +} + +export default memo(TrashProjectsButton) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/untrash-projects-button.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/untrash-projects-button.tsx new file mode 100644 index 0000000000..d8c3143fbc --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/buttons/untrash-projects-button.tsx @@ -0,0 +1,37 @@ +import { memo } from 'react' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import { useTranslation } from 'react-i18next' +import { useProjectListContext } from '../../../../context/project-list-context' +import { untrashProjectForUser } from '../../../../util/api' + +export default function UntrashProjectsButton() { + const { selectedProjects, toggleSelectedProject, updateProjectViewData } = + useProjectListContext() + const { t } = useTranslation() + const text = t('restore') + + const handleUntrashProjects = async () => { + for (const project of selectedProjects) { + await untrashProjectForUser(project.id, project.owner) + toggleSelectedProject(project.id, false) + updateProjectViewData({ ...project, trashed: false }) + } + } + + return ( + + + + ) +} + diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/project-tools.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/project-tools.tsx new file mode 100644 index 0000000000..e2097b37e1 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/table/project-tools/project-tools.tsx @@ -0,0 +1,52 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { useProjectListContext } from '../../../context/project-list-context' +import TransferProjectsButton from './buttons/transfer-projects-button' +import DownloadProjectsButton from './buttons/download-projects-button' +import TrashProjectsButton from './buttons/trash-projects-button' +import UntrashProjectsButton from './buttons/untrash-projects-button' +import DeleteProjectsButton from './buttons/delete-projects-button' +import RestoreProjectsButton from './buttons/restore-projects-button' +import PurgeProjectsButton from './buttons/purge-projects-button' +import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar' +import OLButtonGroup from '@/shared/components/ol/ol-button-group' + +function ProjectTools() { + const { t } = useTranslation() + const { filter } = useProjectListContext() + + return ( + + + {filter !== 'deleted' && } + {filter !== 'deleted' && } + {filter !== 'deleted' && filter !== 'trashed' && } + {filter === 'trashed' && } + + {filter === 'trashed' && ( + + + + )} + + {(filter === 'deleted') && ( + <> + + + + + + + + )} + + ) +} + +export default memo(ProjectTools) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/title/project-list-title.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/title/project-list-title.tsx new file mode 100644 index 0000000000..e406e24ef7 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/title/project-list-title.tsx @@ -0,0 +1,43 @@ +import { useTranslation } from 'react-i18next' +import classnames from 'classnames' +import { Filter } from '../../context/project-list-context' + +function ProjectListTitle({ + filter, + className, +}: { + filter: Filter + className?: string +}) { + const { t } = useTranslation() + let message = t('projects') + let extraProps = {} + + switch (filter) { + case 'owned': + message = t('all_projects') + break + case 'inactive': + message = t('inactive_projects') + break + case 'trashed': + message = t('trashed_projects') + break + case 'deleted': + message = t('deleted_projects') + break + } + + return ( +

    + {message} +

    + ) +} + +export default ProjectListTitle diff --git a/services/web/modules/admin-tools/frontend/js/project-list/context/project-list-context.tsx b/services/web/modules/admin-tools/frontend/js/project-list/context/project-list-context.tsx new file mode 100644 index 0000000000..6b99c42beb --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/context/project-list-context.tsx @@ -0,0 +1,335 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import getMeta from '@/utils/meta' +import { debugConsole } from '@/utils/debugging' +import useAsync from '@/shared/hooks/use-async' +import usePersistedState from '@/shared/hooks/use-persisted-state' +import { + GetProjectsResponseBody, + Project, + Sort, +} from '../../../../types/project/api' +import { getProjects } from '../util/api' +import { useUserIdentityContext } from '../../user-list/context/user-identity-context' +import sortProjects from '../util/sort-projects' + +const MAX_PROJECT_PER_PAGE = 20 + +export type Filter = 'owned' | 'trashed' | 'deleted' | 'inactive' + +type FilterMap = { + [key in Filter]: Partial | ((project: Project) => boolean) +} + +const filters: FilterMap = { + owned: (project) => + project.deleted === false && + project.trashed === false && + project.owner != null, + + trashed: (project) => + project.deleted === false && + project.trashed === true && + project.owner != null, + + deleted: (project) => + project.deleted === true, + + inactive: (project) => + project.deleted === false && + project.trashed === false && + project.inactive === true, +} + +export type ProjectListContextValue = { + error: Error | null + filter: Filter + hiddenProjectsCount: number + isLoading: ReturnType['isLoading'] + loadMoreCount: number + loadMoreProjects: () => void + loadProgress: number + removeProjectFromView: (project: Project) => void + selectFilter: (filter: Filter) => void + selectedProjectIds: Set + selectedProjects: Project[] + selectOrUnselectAllProjects: React.Dispatch> + searchText: string + setSearchText: React.Dispatch> + setSelectedProjectIds: React.Dispatch>> + setSort: React.Dispatch> + showAllProjects: () => void + sort: Sort + toggleSelectedProject: (projectId: string, selected?: boolean) => void + totalProjectsCount: number + projectsOwnerId: string | null + updateProjectViewData: (newProjectData: Project) => void + visibleProjects: Project[] +} + +export const ProjectListContext = createContext< + ProjectListContextValue | undefined +>(undefined) + +type ProjectListProviderProps = { + projectsOwnerId: string | null + children: ReactNode +} + +export function ProjectListProvider({ projectsOwnerId, children }: ProjectListProviderProps) { + const { getUserById } = useUserIdentityContext() + + const prefetchedProjectsBlob = projectsOwnerId ? null : getMeta('ol-prefetchedProjectsBlob') + const [loadedProjects, setLoadedProjects] = useState( + prefetchedProjectsBlob?.projects ?? [] + ) + + const [maxVisibleProjects, setMaxVisibleProjects] = + useState(MAX_PROJECT_PER_PAGE) + + const [loadProgress, setLoadProgress] = useState( + prefetchedProjectsBlob ? 100 : 20 + ) + + const [totalProjectsCount, setTotalProjectsCount] = useState( + prefetchedProjectsBlob?.totalSize ?? 0 + ) + + const [filter, setFilter] = usePersistedState( + 'admin-project-list-filter', + 'owned' + ) + const [sort, setSort] = useState({ + by: filter === 'deleted' ? 'deletedAt' : 'lastUpdated', + order: 'desc', + }) + const prevSortRef = useRef(sort) + + const [searchText, setSearchText] = useState('') + + const { + isLoading: loading, + isIdle, + error, + runAsync, + } = useAsync({ + status: prefetchedProjectsBlob ? 'resolved' : 'pending', + data: prefetchedProjectsBlob, + }) + + const isLoading = isIdle ? true : loading + + useEffect(() => { + if (prefetchedProjectsBlob) return + + setLoadProgress(40) + + runAsync(getProjects({ userId: projectsOwnerId, by: 'lastUpdated', order: 'desc' })) + .then(data => { + setLoadedProjects(data.projects) + setTotalProjectsCount(data.totalSize) + }) + .catch(debugConsole.error) + .finally(() => { + setLoadProgress(100) + }) + }, [projectsOwnerId, runAsync, prefetchedProjectsBlob]) + + const sortedProjects = useMemo(() => { + if (prevSortRef.current === sort) return loadedProjects + + const sorted = sortProjects(loadedProjects, sort, getUserById) + prevSortRef.current = sort + return sorted + }, [loadedProjects, sort, getUserById]) + + const filteredProjects = useMemo(() => { + let result = sortedProjects + + if (searchText.length) { + const lower = searchText.toLowerCase() + result = result.filter(project => + project.name.toLowerCase().includes(lower) + ) + } + + return result.filter(filters[filter]) + }, [sortedProjects, searchText, filter]) + + const visibleProjects = useMemo(() => { + return filteredProjects.slice(0, maxVisibleProjects) + }, [filteredProjects, maxVisibleProjects]) + + const hiddenProjectsCount = Math.max( + filteredProjects.length - visibleProjects.length, + 0 + ) + + const loadMoreCount = Math.min( + hiddenProjectsCount, + MAX_PROJECT_PER_PAGE + ) + + const showAllProjects = useCallback(() => { + setMaxVisibleProjects(v => v + hiddenProjectsCount) + }, [hiddenProjectsCount]) + + const loadMoreProjects = useCallback(() => { + setMaxVisibleProjects(v => v + loadMoreCount) + }, [maxVisibleProjects]) + + const [selectedProjectIds, setSelectedProjectIds] = useState( + () => new Set() + ) + + const toggleSelectedProject = useCallback( + (projectId: string, selected?: boolean) => { + setSelectedProjectIds(prevSelectedProjectIds => { + const selectedProjectIds = new Set(prevSelectedProjectIds) + if (selected === true) { + selectedProjectIds.add(projectId) + } else if (selected === false) { + selectedProjectIds.delete(projectId) + } else if (selectedProjectIds.has(projectId)) { + selectedProjectIds.delete(projectId) + } else { + selectedProjectIds.add(projectId) + } + return selectedProjectIds + }) + }, + [] + ) + + const selectedProjects = useMemo(() => { + return visibleProjects.filter(project => selectedProjectIds.has(project.id)) + }, [selectedProjectIds, visibleProjects]) + + + const selectOrUnselectAllProjects = useCallback( + (checked: any) => { + setSelectedProjectIds(prevSelectedProjectIds => { + const selectedProjectIds = new Set(prevSelectedProjectIds) + for (const project of visibleProjects) { + if (checked) { + selectedProjectIds.add(project.id) + } else { + selectedProjectIds.delete(project.id) + } + } + return selectedProjectIds + }) + }, + [visibleProjects] + ) + + const selectFilter = useCallback( + (filter: Filter) => { + setFilter(filter) + + setSort(prev => { + if (filter === 'deleted' && prev.by === 'lastUpdated') { + return { ...prev, by: 'deletedAt' } + } + if (filter !== 'deleted' && prev.by === 'deletedAt') { + return { ...prev, by: 'lastUpdated' } + } + return prev + }) + + selectOrUnselectAllProjects(false) + }, + [selectOrUnselectAllProjects] + ) + + const updateProjectViewData = useCallback((newProjectData: Project) => { + setLoadedProjects(loadedProjects => { + return loadedProjects.map(p => + p.id === newProjectData.id ? { ...newProjectData } : p + ) + }) + }, []) + + const removeProjectFromView = useCallback((project: Project) => { + setLoadedProjects(loadedProjects => { + return loadedProjects.filter(p => p.id !== project.id) + }) + }, []) + + const value = useMemo( + () => ({ + error, + filter, + hiddenProjectsCount, + isLoading, + loadMoreCount, + loadMoreProjects, + loadProgress, + removeProjectFromView, + selectFilter, + selectedProjects, + selectedProjectIds, + selectOrUnselectAllProjects, + searchText, + setSearchText, + setSelectedProjectIds, + setSort, + showAllProjects, + sort, + toggleSelectedProject, + totalProjectsCount, + updateProjectViewData, + projectsOwnerId, + visibleProjects, + }), + [ + error, + filter, + hiddenProjectsCount, + isLoading, + loadMoreCount, + loadMoreProjects, + loadProgress, + removeProjectFromView, + selectFilter, + selectedProjectIds, + selectedProjects, + selectOrUnselectAllProjects, + searchText, + setSearchText, + setSelectedProjectIds, + setSort, + showAllProjects, + sort, + toggleSelectedProject, + totalProjectsCount, + projectsOwnerId, + updateProjectViewData, + visibleProjects, + ] + ) + + return ( + + {children} + + ) +} + +export function useProjectListContext() { + const context = useContext(ProjectListContext) + if (!context) { + throw new Error( + 'ProjectListContext is only available inside ProjectListProvider' + ) + } + return context +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/hooks/use-sort.ts b/services/web/modules/admin-tools/frontend/js/project-list/hooks/use-sort.ts new file mode 100644 index 0000000000..0cd349da6f --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/hooks/use-sort.ts @@ -0,0 +1,32 @@ +import { useEffect } from 'react' +import { useProjectListContext } from '../context/project-list-context' +import { Sort } from '../../../../types/project/api' +import { SortingOrder } from '../../../../../../types/sorting-order' + +const toggleSort = (order: SortingOrder): SortingOrder => + order === 'asc' ? 'desc' : 'asc' + +function useSort() { + const { filter, sort, setSort } = useProjectListContext() + + const handleSort = (by: Sort['by']) => { + setSort(prev => ({ + by, + order: prev.by === by ? toggleSort(prev.order) : prev.order, + })) + } + + useEffect(() => { + if (filter === 'deleted' && sort.by === 'lastUpdated') { + setSort(prev => ({ ...prev, by: 'deletedAt' })) + } + + if (filter !== 'deleted' && sort.by === 'deletedAt') { + setSort(prev => ({ ...prev, by: 'lastUpdated' })) + } + }, [filter, sort.by, setSort]) + + return { handleSort } +} + +export default useSort diff --git a/services/web/modules/admin-tools/frontend/js/project-list/util/api.ts b/services/web/modules/admin-tools/frontend/js/project-list/util/api.ts new file mode 100644 index 0000000000..3d862a4933 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/util/api.ts @@ -0,0 +1,43 @@ +import { + GetProjectsResponseBody, + Sort, +} from '../../../../types/project/api' +import { deleteJSON, postJSON } from '@/infrastructure/fetch-json' + +export type TransferOwnershipOptions = { + user_id: string + skipEmails: boolean +} + +export function getProjects( + params: { + userId: string, + by: Sort['by'] + order: Sort['order'] + }): Promise { + const { userId, ...sort } = params + return postJSON(`/admin/user/${userId}/projects`, { body: { sort } }) +} + +export function deleteProject(projectId: string) { + return deleteJSON(`/project/${projectId}`) +} + +export function purgeProject(projectId: string) { + return deleteJSON(`/admin/project/${projectId}/purge`) +} + +export function undeleteProject(projectId: string, userId: string) { + return postJSON(`/admin/project/${projectId}/undelete`, { body: { userId } }) +} + +export function trashProjectForUser(projectId: string, userId: string) { + return postJSON(`/admin/project/${projectId}/trash`, { body: { userId } }) +} + +export function untrashProjectForUser(projectId: string, userId: string) { + return postJSON(`/admin/project/${projectId}/untrash`, { body: { userId } }) +} +export function transferProjectOwnership(projectId: string, options: TransferOwnershipOptions) { + return postJSON(`/project/${projectId}/transfer-ownership`, { body: { ...options } }) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/util/sort-projects.ts b/services/web/modules/admin-tools/frontend/js/project-list/util/sort-projects.ts new file mode 100644 index 0000000000..0ebfb95407 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/util/sort-projects.ts @@ -0,0 +1,87 @@ +import { Project, Sort } from '../../../../types/project/api' +import { SortingOrder } from '../../../../../../types/sorting-order' +import { Compare } from '../../../../../../types/helpers/array/sort' +import { User } from '../../../../types/user/api' + +const order = (order: SortingOrder, projects: Project[]) => { + return order === 'asc' ? [...projects] : projects.reverse() +} + +export const ownerNameComparator = + (getUserById: (userId: string) => User | null) => + (v1: Project, v2: Project) => { + const user1 = getUserById(v1.owner) + const user2 = getUserById(v2.owner) + + if (!user1) { + if (!user2) { + return v1.lastUpdated < v2.lastUpdated + ? Compare.SORT_A_BEFORE_B + : Compare.SORT_A_AFTER_B + } + return Compare.SORT_A_AFTER_B + } + + if (!user2) { + return Compare.SORT_A_BEFORE_B + } + + const lastNameCmp = user1.lastName.localeCompare(user2.lastName) + if (lastNameCmp !== 0) return lastNameCmp + + const firstNameCmp = user1.firstName.localeCompare(user2.firstName) + if (firstNameCmp !== 0) return firstNameCmp + + return v1.lastUpdated < v2.lastUpdated + ? Compare.SORT_A_BEFORE_B + : Compare.SORT_A_AFTER_B + } + +export const defaultComparator = ( + v1: Project, + v2: Project, + key: 'name' | 'lastUpdated' | 'deletedAt' +) => { + const value1 = v1[key]?.toLowerCase() + const value2 = v2[key]?.toLowerCase() + + if (value1 !== value2) { + if (value1 === undefined) return Compare.SORT_A_BEFORE_B + if (value2 === undefined) return Compare.SORT_A_AFTER_B + return value1 < value2 ? Compare.SORT_A_BEFORE_B : Compare.SORT_A_AFTER_B + } + + return Compare.SORT_KEEP_ORDER +} + +export default function sortProjects( + projects: Project[], + sort: Sort, + getUserById: (userId: string) => string +) { + let sorted = [...projects] + + if (sort.by === 'title') { + sorted = sorted.sort((...args) => { + return defaultComparator(...args, 'name') + }) + } + + if (sort.by === 'lastUpdated') { + sorted = sorted.sort((...args) => { + return defaultComparator(...args, 'lastUpdated') + }) + } + + if (sort.by === 'deletedAt') { + sorted = sorted.sort((...args) => { + return defaultComparator(...args, 'deletedAt') + }) + } + + if (sort.by === 'owner') { + sorted.sort(ownerNameComparator(getUserById)) + } + + return order(sort.order, sorted) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/util/user.ts b/services/web/modules/admin-tools/frontend/js/project-list/util/user.ts new file mode 100644 index 0000000000..8a2e364192 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/util/user.ts @@ -0,0 +1,15 @@ +import { User } from '../../../../types/user/api' +export function getUserName(user: User) { + + if (!user) return '[N/A]' + + const { firstName, lastName, email } = user + if (firstName || lastName) { + return [firstName, lastName].filter(n => n != null).join(' ') + } + if (email) { + return email + } + + return '[Noname]' +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button.tsx new file mode 100644 index 0000000000..2305300c1b --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button.tsx @@ -0,0 +1,61 @@ +import OLButton from '@/shared/components/ol/ol-button' +import Button from '@/shared/components/button/button' +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { sendMB } from '@/infrastructure/event-tracking' +import { useSendUserListMB } from './user-list-events' +import CreateAccountModal from './create-account-button/create-account-modal' + +type Segmentation = { + action: string +} + +type CreateAccountButtonProps = { + id: string + buttonText?: string + className?: string + trackingKey?: string +} + +function CreateAccountButton({ + id, + buttonText, + className, + trackingKey, +}: CreateAccountButtonProps) { + const { t } = useTranslation() + const [showModal, setShowModal] = useState(false) + const sendUserListMB = useSendUserListMB() + + const handleButtonClick = useCallback(() => { + if (trackingKey) { + const segmentation: Segmentation = { + action: 'create-account-click', + } + sendMB(trackingKey, segmentation) + } + + sendUserListMB('create-account-click') + setShowModal(true) + }, [sendUserListMB, trackingKey]) + + return ( +
    + + {buttonText || t('create_account')} + + + {showModal && ( + setShowModal(false)} /> + )} +
    + ) +} + +export default CreateAccountButton + diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button/create-account-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button/create-account-modal.tsx new file mode 100644 index 0000000000..99923114e0 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button/create-account-modal.tsx @@ -0,0 +1,22 @@ +import ModalContentNewUserForm from './modal-content-new-user-form' +import { OLModal } from '@/shared/components/ol/ol-modal' + +type CreateAccountModalProps = { + onHide: () => void +} + +function CreateAccountModal({ onHide }: CreateAccountModalProps) { + return ( + + + + ) +} + +export default CreateAccountModal diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button/modal-content-new-user-form.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button/modal-content-new-user-form.tsx new file mode 100644 index 0000000000..a785b328cf --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/create-account-button/modal-content-new-user-form.tsx @@ -0,0 +1,195 @@ +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' +import useAsync from '@/shared/hooks/use-async' +import { debugConsole } from '@/utils/debugging' +import { + getUserFacingMessage, + postJSON, +} from '@/infrastructure/fetch-json' +import { useRefWithAutoFocus } from '@/shared/hooks/use-ref-with-auto-focus' +import Notification from '@/shared/components/notification' +import { + OLModalBody, + OLModalFooter, + OLModalHeader, + OLModalTitle, +} from '@/shared/components/ol/ol-modal' +import OLButton from '@/shared/components/ol/ol-button' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import OLForm from '@/shared/components/ol/ol-form' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormLabel from '@/shared/components/ol/ol-form-label' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' +import getMeta from '@/utils/meta' +import { useUserListContext } from '../../context/user-list-context' +import { User } from '../../../../../types/user/api' + +type CreateUserResult = { + user: User +} + +type Props = { + handleCloseModal: () => void +} + +const availableAuthMethods = getMeta("ol-availableAuthMethods") +const onlyLocalAuthEnabled = (availableAuthMethods.length === 1 && availableAuthMethods[0] === 'local') + +function ModalContentNewUserForm({ handleCloseModal }: Props) { + const { t } = useTranslation() + const { autoFocusedRef } = useRefWithAutoFocus() + const [userData, setUserData] = useState({ + email: '', + firstName: '', + lastName: '', + isAdmin: false, + isExternal: false, + }) + + const { refreshUsers, addUserToView } = useUserListContext() + const [redirecting, setRedirecting] = useState(false) + const { isLoading, isError, error, runAsync } = useAsync() + + const createAccount = () => { + runAsync( + postJSON('/admin/user/create', { + body: { + email: userData.email.trim(), + first_name: userData.firstName.trim(), + last_name: userData.lastName.trim(), + isAdmin: userData.isAdmin, + isExternal: userData.isExternal, + } + }) + ) + .then(data => { + addUserToView(data.user) + handleCloseModal() + }) + .catch(debugConsole.error) + } + + const handleTextChange = (e: React.ChangeEvent) => { + const { name, value } = e.currentTarget + setUserData(prev => ({ ...prev, [name]: value })) + } + + const handleCheckboxChange = (e: React.ChangeEvent) => { + const { name, checked } = e.currentTarget + setUserData(prev => ({ ...prev, [name]: checked })) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + createAccount() + } + + return ( + <> + + {t('create_account')} + + + + {isError && ( +
    + +
    + )} + + + + {t('email_address')} + + + + {t('first_name')} + + + + {t('last_name')} + + + + + + + + + {(!onlyLocalAuthEnabled && + + + + + + )} + + +
    + + + + {t('cancel')} + + + {t('create')} + + + + ) +} + +export default ModalContentNewUserForm diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/actions-dropdown.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/actions-dropdown.tsx new file mode 100644 index 0000000000..ee2aaf690b --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/actions-dropdown.tsx @@ -0,0 +1,161 @@ +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import { + Dropdown, + DropdownItem, + DropdownMenu, + DropdownToggle, +} from '@/shared/components/dropdown/dropdown-menu' +import MaterialIcon from '@/shared/components/material-icon' +import OLSpinner from '@/shared/components/ol/ol-spinner' +import FlagUserButton from '../table/cells/action-buttons/flag-user-button' +import DeleteUserButton from '../table/cells/action-buttons/delete-user-button' +import UpdateUserButton from '../table/cells/action-buttons/update-user-button' +import RestoreUserButton from '../table/cells/action-buttons/restore-user-button' +import PurgeUserButton from '../table/cells/action-buttons/purge-user-button' +import ShowUserInfoButton from '../table/cells/action-buttons/show-user-info-button' +import SendRegEmailButton from '../table/cells/action-buttons/send-reg-email-button' +import { User } from '../../../../../types/user/api' + +const flagActions = [ + { action: 'suspend', icon: 'pause', unfilled: false }, + { action: 'resume', icon: 'resume', unfilled: false }, +] + +type ActionDropdownProps = { + user: User +} + +function ActionsDropdown({ user }: ActionDropdownProps) { + const { t } = useTranslation() + const isSelf = getMeta('ol-user_id') === user.id + + return ( + + + + + + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + {!isSelf && ( + <> + {flagActions.map(({ action, icon, unfilled }) => ( + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + ))} + + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + {(user.authMethods.includes('local') && !user.suspended) && ( + + {(text, handleOpenModal) => ( +
  • + + {text} + +
  • + )} +
    + )} + + )} +
    +
    + ) +} + +export default ActionsDropdown diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/menu-item-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/menu-item-button.tsx new file mode 100644 index 0000000000..200cdecf95 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/menu-item-button.tsx @@ -0,0 +1,30 @@ +import { ReactNode } from 'react' + +type MenuItemButtonProps = { + children: ReactNode + onClick?: (e?: React.MouseEvent) => void + className?: string + afterNode?: React.ReactNode +} + +export default function MenuItemButton({ + children, + onClick, + className, + afterNode, + ...buttonProps +}: MenuItemButtonProps) { + return ( +
  • + + {afterNode} +
  • + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/sort-by-dropdown.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/sort-by-dropdown.tsx new file mode 100644 index 0000000000..3e2351e130 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/sort-by-dropdown.tsx @@ -0,0 +1,107 @@ +import { useState, useEffect, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { + Dropdown, + DropdownHeader, + DropdownItem, + DropdownMenu, + DropdownToggle, +} from '@/shared/components/dropdown/dropdown-menu' +import { useUserListContext } from '../../context/user-list-context' +import useSort from '../../hooks/use-sort' +import withContent, { SortBtnProps } from '../sort/with-content' +import { Sort } from '../../../../../types/user/api' + +function Item({ onClick, text, iconType }: SortBtnProps) { + return ( + + {text} + + ) +} + +const ItemWithContent = withContent(Item) + +function SortByDropdown() { + const { t } = useTranslation() + const [title, setTitle] = useState(() => t('last_modified')) + const { filter, sort } = useUserListContext() + const { handleSort } = useSort() + const sortByTranslations = useRef>({ + name: t('name'), + email: t('email'), + signUpDate: t('signed_up'), + lastActive: t('last_active'), + deletedAt: t('deleted_at'), + }) + + const handleClick = (by: Sort['by']) => { + setTitle(sortByTranslations.current[by]) + handleSort(by) + } + + useEffect(() => { + setTitle(sortByTranslations.current[sort.by]) + }, [sort.by]) + + return ( + + + + {title} + + + + + {t('sort_by')}: + + handleClick('name')} + /> + handleClick('email')} + /> + + { filter !== 'deleted' ? ( + handleClick('signUpDate')} + /> + ) : ( + handleClick('deletedAt')} + /> + )} + handleClick('lastActive')} + /> + + + ) +} + +export default SortByDropdown diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/users-dropdown.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/users-dropdown.tsx new file mode 100644 index 0000000000..559091e4b5 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/dropdown/users-dropdown.tsx @@ -0,0 +1,74 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { + Filter, + useUserListContext, +} from '../../context/user-list-context' +import { + Dropdown, + DropdownItem, + DropdownMenu, + DropdownToggle, +} from '@/shared/components/dropdown/dropdown-menu' +import UsersFilterMenu from '../users-filter-menu' + +type ItemProps = { + filter: Filter + text: string + onClick?: () => void +} + +export function Item({ filter, text, onClick }: ItemProps) { + const { selectFilter } = useUserListContext() + const handleClick = () => { + selectFilter(filter) + onClick?.() + } + + return ( + + {isActive => ( + + {text} + + )} + + ) +} + +function UsersDropdown() { + const { t } = useTranslation() + const { filter, filterTranslations } = useUserListContext() + + const title = filterTranslations.get(filter) ?? t('user_category_all') + + return ( + + + + {title} + + + + {[...filterTranslations.entries()].map(([key, text]) => ( +
  • + +
  • + ))} +
    +
    + ) +} + +export default UsersDropdown diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/load-more.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/load-more.tsx new file mode 100644 index 0000000000..1e51116c74 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/load-more.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from 'react-i18next' +import { useUserListContext } from '../context/user-list-context' +import OLButton from '@/shared/components/ol/ol-button' + +export default function LoadMore() { + const { + visibleUsers, + hiddenUsersCount, + loadMoreCount, + showAllUsers, + loadMoreUsers, + } = useUserListContext() + const { t } = useTranslation() + + return ( +
    + {hiddenUsersCount > 0 ? ( + <> + loadMoreUsers()} + > + {t('show_x_more_users', { x: loadMoreCount })} + + + ) : null} +

    + {hiddenUsersCount > 0 ? ( + <> + + {t('showing_x_out_of_n_users', { + x: visibleUsers.length, + n: visibleUsers.length + hiddenUsersCount, + })} + {' '} + showAllUsers()} + className="btn-inline-link" + > + {t('show_all_users')} + + + ) : ( + + {t('showing_x_out_of_n_users', { + x: visibleUsers.length, + n: visibleUsers.length, + })} + + )} +

    +
    + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/delete-user-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/delete-user-modal.tsx new file mode 100644 index 0000000000..025dcf6f98 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/delete-user-modal.tsx @@ -0,0 +1,138 @@ +import React, { useEffect, useState, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import UsersActionModal from './users-action-modal' +import UsersList from './users-list' +import Notification from '@/shared/components/notification' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' +import OLForm from '@/shared/components/ol/ol-form' +import SelectOwnerForm from '../../../project-list/components/select-owner-form' +import { useUserListContext } from '../../../user-list/context/user-list-context' +import { UserRef } from '../../../../../types/project/api' +import sortUsers from '../../../user-list/util/sort-users' + +type DeleteUserModalProps = Pick< + React.ComponentProps, + 'users' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function DeleteUserModal({ + users, + actionHandler, + showModal, + handleCloseModal, +}: DeleteUserModalProps) { + const { t } = useTranslation() + const { loadedUsers } = useUserListContext() + + const [usersToDisplay, setUsersToDisplay] = useState([]) + const [sendEmail, setSendEmail] = useState(false) + const [transferProjects, setTransferProjects] = useState(false) + const [newOwner, setNewOwner] = useState(null) + + const selectOwnerInputRef = useRef(null) + + const potentialOwners = useMemo(() => { + if (!loadedUsers) return [] + + const excludeIds = new Set(users.map(u => u.id)) + const possibleUsers = loadedUsers.filter( + user => !user.deleted && !excludeIds.has(user.id) + ) + return sortUsers(possibleUsers, { by: 'name', order: 'asc' }) + }, [loadedUsers, users]) + + useEffect(() => { + if (showModal) { + setUsersToDisplay(displayUsers => displayUsers.length ? displayUsers : users) + setSendEmail(false) + setTransferProjects(false) + setNewOwner(null) + } else { + setUsersToDisplay([]) + } + }, [showModal, users]) + + useEffect(() => { + if (transferProjects) { + selectOwnerInputRef.current?.focus() + } + }, [transferProjects]) + + const handleSendEmailChange = (e: React.ChangeEvent) => { + setSendEmail(e.currentTarget.checked) + } + + const handleTransferProjectsChange = (e: React.ChangeEvent) => { + setTransferProjects(e.currentTarget.checked) + if (!e.currentTarget.checked) { + setNewOwner(null) + } + } + + const options = useMemo(() => { + return { + sendEmail, + toUserId: transferProjects && newOwner ? newOwner.id : null, + } + }, [sendEmail, transferProjects, newOwner]) + + return ( + +

    {t('about_to_delete_accounts')}

    + + + + + + + + + + + + + {transferProjects && ( + + + + )} + +
    + ) +} + +export default DeleteUserModal + diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/flag-user-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/flag-user-modal.tsx new file mode 100644 index 0000000000..af724f86ea --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/flag-user-modal.tsx @@ -0,0 +1,67 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import UsersActionModal from './users-action-modal' +import UsersList from './users-list' + +type FlagUserModalProps = Pick< + React.ComponentProps, + 'users' | 'action' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function FlagUserModal({ + users, + action, + actionHandler, + showModal, + handleCloseModal, +}: FlagUserModalProps) { + const { t } = useTranslation() + const [usersToDisplay, setUsersToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setUsersToDisplay(displayUsers => { + return displayUsers.length ? displayUsers : users + }) + } else { + setUsersToDisplay([]) + } + }, [showModal, users]) + + let userData + switch (action) { + case 'set_admin': + userData = { isAdmin: true } + break + case 'unset_admin': + userData = { isAdmin: false } + break + case 'suspend': + userData = { suspended: true } + break + case 'resume': + userData = { suspended: false } + break + default: + return + } + +return ( + +

    {t(`about_to_${action}_accounts`)}

    + +
    + ) +} + +export default FlagUserModal diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/purge-user-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/purge-user-modal.tsx new file mode 100644 index 0000000000..e455c01dc8 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/purge-user-modal.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import UsersActionModal from './users-action-modal' +import UsersList from './users-list' +import Notification from '@/shared/components/notification' + +type PurgeUserModalProps = Pick< + React.ComponentProps, + 'users' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function PurgeUserModal({ + users, + actionHandler, + showModal, + handleCloseModal, +}: PurgeUserModalProps) { + const { t } = useTranslation() + const [usersToDisplay, setUsersToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setUsersToDisplay(displayUsers => { + return displayUsers.length ? displayUsers : users + }) + } else { + setUsersToDisplay([]) + } + }, [showModal, users]) + + return ( + +

    {t('about_to_permanently_delete_accounts')}

    + + +
    + ) +} + +export default PurgeUserModal diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/restore-user-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/restore-user-modal.tsx new file mode 100644 index 0000000000..460a92924c --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/restore-user-modal.tsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import UsersActionModal from './users-action-modal' +import UsersList from './users-list' + +type RestoreUserModalProps = Pick< + React.ComponentProps, + 'users' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function RestoreUserModal({ + users, + actionHandler, + showModal, + handleCloseModal, +}: RestoreUserModalProps) { + const { t } = useTranslation() + const [usersToDisplay, setUsersToDisplay] = useState( + [] + ) + + useEffect(() => { + if (showModal) { + setUsersToDisplay(displayUsers => { + return displayUsers.length ? displayUsers : users + }) + } else { + setUsersToDisplay([]) + } + }, [showModal, users]) + + return ( + +

    {t('about_to_restore_accounts')}

    + +
    + ) +} + +export default RestoreUserModal diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/send-reg-email-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/send-reg-email-modal.tsx new file mode 100644 index 0000000000..c0f64d4f1d --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/send-reg-email-modal.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import UsersActionModal from './users-action-modal' +import UsersList from './users-list' +import { useUserListContext } from '../../context/user-list-context' + +type SendRegEmailModalProps = Pick< + React.ComponentProps, + 'users' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> + +function SendRegEmailModal({ + users, + actionHandler, + showModal, + handleCloseModal, +}: SendRegEmailModalProps) { + const { t } = useTranslation() + const { selectedUsers, toggleSelectedUser } = useUserListContext() + + const localUsers = useMemo( + () => users.filter(user => user.authMethods?.includes('local') && !user.suspended), + [users] + ) + + const [usersToDisplay, setUsersToDisplay] = useState([]) + + useEffect(() => { + if (!showModal) return + + selectedUsers.forEach(user => { + if (!user.authMethods?.includes('local') || user.suspended) { + toggleSelectedUser(user.id, false) + } + }) + // intentionally depends only on showModal + }, [showModal]) + + useEffect(() => { + if (showModal) { + setUsersToDisplay(displayUsers => { + return displayUsers.length ? displayUsers : localUsers + }) + } else { + setUsersToDisplay([]) + } + }, [showModal, localUsers]) + + return ( + +

    {t('about_to_resend_activation_email')}

    + +
    + ) +} + +export default SendRegEmailModal + diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/show-user-info-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/show-user-info-modal.tsx new file mode 100644 index 0000000000..8b31eec5d2 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/show-user-info-modal.tsx @@ -0,0 +1,173 @@ +import React, { useEffect, useState } from 'react' +import { Card } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import OLCard from '@/shared/components/ol/ol-card' +import OLBadge from '@/shared/components/ol/ol-badge' +import { formatDate } from '@/utils/dates' +import UsersActionModal from './users-action-modal' +import { getAdditionalUserInfo } from '../../util/api' + +type ShowUserInfoModalProps = Pick< + React.ComponentProps, + 'users' | 'showModal' | 'handleCloseModal' +> + +function InfoRow({ + label, + value, +}: { + label: string + value: React.ReactNode +}) { + return ( + + + {label} + + {value} + + ) +} + +function ShowUserInfoModal({ + users, + showModal, + handleCloseModal, +}: ShowUserInfoModalProps) { + const { t } = useTranslation() + + if (users.length !== 1) return null + const user = users[0] + + const [activationLink, setActivationLink] = useState(null) + const [copied, setCopied] = useState(false) + + useEffect(() => { + if (!showModal) return + + getAdditionalUserInfo(user.id) + .then(({ activationLink }) => { + setActivationLink(activationLink) + }) + .catch(() => { + setActivationLink(null) + }) + }, [showModal, user.id]) + + const handleCopy = () => { + if (!activationLink) return + + const markCopied = () => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(activationLink).then(markCopied) + return + } + + // fallback for older browsers + const tempInput = document.createElement('input') + tempInput.value = activationLink + tempInput.style.position = 'fixed' + tempInput.style.opacity = '0' + document.body.appendChild(tempInput) + tempInput.select() + document.execCommand('copy') + document.body.removeChild(tempInput) + + markCopied() + } + + return ( + + + {(Body) => ( + <> + {t('Account')} + + + + + + {user.isAdmin && ( + + {t('user_category_admin')} + + } + /> + )} + {activationLink && ( + + {activationLink} + {copied && ( + + ({t('copied')}) + + )} + + } + /> + )} + + + )} + + + + {(Body) => ( + <> + {t('user_activity')} + + + + + + + + )} + + + ) +} + +export default ShowUserInfoModal + diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/update-user-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/update-user-modal.tsx new file mode 100644 index 0000000000..52f480adc8 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/update-user-modal.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import UsersActionModal from './users-action-modal' +import UsersList from './users-list' +import Notification from '@/shared/components/notification' +import OLForm from '@/shared/components/ol/ol-form' +import OLFormLabel from '@/shared/components/ol/ol-form-label' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' +import OLButton from '@/shared/components/ol/ol-button' +import OLRow from '@/shared/components/ol/ol-row' +import OLCol from '@/shared/components/ol/ol-col' +import { useRefWithAutoFocus } from '@/shared/hooks/use-ref-with-auto-focus' + +type UpdateUserModalProps = Pick< + React.ComponentProps, + 'users' | 'actionHandler' | 'showModal' | 'handleCloseModal' +> +const pickUserFields = ({ firstName, lastName, email, isAdmin }) => ({ firstName, lastName, email, isAdmin }) + +function UpdateUserModal({ + users, + actionHandler, + showModal, + handleCloseModal, +}: UpdateUserModalProps) { + const { t } = useTranslation() + + const { autoFocusedRef } = useRefWithAutoFocus() + + if (users.length !== 1) return null + const [userData, setUserData] = useState(pickUserFields(users[0])) + const isSelf = getMeta('ol-user_id') === users[0].id + const allowUpdateDetails = users[0].allowUpdateDetails + const allowUpdateIsAdmin = users[0].allowUpdateIsAdmin + + useEffect(() => { + if (showModal) { + setUserData(pickUserFields(users[0])) + } + }, [showModal, users]) + + const handleTextChange = (e: React.ChangeEvent) => { + const { name, value } = e.currentTarget + setUserData(prev => ({ ...prev, [name]: value })) + } + + const handleCheckboxChange = (e: React.ChangeEvent) => { + const { name, checked } = e.currentTarget + setUserData(prev => ({ ...prev, [name]: checked })) + } + + return ( + + + {t('email_address')} + + + + {t('first_name')} + + + + {t('last_name')} + + + + + + + + + + + ) +} + +export default UpdateUserModal diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/users-action-modal.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/users-action-modal.tsx new file mode 100644 index 0000000000..0d2a258c25 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/users-action-modal.tsx @@ -0,0 +1,136 @@ +import { memo, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import * as eventTracking from '@/infrastructure/event-tracking' +import { isSmallDevice } from '@/infrastructure/event-tracking' +import { getUserFacingMessage } from '@/infrastructure/fetch-json' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import OLButton from '@/shared/components/ol/ol-button' +import { + OLModal, + OLModalBody, + OLModalFooter, + OLModalHeader, + OLModalTitle, +} from '@/shared/components/ol/ol-modal' +import Notification from '@/shared/components/notification' +import { User } from '../../../../../types/user/api' + +type UsersActionModalProps = { + title?: string + action: 'info' | 'update' | 'delete' | 'purge' | 'restore' | 'suspend' | 'resume' | 'set_admin' | 'unset_admin' | 'resend' + actionHandler?: (user: User, options?: any) => Promise + handleCloseModal: () => void + users: Array + options?: any + showModal: boolean + actionIsDisabled?: boolean + children?: React.ReactNode +} + +const greenActions = new Set(['update', 'restore', 'resume', 'unset_admin', 'resend']) +const redActions = new Set(['delete', 'purge', 'set_admin', 'suspend']) + +function UsersActionModal({ + title, + action, + actionHandler, + handleCloseModal, + showModal, + actionIsDisabled, + users, + options, + children, +}: UsersActionModalProps) { + const { t } = useTranslation() + const [errors, setErrors] = useState>([]) + const [isProcessing, setIsProcessing] = useState(false) + const isMounted = useIsMounted() + + const variant = + redActions.has(action) ? 'danger' : + greenActions.has(action) ? 'primary' : 'secondary' + + const actionLabel = + action === 'update' ? t('confirm') : + action === 'info' ? t('close') : + t(action) + + async function handleActionForUsers(users: Array, options?: any) { + const errored = [] + setIsProcessing(true) + setErrors([]) + + if (actionHandler) { + for (const user of users) { + try { + await actionHandler(user, options) + } catch (e) { + errored.push({ userName: user.email, error: e }) + } + } + } + + if (isMounted.current) { + setIsProcessing(false) + } + + if (errored.length === 0) { + handleCloseModal() + } else { + setErrors(errored) + } + } + + useEffect(() => { + if (showModal) { + eventTracking.sendMB('admin-user-list-page-interaction', { + action, + isSmallDevice, + }) + } + }, [action, showModal]) + + return ( + + + {title} + + + {children} + {!isProcessing && + errors.length > 0 && + errors.map((error, i) => ( +
    + +
    + ))} +
    + + {action !== 'info' && ( + + {t('cancel')} + + )} + handleActionForUsers(users, options)} + disabled={isProcessing || actionIsDisabled} + > + {actionLabel} + + +
    + ) +} + +export default memo(UsersActionModal) diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/modals/users-list.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/users-list.tsx new file mode 100644 index 0000000000..5a5fcba399 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/modals/users-list.tsx @@ -0,0 +1,29 @@ +import classnames from 'classnames' +import { User } from '../../../../../types/user/api' +import { getUserName } from '../../../project-list/util/user' + +type UsersToDisplayProps = { + users: User[] + usersToDisplay: User[] +} + +function UsersList({ users, usersToDisplay }: UsersToDisplayProps) { + return ( +
      + {usersToDisplay.map(user => ( +
    • id === user.id + ), + })} + > + {`${getUserName(user)} <${user.email}>`} +
    • + ))} +
    + ) +} + +export default UsersList diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/search-form.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/search-form.tsx new file mode 100644 index 0000000000..303ed67b2a --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/search-form.tsx @@ -0,0 +1,82 @@ +import { useTranslation } from 'react-i18next' +import classnames from 'classnames' +import * as eventTracking from '@/infrastructure/event-tracking' +import { isSmallDevice } from '@/infrastructure/event-tracking' +import OLCol from '@/shared/components/ol/ol-col' +import OLForm from '@/shared/components/ol/ol-form' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import MaterialIcon from '@/shared/components/material-icon' +import { MergeAndOverride } from '../../../../../../types/utils' +import { Filter } from '../context/user-list-context' + +type SearchFormOwnProps = { + inputValue: string + setInputValue: (input: string) => void + filter: Filter +} + +type SearchFormProps = MergeAndOverride< + React.ComponentProps, + SearchFormOwnProps +> + +function SearchForm({ + inputValue, + setInputValue, + filter, + className, + ...props +}: SearchFormProps) { + const { t } = useTranslation() + const placeholder = t('search')+'…' + + const handleChange: React.ComponentProps< + typeof OLFormControl + >['onChange'] = e => { + eventTracking.sendMB('admin-user-list-page-interaction', { + action: 'search', + isSmallDevice, + }) + setInputValue(e.target.value) + } + + const handleClear = () => setInputValue('') + + return ( + e.preventDefault()} + {...props} + > + + + } + append={ + inputValue.length > 0 && ( + + ) + } + /> + + + + ) +} + +export default SearchForm diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/sidebar/sidebar-ds-nav.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/sidebar/sidebar-ds-nav.tsx new file mode 100644 index 0000000000..01e9656033 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/sidebar/sidebar-ds-nav.tsx @@ -0,0 +1,130 @@ +import classnames from 'classnames' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Dropdown } from 'react-bootstrap' +import { User as UserIcon } from '@phosphor-icons/react' +import { usePersistedResize } from '@/shared/hooks/use-resize' +import getMeta from '@/utils/meta' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import { AccountMenuItems } from '@/shared/components/navbar/account-menu-items' +import { useFeatureFlag } from '@/shared/context/split-test-context' +import SidebarFilters from './sidebar-filters' +import CreateAccountButton from '../create-account-button' +import { useSendUserListMB } from '../user-list-events' +import { useScrolled } from '@/features/project-list/components/sidebar/use-scroll' + +function SidebarDsNav() { + const { t } = useTranslation() + const [showAccountDropdown, setShowAccountDropdown] = useState(false) + const { mousePos, getHandleProps, getTargetProps } = usePersistedResize({ + name: 'users-and-projects-sidebar', + }) + const sendMB = useSendUserListMB() + const { sessionUser } = getMeta('ol-navbar') + const { containerRef, scrolledUp, scrolledDown } = useScrolled() + const themedDsNav = useFeatureFlag('themed-project-dashboard') + return ( +
    + +
    + +
    + Extended CE +
    +
    +
    +
    + ) +} + +export default SidebarDsNav diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/sidebar/sidebar-filters.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/sidebar/sidebar-filters.tsx new file mode 100644 index 0000000000..2dcd69ac8f --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/sidebar/sidebar-filters.tsx @@ -0,0 +1,42 @@ +import { useMemo } from 'react' +import { + Filter, + useUserListContext, +} from '../../context/user-list-context' +import UsersFilterMenu from '../users-filter-menu' + +type SidebarFilterProps = { + filter: Filter + text: React.ReactNode +} + +export function SidebarFilter({ filter, text }: SidebarFilterProps) { + const { selectFilter } = useUserListContext() + + return ( + + {isActive => ( +
  • + +
  • + )} +
    + ) +} + +export default function SidebarFilters() { + const { filterTranslations } = useUserListContext() + + return ( +
      + {[...filterTranslations.entries()].map(([key, text]) => ( + + ))} + +
    + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/sort/with-content.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/sort/with-content.tsx new file mode 100644 index 0000000000..6c28771413 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/sort/with-content.tsx @@ -0,0 +1,46 @@ +import { useTranslation } from 'react-i18next' +import { Sort } from '../../../../../types/user/api' + +type SortBtnOwnProps = { + column: string + sort: Sort + text: string + onClick: () => void +} + +type WithContentProps = { + iconType?: string + screenReaderText: string +} + +export type SortBtnProps = SortBtnOwnProps & WithContentProps + +function withContent( + WrappedComponent: React.ComponentType +) { + function WithContent(hocProps: T) { + const { t } = useTranslation() + const { column, text, sort } = hocProps + let iconType + + let screenReaderText = t('sort_by_x', { x: text }) + + if (column === sort.by) { + iconType = + sort.order === 'asc' ? 'arrow_upward_alt' : 'arrow_downward_alt' + screenReaderText = t('reverse_x_sort_order', { x: text }) + } + + return ( + + ) + } + + return WithContent +} + +export default withContent diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/delete-user-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/delete-user-button.tsx new file mode 100644 index 0000000000..6826957052 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/delete-user-button.tsx @@ -0,0 +1,80 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import DeleteUserModal from '../../../modals/delete-user-modal' +import { performDeleteUser, PostActions } from '../../../../util/user-actions' + +type DeleteUserButtonProps = { + user: User + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function DeleteUserButton({ user, children }: DeleteUserButtonProps) { + const { t } = useTranslation() + const text = t('delete') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const { toggleSelectedUser, updateUserViewData } = useUserListContext() + const postActions: PostActions = { toggleSelectedUser, updateUserViewData } + const handleDeleteUser = useCallback((user: User, options: any) => { + return performDeleteUser(user, postActions, options) + }, [postActions]) + + if (user.deleted) return null + + return ( + <> + {children(text, handleOpenModal)} + + + ) +} + +const DeleteUserButtonTooltip = memo(function DeleteUserButtonTooltip({ + user, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(DeleteUserButton) +export { DeleteUserButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/flag-user-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/flag-user-button.tsx new file mode 100644 index 0000000000..c388faa699 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/flag-user-button.tsx @@ -0,0 +1,103 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import FlagUserModal from '../../../modals/flag-user-modal' +import { performUpdateUser, PostActions } from '../../../../util/user-actions' + +type FlagUserButtonProps = { + user: User + action: string + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function FlagUserButton({ user, action, children }: FlagUserButtonProps) { + const { t } = useTranslation() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + const text = t(action) + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const { toggleSelectedUser, updateUserViewData } = useUserListContext() + const postActions: PostActions = { toggleSelectedUser, updateUserViewData } + const handleFlagUser = useCallback((user: User, options: any) => { + return performUpdateUser(user, postActions, options) + }, [postActions]) + + if (user.deleted) return null + if (action === "suspend" && user.suspended) return null + if (action === "resume" && !user.suspended) return null + + return ( + <> + {children(text, handleOpenModal)} + + + ) +} + +const FlagUserButtonTooltip = memo(function FlagUserButtonTooltip({ + user, flag +}: Pick) { + + let action + let icon + let unfilled + switch (flag) { + case 'isAdmin': + action = user.isAdmin ? 'unset_admin' : 'set_admin' + icon = user.isAdmin ? 'remove_moderator' : 'add_moderator' + unfilled = true + break + case 'suspended': + action = user.suspended ? 'resume' : 'suspend' + icon = user.suspended ? 'resume' : 'pause' + unfilled = false + break + default: + return null + } + + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(FlagUserButton) +export { FlagUserButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/purge-user-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/purge-user-button.tsx new file mode 100644 index 0000000000..f0e485785d --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/purge-user-button.tsx @@ -0,0 +1,79 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import PurgeUserModal from '../../../modals/purge-user-modal' +import { performPurgeUser, PostActions } from '../../../../util/user-actions' + +type PurgeUserButtonProps = { + user: User + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function PurgeUserButton({ user, children }: PurgeUserButtonProps) { + const { t } = useTranslation() + const text = t('purge') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const { removeUserFromView } = useUserListContext() + const postActions: PostActions = { removeUserFromView } + const handlePurgeUser = useCallback((user: User) => { + return performPurgeUser(user, postActions) + }, [user, postActions]) + + if (!user.deleted) return null + + return ( + <> + {children(text, handleOpenModal)} + + + ) +} + +const PurgeUserButtonTooltip = memo(function PurgeUserButtonTooltip({ + user, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(PurgeUserButton) +export { PurgeUserButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/restore-user-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/restore-user-button.tsx new file mode 100644 index 0000000000..d90d9ac565 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/restore-user-button.tsx @@ -0,0 +1,79 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import RestoreUserModal from '../../../modals/restore-user-modal' +import { performRestoreUser, PostActions } from '../../../../util/user-actions' + +type RestoreUserButtonProps = { + user: User + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function RestoreUserButton({ user, children }: RestoreUserButtonProps) { + const { t } = useTranslation() + const text = t('restore') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const { toggleSelectedUser, updateUserViewData } = useUserListContext() + const postActions: PostActions = { toggleSelectedUser, updateUserViewData } + const handleRestoreUser = useCallback((user: User) => { + return performRestoreUser(user, postActions) + }, [user, postActions]) + + if (!user.deleted) return null + + return ( + <> + {children(text, handleOpenModal)} + + + ) +} + +const RestoreUserButtonTooltip = memo(function RestoreUserButtonTooltip({ + user, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(RestoreUserButton) +export { RestoreUserButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/send-reg-email-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/send-reg-email-button.tsx new file mode 100644 index 0000000000..b99cdb1dab --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/send-reg-email-button.tsx @@ -0,0 +1,83 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import SendRegEmailModal from '../../../modals/send-reg-email-modal' +import { performSendRegEmail, PostActions } from '../../../../util/user-actions' + +type SendRegEmailButtonProps = { + user: User + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function SendRegEmailButton({ user, children }: SendRegEmailButtonProps) { + const { t } = useTranslation() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + const text = t('resend') + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const { toggleSelectedUser, updateUserViewData } = useUserListContext() + const postActions: PostActions = { toggleSelectedUser } + const handleSendRegEmail = useCallback((user: User) => { + return performSendRegEmail(user, postActions) + }, [postActions]) + + if (user.deleted) return null + + const isHidden = !user.authMethods.includes('local') || user.suspended + + return ( + + {children(text, handleOpenModal)} + + + ) +} + +const SendRegEmailButtonTooltip = memo(function SendRegEmailButtonTooltip({ + user, +}: Pick) { + + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(SendRegEmailButton) +export { SendRegEmailButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/show-user-info-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/show-user-info-button.tsx new file mode 100644 index 0000000000..cd8b5be7a8 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/show-user-info-button.tsx @@ -0,0 +1,77 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import ShowUserInfoModal from '../../../modals/show-user-info-modal' + +type ShowUserInfoButtonProps = { + user: User + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function ShowUserInfoButton({ user, children }: ShowUserInfoButtonProps) { + const { t } = useTranslation() + const text = t('info') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const { toggleSelectedUser, updateUserViewData } = useUserListContext() + const handleShowUserInfo = useCallback((user: User) => { + return performShowUserInfo(user) + }, []) + + if (user.deleted) return null + + return ( + <> + {children(text, handleOpenModal)} + + + ) +} + +const ShowUserInfoButtonTooltip = memo(function ShowUserInfoButtonTooltip({ + user, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(ShowUserInfoButton) +export { ShowUserInfoButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/update-user-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/update-user-button.tsx new file mode 100644 index 0000000000..f0c435233b --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/action-buttons/update-user-button.tsx @@ -0,0 +1,80 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import UpdateUserModal from '../../../modals/update-user-modal' +import { performUpdateUser, PostActions } from '../../../../util/user-actions' + +type UpdateUserButtonProps = { + user: User + children: (text: string, handleOpenModal: () => void) => React.ReactElement +} + +function UpdateUserButton({ user, children }: UpdateUserButtonProps) { + const { t } = useTranslation() + const text = t('update') + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const { toggleSelectedUser, updateUserViewData } = useUserListContext() + const postActions: PostActions = { toggleSelectedUser, updateUserViewData } + const handleUpdateUser = useCallback((user: User, options: any) => { + return performUpdateUser(user, postActions, options) + }, [postActions]) + + if (user.deleted) return null + + return ( + <> + {children(text, handleOpenModal)} + + + ) +} + +const UpdateUserButtonTooltip = memo(function UpdateUserButtonTooltip({ + user, +}: Pick) { + return ( + + {(text, handleOpenModal) => ( + + + + )} + + ) +}) + +export default memo(UpdateUserButton) +export { UpdateUserButtonTooltip } diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/actions-cell.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/actions-cell.tsx new file mode 100644 index 0000000000..6c096c046c --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/actions-cell.tsx @@ -0,0 +1,34 @@ +import getMeta from '@/utils/meta' +import { DeleteUserButtonTooltip } from './action-buttons/delete-user-button' +import { RestoreUserButtonTooltip } from './action-buttons/restore-user-button' +import { PurgeUserButtonTooltip } from './action-buttons/purge-user-button' +import { FlagUserButtonTooltip } from './action-buttons/flag-user-button' +import { UpdateUserButtonTooltip } from './action-buttons/update-user-button' +import { ShowUserInfoButtonTooltip } from './action-buttons/show-user-info-button' +import { SendRegEmailButtonTooltip } from './action-buttons/send-reg-email-button' +import { User } from '../../../../../../types/user/api' + +type ActionsCellProps = { + user: User +} + +export default function ActionsCell({ user }: ActionsCellProps) { + const isSelf = getMeta('ol-user_id') === user.id + return ( +
    + + + + + + + + + + + + + +
    + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/deleted-at-cell.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/deleted-at-cell.tsx new file mode 100644 index 0000000000..9c69412580 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/deleted-at-cell.tsx @@ -0,0 +1,22 @@ +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import { formatDate, fromNowDate } from '@/utils/dates' +import { User } from '../../../../../../types/user/api' + +type DeletedAtProps = { + user: User +} + +export default function deletedAtCell({ user }: deletedAtCellProps) { + const deletedAt = user.deletedAt ? fromNowDate(user.deletedAt) : 'Not deleted' + const tooltipText = user.deletedAt ? formatDate(user.deletedAt) : 'Not deleted' + return ( + + {deletedAt} + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/email-cell.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/email-cell.tsx new file mode 100644 index 0000000000..81f2b8617d --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/email-cell.tsx @@ -0,0 +1,14 @@ +import { useTranslation } from 'react-i18next' +import { User } from '../../../../../../types/user/api' + +type EmailCellProps = { + user: User +} + +export default function EmailCell({ user }: EmailCellProps) { + return ( + + {user.email} + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/last-active-cell.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/last-active-cell.tsx new file mode 100644 index 0000000000..0629fd365b --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/last-active-cell.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import { formatDate, fromNowDate } from '@/utils/dates' +import { User } from '../../../../../../types/user/api' + +type LastActiveProps = { + user: User +} + +export default function lastActiveCell({ user }: LastActiveCellProps) { + const { t } = useTranslation() + const lastActiveDate = user.lastActive ? fromNowDate(user.lastActive) : t('never') + const tooltipText = user.lastActive ? formatDate(user.lastActive) : t('never') + return ( + + {lastActiveDate} + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/sign-up-date-cell.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/sign-up-date-cell.tsx new file mode 100644 index 0000000000..0a1bda9cea --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/cells/sign-up-date-cell.tsx @@ -0,0 +1,22 @@ +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import { formatDate, fromNowDate } from '@/utils/dates' +import { User } from '../../../../../../types/user/api' + +type SignUpDateCellProps = { + user: User +} + +export default function signUpDateCell({ user }: SignUpDateCellProps) { + const signUpDate = fromNowDate(user.signUpDate) + const tooltipText = formatDate(user.signUpDate) + return ( + + {signUpDate} + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-checkbox.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-checkbox.tsx new file mode 100644 index 0000000000..074ae3f671 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-checkbox.tsx @@ -0,0 +1,38 @@ +import { ChangeEvent, memo, useCallback, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import { useUserListContext } from '../../context/user-list-context' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' + +export const UserCheckbox = memo<{ userId: string; userName: string }>( + ({ userId, userName }) => { + const { t } = useTranslation() + const { selectedUserIds, toggleSelectedUser } = + useUserListContext() + + const isSelf = useMemo(() => { + return getMeta('ol-user_id') === userId + }, [userId]) + + const handleCheckboxChange = useCallback( + (event: ChangeEvent) => { + toggleSelectedUser(userId, event.target.checked) + }, + [userId, toggleSelectedUser] + ) + + return ( + + ) + } +) + +UserCheckbox.displayName = 'UserCheckbox' diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table-row.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table-row.tsx new file mode 100644 index 0000000000..eafdc8d059 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table-row.tsx @@ -0,0 +1,70 @@ +import { memo, useState } from 'react' +import EmailCell from './cells/email-cell' +import LastActiveCell from './cells/last-active-cell' +import SignUpDateCell from './cells/sign-up-date-cell' +import DeletedAtCell from './cells/deleted-at-cell' +import ActionsCell from './cells/actions-cell' +import ActionsDropdown from '../dropdown/actions-dropdown' +import { User } from '../../../../../types/user/api' +import { UserCheckbox } from './user-checkbox' +import { useUsersPageContext } from '../../../users-page-context' +import { getUserName } from '../../../project-list/util/user' + +type UserListTableRowProps = { + user: User + selected: boolean + filter: string +} +function UserListTableRow({ user, selected, filter }: UserListTableRowProps) { + const fullName = getUserName(user) + const rowClassName = `${selected ? 'table-active' : ''} ${user.isAdmin ? 'dash-row-admin' : ''}`.trim() + const { showProjects } = useUsersPageContext() + + return ( + + + + + + { + showProjects(user.id) + }} + style={{ cursor: 'pointer' }} + > + {fullName} + + + + + + + + + {filter !== 'deleted' ? ( + + + + ) : ( + + + + )} + + + + +
    + +
    +
    + +
    + + + ) +} +export default memo(UserListTableRow) diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table.tsx new file mode 100644 index 0000000000..3df582f676 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-list-table.tsx @@ -0,0 +1,202 @@ +import { useCallback, useRef, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import UserListTableRow from './user-list-table-row' +import { useUserListContext } from '../../context/user-list-context' +import useSort from '../../hooks/use-sort' +import withContent, { SortBtnProps } from '../sort/with-content' +import OLTable from '@/shared/components/ol/ol-table' +import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox' +import MaterialIcon from '@/shared/components/material-icon' + +function SortBtn({ onClick, text, iconType, screenReaderText }: SortBtnProps) { + return ( + + ) +} + +const SortByButton = withContent(SortBtn) + +function UserListTable() { + const { t } = useTranslation() + const { + visibleUsers, + sort, + selectedUsers, + selectOrUnselectAllUsers, + selfVisibleCount, + filter, + } = useUserListContext() + const { handleSort } = useSort() + const checkAllRef = useRef(null) + + const handleAllUsersCheckboxChange = useCallback( + (event: React.ChangeEvent) => { + selectOrUnselectAllUsers(event.target.checked) + }, + [selectOrUnselectAllUsers] + ) + + useEffect(() => { + if (checkAllRef.current) { + checkAllRef.current.indeterminate = + selectedUsers.length > 0 && + selectedUsers.length + selfVisibleCount !== visibleUsers.length + } + }, [selectedUsers, visibleUsers]) + + return ( + + {t('users_list')} + + + + + + + handleSort('name')} + /> + + + {t('date_and_owner')} + + + handleSort('email')} + /> + + {filter !== 'deleted' ? ( + + handleSort('signUpDate')} + /> + + ) : ( + + handleSort('deletedAt')} + /> + + )} + + handleSort('lastActive')} + /> + + + {t('actions')} + + + + + {visibleUsers.length > 0 ? ( + visibleUsers.map(u => ( + id === u.id)} + key={u.id} + filter={filter} + /> + )) + ) : ( + + + {t('no_users')} + + + )} + + + ) +} + +export default UserListTable diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/delete-users-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/delete-users-button.tsx new file mode 100644 index 0000000000..06b6dfc7e6 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/delete-users-button.tsx @@ -0,0 +1,52 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLButton from '@/shared/components/ol/ol-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import DeleteUserModal from '../../../modals/delete-user-modal' +import { performDeleteUser, PostActions } from '../../../../util/user-actions' + +type DeleteUserResult = { + success: boolean + message: string + user: Partial +} + +function DeleteUsersButton() { + const { t } = useTranslation() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = () => { + setShowModal(true) + } + const handleCloseModal = () => { + if (isMounted.current) { + setShowModal(false) + } + } + + const { selectedUsers, toggleSelectedUser, updateUserViewData } = useUserListContext() + const postActions: Partial = { toggleSelectedUser, updateUserViewData } + + const handleDeleteUser = (user: User, options: any) => { + return performDeleteUser(user, postActions, options) + } + + return ( + <> + + {t('delete')} + + + + ) +} + +export default DeleteUsersButton diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/flag-users-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/flag-users-button.tsx new file mode 100644 index 0000000000..e9b7f25054 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/flag-users-button.tsx @@ -0,0 +1,84 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import FlagUserModal from '../../../modals/flag-user-modal' +import { performUpdateUser, PostActions } from '../../../../util/user-actions' + +function FlagUsersButton({ action }: { action: string }) { + const { selectedUsers, toggleSelectedUser, updateUserViewData } = + useUserListContext() + const { t } = useTranslation() + const text = t(action) + + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const postActions: PostActions = { toggleSelectedUser, updateUserViewData } + const handleFlagUser = async (user: User, options: any) => { + await performUpdateUser(user, postActions, options) + } + + let icon + let unfilled + switch (action) { + case 'set_admin': + icon = 'add_moderator' + unfilled = true + break + case 'unset_admin': + icon = 'remove_moderator' + unfilled = true + break + case 'suspend': + icon = 'pause' + unfilled = false + break + case 'resume': + icon = 'resume' + unfilled = false + break + default: + return null + } + + return ( + <> + + + + + + ) +} + +export default memo(FlagUsersButton) diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/purge-users-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/purge-users-button.tsx new file mode 100644 index 0000000000..c2e2597f61 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/purge-users-button.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLButton from '@/shared/components/ol/ol-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import PurgeUserModal from '../../../modals/purge-user-modal' +import { performPurgeUser, postActions } from '../../../../util/user-actions' + +function PurgeUsersButton() { + const { t } = useTranslation() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = () => { + setShowModal(true) + } + const handleCloseModal = () => { + if (isMounted.current) { + setShowModal(false) + } + } + + const { selectedUsers, removeUserFromView } = useUserListContext() + const postActions: PostActions = { removeUserFromView } + + const handlePurgeUser = (user: User) => { + return performPurgeUser(user, postActions) + } + + return ( + <> + + {t('purge')} + + + + + ) +} + +export default PurgeUsersButton diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/restore-users-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/restore-users-button.tsx new file mode 100644 index 0000000000..bc718a20cc --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/restore-users-button.tsx @@ -0,0 +1,47 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLButton from '@/shared/components/ol/ol-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import RestoreUserModal from '../../../modals/restore-user-modal' +import { performRestoreUser, postActions } from '../../../../util/user-actions' + +function RestoreUsersButton() { + const { t } = useTranslation() + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = () => { + setShowModal(true) + } + const handleCloseModal = () => { + if (isMounted.current) { + setShowModal(false) + } + } + + const { selectedUsers, toggleSelectedUser, updateUserViewData } = useUserListContext() + const postActions: PostActions = { toggleSelectedUser, updateUserViewData } + + const handleRestoreUser = (user: User) => { + return performRestoreUser(user, postActions) + } + + return ( + <> + + {t('restore')} + + + + + ) +} + +export default RestoreUsersButton diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/send-reg-emails-button.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/send-reg-emails-button.tsx new file mode 100644 index 0000000000..a9385dd6f6 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/buttons/send-reg-emails-button.tsx @@ -0,0 +1,60 @@ +import { memo, useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import OLTooltip from '@/shared/components/ol/ol-tooltip' +import OLIconButton from '@/shared/components/ol/ol-icon-button' +import useIsMounted from '@/shared/hooks/use-is-mounted' +import { useUserListContext } from '../../../../context/user-list-context' +import { User } from '../../../../../../../types/user/api' +import SendRegEmailModal from '../../../modals/send-reg-email-modal' +import { performSendRegEmail } from '../../../../util/user-actions' + +function SendRegEmailsButton({ action }: { action: string }) { + const { selectedUsers, toggleSelectedUser } = + useUserListContext() + const { t } = useTranslation() + const text = t(action) + + const [showModal, setShowModal] = useState(false) + const isMounted = useIsMounted() + + const handleOpenModal = useCallback(() => { + setShowModal(true) + }, []) + + const handleCloseModal = useCallback(() => { + if (isMounted.current) { + setShowModal(false) + } + }, [isMounted]) + + const postActions: PostActions = { toggleSelectedUser } + const handleSendRegEmail = async (user: User) => { + await performSendRegEmail(user, postActions) + } + + return ( + <> + + + + + + ) +} + +export default memo(SendRegEmailsButton) diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/user-tools.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/user-tools.tsx new file mode 100644 index 0000000000..c11f7d3258 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/table/user-tools/user-tools.tsx @@ -0,0 +1,56 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { useUserListContext } from '../../../context/user-list-context' +import DeleteUsersButton from './buttons/delete-users-button' +import PurgeUsersButton from './buttons/purge-users-button' +import RestoreUsersButton from './buttons/restore-users-button' +import FlagUsersButton from './buttons/flag-users-button' +import SendRegEmailsButton from './buttons/send-reg-emails-button' +import OLButtonToolbar from '@/shared/components/ol/ol-button-toolbar' +import OLButtonGroup from '@/shared/components/ol/ol-button-group' + +function UserTools() { + const { t } = useTranslation() + const { filter, selectedUsers } = useUserListContext() + + return ( + + + {(filter === 'deleted') && ( + + + + )} + + {(filter !== 'deleted') && ( + + {filter !== 'suspended' && } + + {filter !== 'suspended' && } + + )} + + {(filter !== 'deleted') && ( + + {(filter !== 'admin' && filter !== 'suspended') && } + + + )} + + {(filter !== 'deleted') && ( + + + + )} + + {(filter === 'deleted') && ( + + + + )} + + + ) +} + +export default memo(UserTools) diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/title/user-list-title.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/title/user-list-title.tsx new file mode 100644 index 0000000000..2162c9a37b --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/title/user-list-title.tsx @@ -0,0 +1,29 @@ +import { useMemo } from 'react' +import classnames from 'classnames' +import { Filter, useUserListContext } from '../../context/user-list-context' + +function UserListTitle({ + filter, + className, +}: { + filter: Filter + className?: string +}) { + const { filterTranslations } = useUserListContext() + + let message = filterTranslations.get(filter) + let extraProps = {} + + return ( +

    + {message} +

    + ) +} + +export default UserListTitle diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/user-filter-menu.ts b/services/web/modules/admin-tools/frontend/js/user-list/components/user-filter-menu.ts new file mode 100644 index 0000000000..48f482f550 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/user-filter-menu.ts @@ -0,0 +1,15 @@ +import { Filter, useUserListContext } from '../context/user-list-context' + +type UsersMenuFilterType = { + children: (isActive: boolean) => React.ReactElement + filter: Filter +} + +function UsersFilterMenu({ children, filter }: UsersMenuFilterType) { + const { filter: activeFilter } = useUserListContext() + const isActive = (filter === activeFilter) + + return children(isActive) +} + +export default UsersFilterMenu diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-ds-nav.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-ds-nav.tsx new file mode 100644 index 0000000000..77d7a1db3e --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-ds-nav.tsx @@ -0,0 +1,113 @@ +import { useUserListContext } from '../context/user-list-context' +import { useTranslation } from 'react-i18next' +import CreateAccountButton from './create-account-button' +import UserListTable from './table/user-list-table' +import SearchForm from './search-form' +import UsersDropdown from './dropdown/users-dropdown' +import SortByDropdown from './dropdown/sort-by-dropdown' +import UserTools from './table/user-tools/user-tools' +import UserListTitle from './title/user-list-title' +import LoadMore from './load-more' +import OLCol from '@/shared/components/ol/ol-col' +import OLRow from '@/shared/components/ol/ol-row' +import { TableContainer } from '@/shared/components/table' +import DashApiError from '@/features/project-list/components/dash-api-error' +import getMeta from '@/utils/meta' +import DefaultNavbar from '@/shared/components/navbar/default-navbar' +import Footer from '@/shared/components/footer/footer' +import SidebarDsNav from './sidebar/sidebar-ds-nav' +import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg' +import CookieBanner from '@/shared/components/cookie-banner' + +export function UserListDsNav() { + const navbarProps = getMeta('ol-navbar') + const footerProps = getMeta('ol-footer') + + const { t } = useTranslation() + const { + error, + searchText, + setSearchText, + selectedUsers, + filter, + } = useUserListContext() + + const tableTopArea = ( +
    + + +
    + ) + + return ( +
    + +
    + +
    +
    +
    + {error ? : ''} +
    +
    + +
    +
    + {selectedUsers.length !== 0 && } +
    +
    +
    +
    + + + + + +
    +
    + + +
    +
    +
    + + {tableTopArea} + + +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-events.ts b/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-events.ts new file mode 100644 index 0000000000..b9180e2261 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-events.ts @@ -0,0 +1,31 @@ +import { useCallback } from 'react' +import { sendMB } from '@/infrastructure/event-tracking' + +export type ExtraSegmentations = { + 'menu-expand': { + item: 'help' | 'account' | 'features' | 'admin' + location: 'top-menu' | 'sidebar' + } + 'menu-click': { + item: + | 'login' + | 'register' + | 'why-latex' + | 'learn' + | 'contact' + | 'templates' + location: 'top-menu' | 'sidebar' + destinationURL?: string + } + 'create-account-click': undefined +} + +export const useSendUserListMB = () => { + return useCallback( + ( + event: T, + payload: ExtraSegmentations[T] + ) => sendMB(event, payload), + [] + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-root.tsx b/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-root.tsx new file mode 100644 index 0000000000..6c0cc65f9a --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/user-list-root.tsx @@ -0,0 +1,33 @@ +import { ReactNode, useEffect, useRef } from 'react' +import { + useUserListContext, +} from '../context/user-list-context' +import * as eventTracking from '@/infrastructure/event-tracking' +import { useTranslation } from 'react-i18next' +import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav' +import LoadingBranded from '@/shared/components/loading-branded' +import useThemedPage from '@/shared/hooks/use-themed-page' +import { UserListDsNav } from './user-list-ds-nav' + +export default function UserListRoot() { + useThemedPage('themed-project-dashboard') + const { isLoading, loadProgress } = useUserListContext() + + useEffect(() => { + eventTracking.sendMB('loads_v2_dash', {}) + }, []) + + const { t } = useTranslation() + + if (isLoading) { + const loadingComponent = ( + + ) + return loadingComponent + } + return ( + + + + ) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/components/users-filter-menu.ts b/services/web/modules/admin-tools/frontend/js/user-list/components/users-filter-menu.ts new file mode 100644 index 0000000000..48f482f550 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/components/users-filter-menu.ts @@ -0,0 +1,15 @@ +import { Filter, useUserListContext } from '../context/user-list-context' + +type UsersMenuFilterType = { + children: (isActive: boolean) => React.ReactElement + filter: Filter +} + +function UsersFilterMenu({ children, filter }: UsersMenuFilterType) { + const { filter: activeFilter } = useUserListContext() + const isActive = (filter === activeFilter) + + return children(isActive) +} + +export default UsersFilterMenu diff --git a/services/web/modules/admin-tools/frontend/js/user-list/context/user-identity-context.tsx b/services/web/modules/admin-tools/frontend/js/user-list/context/user-identity-context.tsx new file mode 100644 index 0000000000..dde1f22047 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/context/user-identity-context.tsx @@ -0,0 +1,76 @@ +import { + createContext, + ReactNode, + useContext, + useMemo, + useCallback, +} from 'react' +import { User } from '../../../../types/user/api' +import { getUserName } from '../../project-list/util/user' + +export type UserIdentityContextValue = { + getUserById: (userId: string) => User | undefined + getUserNameById: (userId: string) => string +} + +const UserIdentityContext = createContext< + UserIdentityContextValue | undefined +>(undefined) + +type UserIdentityProviderProps = { + users: User[] + children: ReactNode +} + +export function UserIdentityProvider({ + users, + children, +}: UserIdentityProviderProps) { + + const usersById = useMemo(() => { + const map = new Map() + for (const user of users) { + map.set(user.id, user) + } + return map + }, [users]) + + const getUserById = useCallback( + (userId: string) => { + return usersById.get(userId) + }, + [usersById] + ) + + const getUserNameById = useCallback( + (userId: string) => { + const user = usersById.get(userId) + return getUserName(user) + }, + [usersById] + ) + + const value = useMemo( + () => ({ + getUserById, + getUserNameById, + }), + [getUserById, getUserNameById] + ) + + return ( + + {children} + + ) +} + +export function useUserIdentityContext() { + const context = useContext(UserIdentityContext) + if (!context) { + throw new Error( + 'UserIdentityContext is only available inside UserIdentityProvider' + ) + } + return context +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx b/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx new file mode 100644 index 0000000000..5332bef0b4 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/context/user-list-context.tsx @@ -0,0 +1,380 @@ +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { + filter as arrayFilter, +} from 'lodash' +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import { debugConsole } from '@/utils/debugging' +import useAsync from '@/shared/hooks/use-async' +import usePersistedState from '@/shared/hooks/use-persisted-state' +import { + GetUsersResponseBody, + User, + Sort, +} from '../../../../types/user/api' +import { getUsers } from '../util/api' +import sortUsers from '../util/sort-users' + +import { UserIdentityProvider } from './user-identity-context' + +const MAX_USER_PER_PAGE = 10 + +type AuthMethods = 'local' | 'ldap' | 'saml' | 'oidc' +export type Filter = 'all' | 'admin' | 'suspended' | 'inactive' | AuthMethods | 'deleted' + +const selfId = getMeta('ol-user_id') +const availableAuthMethods: AuthMethods[] = getMeta('ol-availableAuthMethods') ?? [] + +type FilterMap = { + [key in Filter]: Partial | ((user: User) => boolean) +} + +const filters: FilterMap = { + all: { deleted: false }, + admin: { isAdmin: true, deleted: false }, + suspended: { suspended: true, deleted: false }, + inactive: { inactive: true, deleted: false }, + deleted: { deleted: true }, + ...Object.fromEntries( + availableAuthMethods.map(method => [ + method, + (user: User) => user.authMethods.includes(method) && !user.deleted + ]) + ) +} + +// if there is only one authentication source we won't show "users by authentication" lists +const filterKeys: Filter[] = [ + 'all', + 'admin', + 'suspended', + 'inactive', + ...(availableAuthMethods.length === 1 ? [] : availableAuthMethods), + 'deleted' +] + +export type UserListContextValue = { + addUserToView: (user: Partial) => void + error: Error | null + filter: Filter + filterTranslations: Map + hiddenUsersCount: number + isLoading: ReturnType['isLoading'] + loadMoreCount: number + loadMoreUsers: () => void + loadProgress: number + removeUserFromView: (user: User) => void + searchText: string + selectFilter: (filter: Filter) => void + selectedUserIds: Set + selectedUsers: User[] + selectOrUnselectAllUsers: React.Dispatch> + selfVisibleCount: number + setSearchText: React.Dispatch> + setSelectedUserIds: React.Dispatch>> + setSort: React.Dispatch> + showAllUsers: () => void + sort: Sort + toggleSelectedUser: (userId: string, selected?: boolean) => void + totalUsersCount: number + updateUserViewData: (newUserData: User) => void + loadedUsers: User[] + visibleUsers: User[] +} + +export const UserListContext = createContext< + UserListContextValue | undefined +>(undefined) + +type UserListProviderProps = { + children: ReactNode +} + +export function UserListProvider({ children }: UserListProviderProps) { + const prefetchedUsersBlob = getMeta('ol-prefetchedUsersBlob') + const [loadedUsers, setLoadedUsers] = useState( + prefetchedUsersBlob?.users ?? [] + ) + + const [maxVisibleUsers, setMaxVisibleUsers] = + useState(MAX_USER_PER_PAGE) + + const [loadProgress, setLoadProgress] = useState( + prefetchedUsersBlob ? 100 : 20 + ) + const [totalUsersCount, setTotalUsersCount] = useState( + prefetchedUsersBlob?.totalSize ?? 0 + ) + const [sort, setSort] = useState({ + by: 'name', + order: 'asc', + }) + + const { t } = useTranslation() + + const filterTranslations = useMemo( + () => new Map(filterKeys.map(key => [key, t(`user_category_${key}`)])), + [t] + ) + + const [filter, setFilter] = usePersistedState( + 'user-list-filter', + 'all' + ) + const prevSortRef = useRef(sort) + + const [searchText, setSearchText] = useState('') + + const { + isLoading: loading, + isIdle, + error, + runAsync, + } = useAsync({ + status: prefetchedUsersBlob ? 'resolved' : 'pending', + data: prefetchedUsersBlob, + }) + const isLoading = isIdle ? true : loading + + useEffect(() => { + if (prefetchedUsersBlob) return + setLoadProgress(40) + runAsync(getUsers({ by: 'signUpDate', order: 'desc' })) + .then(data => { + setLoadedUsers(data.users) + setTotalUsersCount(data.totalSize) + }) + .catch(debugConsole.error) + .finally(() => { + setLoadProgress(100) + }) + }, [prefetchedUsersBlob, runAsync]) + + const addUserToView = useCallback((newUser: Partial) => { + setLoadedUsers(prev => sortUsers([newUser, ...prev], prevSortRef.current)) + }, []) + + const processedUsers = useMemo(() => { + let users = [...loadedUsers] + + if (searchText.length) { + const searchTextLowerCase = searchText.toLowerCase() + users = users.filter(user => + user.email?.toLowerCase().includes(searchTextLowerCase) || + user.firstName?.toLowerCase().includes(searchTextLowerCase) || + user.lastName?.toLowerCase().includes(searchTextLowerCase) + ) + } + + users = arrayFilter(users, filters[filter]) + + if (prevSortRef.current !== sort) { + users = sortUsers(users, sort) + } + + return users + }, [loadedUsers, searchText, filter, sort]) + + const visibleUsers = useMemo(() => { + return processedUsers.slice(0, maxVisibleUsers) + }, [processedUsers, maxVisibleUsers]) + + const hiddenUsersCount = Math.max( + processedUsers.length - visibleUsers.length, + 0 + ) + + const loadMoreCount = Math.min( + hiddenUsersCount, + MAX_USER_PER_PAGE + ) + + useEffect(() => { + prevSortRef.current = sort + }, [sort]) + + const selfVisibleCount = useMemo(() => { + return visibleUsers.some(u => u.id === selfId) ? 1 : 0 + }, [visibleUsers]) + + const showAllUsers = useCallback(() => { + setMaxVisibleUsers(maxVisibleUsers + hiddenUsersCount) + }, [hiddenUsersCount, maxVisibleUsers]) + + const loadMoreUsers = useCallback(() => { + setMaxVisibleUsers(maxVisibleUsers + loadMoreCount) + }, [maxVisibleUsers, loadMoreCount]) + + const [selectedUserIds, setSelectedUserIds] = useState( + () => new Set() + ) + + const toggleSelectedUser = useCallback( + (userId: string, selected?: boolean) => { + setSelectedUserIds(prevSelectedUserIds => { + const selectedUserIds = new Set(prevSelectedUserIds) + if (selected === true) { + selectedUserIds.add(userId) + } else if (selected === false) { + selectedUserIds.delete(userId) + } else if (selectedUserIds.has(userId)) { + selectedUserIds.delete(userId) + } else { + selectedUserIds.add(userId) + } + return selectedUserIds + }) + }, + [] + ) + + const selectedUsers = useMemo(() => { + return visibleUsers.filter(user => selectedUserIds.has(user.id)) + }, [selectedUserIds, visibleUsers]) + + const selectOrUnselectAllUsers = useCallback( + (checked: any) => { + setSelectedUserIds(prevSelectedUserIds => { + const selectedUserIds = new Set(prevSelectedUserIds) + for (const user of visibleUsers) { + if (user.id === selfId) { + selectedUserIds.delete(user.id) + continue + } + if (checked) { + selectedUserIds.add(user.id) + } else { + selectedUserIds.delete(user.id) + } + } + return selectedUserIds + }) + } , + [visibleUsers] + ) + + const selectFilter = useCallback( + (filter: Filter) => { + setFilter(filter) + + setSort(prev => { + if (filter === 'deleted' && prev.by === 'signUpDate') { + return { ...prev, by: 'deletedAt' } + } + if (filter !== 'deleted' && prev.by === 'deletedAt') { + return { ...prev, by: 'signUpDate' } + } + return prev + }) + + const selected = false + selectOrUnselectAllUsers(selected) + }, + [selectOrUnselectAllUsers, setFilter] + ) + + const updateUserViewData = useCallback((newUserData: User) => { + setLoadedUsers(loadedUsers => { + return loadedUsers.map(u => + u.id === newUserData.id ? { ...newUserData } : u + ) + }) + }, []) + + const removeUserFromView = useCallback((user: User) => { + setLoadedUsers(loadedUsers => { + return loadedUsers.filter(u => u.id !== user.id) + }) + }, []) + + const value = useMemo( + () => ({ + addUserToView, + error, + filter, + filterTranslations, + hiddenUsersCount, + isLoading, + loadMoreCount, + loadMoreUsers, + loadProgress, + removeUserFromView, + searchText, + selectFilter, + selectedUserIds, + selectedUsers, + selectOrUnselectAllUsers, + selfVisibleCount, + setSearchText, + setSelectedUserIds, + setSort, + showAllUsers, + sort, + toggleSelectedUser, + totalUsersCount, + updateUserViewData, + loadedUsers, + visibleUsers, + }), + [ + addUserToView, + error, + filter, + filterTranslations, + hiddenUsersCount, + isLoading, + loadMoreCount, + loadMoreUsers, + loadProgress, + removeUserFromView, + searchText, + selectFilter, + selectedUserIds, + selectedUsers, + selectOrUnselectAllUsers, + selfVisibleCount, + setSearchText, + setSelectedUserIds, + setSort, + showAllUsers, + sort, + toggleSelectedUser, + totalUsersCount, + updateUserViewData, + loadedUsers, + visibleUsers, + ] + ) + + if (!loadedUsers || loadedUsers.length === 0) { + return null + } + + return ( + + + {children} + + + ) +} + +export function useUserListContext() { + const context = useContext(UserListContext) + if (!context) { + throw new Error( + 'UserListContext is only available inside UserListProvider' + ) + } + return context +} + diff --git a/services/web/modules/admin-tools/frontend/js/user-list/hooks/use-sort.ts b/services/web/modules/admin-tools/frontend/js/user-list/hooks/use-sort.ts new file mode 100644 index 0000000000..07472eec69 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/hooks/use-sort.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react' +import { Sort } from '../../../../types/user/api' +import { SortingOrder } from '../../../../../../types/sorting-order' +import { useUserListContext } from '../context/user-list-context' + +const toggleSort = (order: SortingOrder): SortingOrder => { + return order === 'asc' ? 'desc' : 'asc' +} + +function useSort() { + const { filter, sort, setSort } = useUserListContext() + + const handleSort = (by: Sort['by']) => { + setSort(prev => ({ + by, + order: prev.by === by ? toggleSort(sort.order) : sort.order, + })) + } + + useEffect(() => { + if (filter === 'deleted' && sort.by === 'signUpDate') { + setSort(prev => ({ ...prev, by: 'deletedAt' })) + } + + if (filter !== 'deleted' && sort.by === 'deletedAt') { + setSort(prev => ({ ...prev, by: 'signUpDate' })) + } + }, [filter, sort.by, setSort]) + + return { handleSort } +} + +export default useSort diff --git a/services/web/modules/admin-tools/frontend/js/user-list/util/api.ts b/services/web/modules/admin-tools/frontend/js/user-list/util/api.ts new file mode 100644 index 0000000000..033ae3a06e --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/util/api.ts @@ -0,0 +1,36 @@ +import { GetUsersResponseBody, Sort } from '../../../../types/user/api' +import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json' + +export function getUsers(sortBy: Sort): Promise { + return postJSON('/admin/users', { body: { sort: sortBy } }) +} + +export function updateUser(userId: string, userData: Partial) { + return postJSON(`/admin/user/${userId}/update`, { body: userData }) +} + +export function deleteUser( + userId: string, + options: { + sendEmail: boolean + toUserId: string | null + } +) { + return postJSON(`/admin/user/${userId}/delete`, { body: options } ) +} + +export function restoreUser(userId: string) { + return postJSON(`/admin/user/${userId}/restore`) +} + +export function purgeUser(userId: string) { + return deleteJSON(`/admin/user/${userId}`) +} + +export function getAdditionalUserInfo(userId: string) { + return getJSON(`/admin/user/${userId}/info`) +} + +export function sendRegEmail(userId: string) { + return postJSON(`/admin/user/${userId}/send-activation`) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/util/sort-users.ts b/services/web/modules/admin-tools/frontend/js/user-list/util/sort-users.ts new file mode 100644 index 0000000000..ca4aee0a5b --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/util/sort-users.ts @@ -0,0 +1,73 @@ +import { User, Sort } from '../../../../types/user/api' +import { SortingOrder } from '../../../../../../types/sorting-order' +import { Compare } from '../../../../../../types/helpers/array/sort' + +const order = (order: SortingOrder, users: User[]) => { + return order === 'asc' ? [...users] : users.reverse() +} + + +function cmp(a, b) { + const aEmpty = (a === "") + const bEmpty = (b === "") + if (aEmpty && bEmpty) return Compare.SORT_KEEP_ORDER + if (aEmpty !== bEmpty) return aEmpty ? Compare.SORT_A_AFTER_B : Compare.SORT_A_BEFORE_B + return a.localeCompare(b) +} + +export const userNameComparator = (v1: User, v2: User) => { + const res = cmp(v1.lastName, v2.lastName) + if (res !== 0) return res + return cmp(v1.firstName, v2.firstName) +} + +export const defaultComparator = ( + v1: User, + v2: User, + key: 'lastActive' | 'signUpDate' | 'deletedAt' | 'email' +) => { + const value1 = v1[key]?.toLowerCase() + const value2 = v2[key]?.toLowerCase() + + if (value1 !== value2) { + if (value1 === undefined) return Compare.SORT_A_BEFORE_B + if (value2 === undefined) return Compare.SORT_A_AFTER_B + return value1 < value2 ? Compare.SORT_A_BEFORE_B : Compare.SORT_A_AFTER_B + } + return Compare.SORT_KEEP_ORDER +} + +export default function sortUsers(users: User[], sort: Sort) { + let sorted = [...users] + if (sort.by === 'lastActive') { + sorted = sorted.sort((...args) => { + return defaultComparator(...args, 'lastActive') + }) + } + + if (sort.by === 'signUpDate') { + sorted = sorted.sort((...args) => { + return defaultComparator(...args, 'signUpDate') + }) + } + + if (sort.by === 'deletedAt') { + sorted = sorted.sort((...args) => { + return defaultComparator(...args, 'deletedAt') + }) + } + + if (sort.by === 'email') { + sorted = sorted.sort((...args) => { + return defaultComparator(...args, 'email') + }) + } + + if (sort.by === 'name') { + sorted = sorted.sort((...args) => { + return userNameComparator(...args) + }) + } + + return order(sort.order, sorted) +} diff --git a/services/web/modules/admin-tools/frontend/js/user-list/util/user-actions.ts b/services/web/modules/admin-tools/frontend/js/user-list/util/user-actions.ts new file mode 100644 index 0000000000..0379a54ab0 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/user-list/util/user-actions.ts @@ -0,0 +1,81 @@ +import { User } from '../../../../types/user/api' +import { updateUser, deleteUser, purgeUser, restoreUser, sendRegEmail } from './api' + +export type PostActions = { + toggleSelectedUser?: (id: string, selected: boolean) => void + updateUserViewData?: (user: User) => void + removeUserFromView?: (user: User) => void +} + +export async function performDeleteUser( + user: User, + postActions: PostActions, + options: { sendEmail: boolean, toUserId: string | null }, +) { + return deleteUser(user.id, options).then(data => { + postActions.toggleSelectedUser(user.id, false) + postActions.updateUserViewData({ + ...user, + ...data, + deleted: true, + }) + }) +} + +export function performUpdateUser( + user: User, + postActions: PostActions, + options: { userData: Partial }, +) { + + const dataToUpdate = { ...options.userData} + if (!user.allowUpdateDetails) { + delete dataToUpdate.firstName + delete dataToUpdate.lastName + } + if (!user.allowUpdateIsAdmin) { + delete dataToUpdate.isAdmin + } + + return updateUser(user.id, dataToUpdate).then(data => { + postActions.toggleSelectedUser(user.id, false) + postActions.updateUserViewData({ + ...user, + ...data + }) + }) +} + +export function performRestoreUser( + user: User, + postActions: PostActions, +) { + + return restoreUser(user.id).then(() => { + postActions.toggleSelectedUser(user.id, false) + postActions.updateUserViewData({ + ...user, + deletedAt: undefined, + deleted: false, + }) + }) +} + +export function performPurgeUser( + user: User, + postActions: PostActions +) { + return purgeUser(user.id).then(() => { + postActions.removeUserFromView(user) + }) +} + +export function performSendRegEmail( + user: User, + postActions: PostActions, +) { + + return sendRegEmail(user.id).then(() => { + postActions.toggleSelectedUser(user.id, false) + }) +} diff --git a/services/web/modules/admin-tools/frontend/js/users-page-context.tsx b/services/web/modules/admin-tools/frontend/js/users-page-context.tsx new file mode 100644 index 0000000000..03fee7d9c0 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/users-page-context.tsx @@ -0,0 +1,101 @@ +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + useMemo, + ReactNode, +} from 'react' + +export type PageState = + | { type: 'users' } + | { type: 'projects'; userId: string | null } + +type UsersPageContextValue = { + page: PageState + showUsers: () => void + showProjects: (userId: string) => void +} + +const UsersPageContext = + createContext(null) + +type ProviderProps = { + children: ReactNode +} + +function isPageState(value: unknown): value is PageState { + if (typeof value !== 'object' || value === null) return false + const v = value as any + if (v.type === 'users') return true + if (v.type === 'projects') return v.userId === null || typeof v.userId === 'string' + return false +} + +export function UsersPageProvider({ children }: ProviderProps) { + const [page, setPage] = useState(() => { + return isPageState(history.state) + ? history.state + : { type: 'users' } + }) + + useEffect(() => { + if (!isPageState(history.state)) { + history.replaceState(page, '') + } + + const onPopState = (e: PopStateEvent) => { + setPage( + isPageState(e.state) + ? e.state + : { type: 'users' } + ) + } + + window.addEventListener('popstate', onPopState) + return () => window.removeEventListener('popstate', onPopState) + }, []) + + const navigate = useCallback((state: PageState) => { + history.pushState(state, '') + setPage(state) + }, []) + + const showUsers = useCallback(() => { + navigate({ type: 'users' }) + }, [navigate]) + + const showProjects = useCallback((userId: string) => { + navigate({ type: 'projects', userId }) + }, [navigate]) + + const value = useMemo( + () => ({ + page, + showUsers, + showProjects, + }), + [ + page, + showUsers, + showProjects, + ] + ) + + return ( + + {children} + + ) +} + +export function useUsersPageContext(): UsersPageContextValue { + const context = useContext(UsersPageContext) + if (!context) { + throw new Error('UsersPageContext is only available inside UsersPageProvider') + } + return context +} diff --git a/services/web/modules/admin-tools/index.mjs b/services/web/modules/admin-tools/index.mjs new file mode 100644 index 0000000000..812d8d293c --- /dev/null +++ b/services/web/modules/admin-tools/index.mjs @@ -0,0 +1,12 @@ +import AdminToolsRouter from './app/src/AdminToolsRouter.mjs' + +/** + * @import { WebModule } from "../../types/web-module" + */ + +/** @type {WebModule} */ +const AdminToolsModule = { + router: AdminToolsRouter, +} + +export default AdminToolsModule diff --git a/services/web/modules/admin-tools/types/project/api.d.ts b/services/web/modules/admin-tools/types/project/api.d.ts new file mode 100644 index 0000000000..2257c0c0f8 --- /dev/null +++ b/services/web/modules/admin-tools/types/project/api.d.ts @@ -0,0 +1,59 @@ +import { SortingOrder } from '../../../../types/sorting-order' +import { MergeAndOverride } from '../../../../types/utils' +import { User } from '../user/api' + +export type Page = { + size: number + lastId?: string +} + +export type Sort = { + by: 'lastUpdated' | 'title' | 'deletedAt' + order: SortingOrder +} + +export type Filters = { + owned?: boolean + trashed?: boolean + deleted?: boolean + search?: string +} + +export type GetProjectsRequestBody = { + page: Page + sort: Sort + filters: Filters +} + +export type UserRef = Pick< + User, + 'id' | 'email' | 'firstName' | 'lastName' +> + + +export type ProjectApi = { + id: string + name: string + owner?: string + lastUpdated: Date + lastUpdatedBy: string | null + trashed: boolean + deleted: boolean + inactive?: boolean + deletedBy?: string + deletedAt?: Date +} + +export type Project = MergeAndOverride< + ProjectApi, + { + lastUpdated: string + deletedAt?: string + selected?: boolean + } +> + +export type GetProjectsResponseBody = { + totalSize: number + projects: Project[] +} diff --git a/services/web/modules/admin-tools/types/user/api.d.ts b/services/web/modules/admin-tools/types/user/api.d.ts new file mode 100644 index 0000000000..46abb9bedb --- /dev/null +++ b/services/web/modules/admin-tools/types/user/api.d.ts @@ -0,0 +1,66 @@ +import { SortingOrder } from '../../../../types/sorting-order' +import { MergeAndOverride } from '../../../../types/utils' + +export type Page = { + size: number + lastId?: string +} + +export type Sort = { + by: 'lastLoggedIn' | 'lastActive' | 'signUpDate' | 'deletedAt' | 'email' | 'name' + order: SortingOrder +} + +export type Filters = { + all?: boolean + admin?: boolean + inactive?: boolean + suspended?: boolean + deleted?: boolean + local?: boolean + saml?: boolean + oidc?: boolean + ldap?: boolean + search?: string +} + +export type GetUsersRequestBody = { + page: Page + sort: Sort + filters: Filters +} + +export type UserApi = { + id: string + email: string + firstName: string + lastName: string + isAdmin: boolean + loginCount: number + signUpDate: Date + lastActive?: Date + lastLoggedIn?: Date + authMethods: string[] + allowUpdateDetails: boolean + allowUpdateIsAdmin: boolean + suspended: boolean + inactive: boolean + deleted?: boolean + deletedAt?: Date +} + +export type User = MergeAndOverride< + UserApi, + { + signUpDate: string + lastActive?: string + lastLoggedIn?: string + deletedAt?: string + selected?: boolean + } +> + +export type GetUsersResponseBody = { + totalSize: number + users: User[] +}