mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 09:09:36 +02:00
Admin Tools: Manage users and Manage projects pages
This commit is contained in:
@@ -157,6 +157,7 @@ const transferOwnershipSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
body: z.object({
|
body: z.object({
|
||||||
user_id: zz.objectId(),
|
user_id: zz.objectId(),
|
||||||
|
skipEmails: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -173,6 +174,7 @@ async function transferOwnership(req, res, next) {
|
|||||||
allowTransferToNonCollaborators: hasAdminAccess(sessionUser),
|
allowTransferToNonCollaborators: hasAdminAccess(sessionUser),
|
||||||
sessionUserId: new ObjectId(sessionUser._id),
|
sessionUserId: new ObjectId(sessionUser._id),
|
||||||
ipAddress: req.ip,
|
ipAddress: req.ip,
|
||||||
|
skipEmails: body.skipEmails
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
res.sendStatus(204)
|
res.sendStatus(204)
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ async function unmarkAsDeletedByExternalSource(projectId) {
|
|||||||
).exec()
|
).exec()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUsersProjects(userId) {
|
async function deleteUsersProjects(userId, options = {}) {
|
||||||
const projects = await Project.find({ owner_ref: userId }).exec()
|
const projects = await Project.find({ owner_ref: userId }).exec()
|
||||||
logger.info(
|
logger.info(
|
||||||
{ userId, projectCount: projects.length },
|
{ userId, projectCount: projects.length },
|
||||||
@@ -87,6 +87,7 @@ async function deleteUsersProjects(userId) {
|
|||||||
)
|
)
|
||||||
await promiseMapWithLimit(5, projects, project =>
|
await promiseMapWithLimit(5, projects, project =>
|
||||||
deleteProject(project._id, {
|
deleteProject(project._id, {
|
||||||
|
...options,
|
||||||
deletedReason: DeletedProjectReasons.ACCOUNT_DELETION,
|
deletedReason: DeletedProjectReasons.ACCOUNT_DELETION,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -235,6 +236,7 @@ async function deleteProject(projectId, options = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const deleterData = {
|
const deleterData = {
|
||||||
deletedAt: new Date(),
|
deletedAt: new Date(),
|
||||||
deleterId:
|
deleterId:
|
||||||
@@ -306,7 +308,7 @@ async function undeleteProject(projectId, options = {}) {
|
|||||||
// if we're undeleting, we want the document to show up
|
// if we're undeleting, we want the document to show up
|
||||||
restored.name = await ProjectDetailsHandler.promises.generateUniqueName(
|
restored.name = await ProjectDetailsHandler.promises.generateUniqueName(
|
||||||
deletedProject.deleterData.deletedProjectOwnerId,
|
deletedProject.deleterData.deletedProjectOwnerId,
|
||||||
restored.name + ' (Restored)'
|
restored.name + (options.suffix ?? ' (Restored)')
|
||||||
)
|
)
|
||||||
restored.archived = undefined
|
restored.archived = undefined
|
||||||
|
|
||||||
@@ -330,6 +332,7 @@ async function undeleteProject(projectId, options = {}) {
|
|||||||
|
|
||||||
await db.projects.insertOne(restored)
|
await db.projects.insertOne(restored)
|
||||||
await DeletedProject.deleteOne({ _id: deletedProject._id }).exec()
|
await DeletedProject.deleteOne({ _id: deletedProject._id }).exec()
|
||||||
|
return restored
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expireDeletedProject(projectId) {
|
async function expireDeletedProject(projectId) {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ async function deleteUser(userId, options) {
|
|||||||
logger.info({ userId }, 'creating deleted user record')
|
logger.info({ userId }, 'creating deleted user record')
|
||||||
await _createDeletedUser(user, options)
|
await _createDeletedUser(user, options)
|
||||||
logger.info({ userId }, 'deleting user projects')
|
logger.info({ userId }, 'deleting user projects')
|
||||||
await ProjectDeleter.promises.deleteUsersProjects(user._id)
|
await ProjectDeleter.promises.deleteUsersProjects(user._id, options)
|
||||||
if (options.skipEmail) {
|
if (options.skipEmail) {
|
||||||
logger.info({ userId }, 'skipping sending deletion email to user')
|
logger.info({ userId }, 'skipping sending deletion email to user')
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import OError from '@overleaf/o-error'
|
|||||||
const UserRegistrationHandler = {
|
const UserRegistrationHandler = {
|
||||||
_registrationRequestIsValid(body) {
|
_registrationRequestIsValid(body) {
|
||||||
const invalidEmail = AuthenticationManager.validateEmail(body.email || '')
|
const invalidEmail = AuthenticationManager.validateEmail(body.email || '')
|
||||||
|
if (invalidEmail) throw new OError('InvalidEmailError')
|
||||||
const invalidPassword = AuthenticationManager.validatePassword(
|
const invalidPassword = AuthenticationManager.validatePassword(
|
||||||
body.password || '',
|
body.password || '',
|
||||||
body.email
|
body.email
|
||||||
)
|
)
|
||||||
|
if (invalidPassword) throw new OError('InvalidPasswordError')
|
||||||
return !(invalidEmail || invalidPassword)
|
return !(invalidEmail || invalidPassword)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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') Manage Site
|
||||||
+dropdown-menu-link-item(href='/admin/user') Manage Users
|
+dropdown-menu-link-item(href='/admin/user') Manage Users
|
||||||
if canDisplayProjectUrlLookup
|
if canDisplayProjectUrlLookup
|
||||||
+dropdown-menu-link-item(href='/admin/project') Project URL Lookup
|
+dropdown-menu-link-item(href='/admin/project') Manage Projects
|
||||||
if canDisplayAdminRedirect
|
if canDisplayAdminRedirect
|
||||||
+dropdown-menu-link-item(href=settings.adminUrl) Switch to Admin
|
+dropdown-menu-link-item(href=settings.adminUrl) Switch to Admin
|
||||||
if canDisplaySplitTestMenu
|
if canDisplaySplitTestMenu
|
||||||
|
|||||||
@@ -1093,13 +1093,13 @@ module.exports = {
|
|||||||
'history-v1',
|
'history-v1',
|
||||||
'launchpad',
|
'launchpad',
|
||||||
'server-ce-scripts',
|
'server-ce-scripts',
|
||||||
'user-activate',
|
|
||||||
'sandboxed-compiles',
|
'sandboxed-compiles',
|
||||||
'symbol-palette',
|
'symbol-palette',
|
||||||
'track-changes',
|
'track-changes',
|
||||||
'authentication/ldap',
|
'authentication/ldap',
|
||||||
'authentication/saml',
|
'authentication/saml',
|
||||||
'authentication/oidc',
|
'authentication/oidc',
|
||||||
|
'admin-tools', // import after authentication
|
||||||
'template-gallery',
|
'template-gallery',
|
||||||
],
|
],
|
||||||
viewIncludes: {},
|
viewIncludes: {},
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"a_new_reference_was_added_to_file_from_provider": "",
|
"a_new_reference_was_added_to_file_from_provider": "",
|
||||||
"a_new_version_of_the_rolling_texlive_build_released": "",
|
"a_new_version_of_the_rolling_texlive_build_released": "",
|
||||||
"about_to_archive_projects": "",
|
"about_to_archive_projects": "",
|
||||||
|
"about_to_delete_accounts": "",
|
||||||
"about_to_delete_cert": "",
|
"about_to_delete_cert": "",
|
||||||
"about_to_delete_projects": "",
|
"about_to_delete_projects": "",
|
||||||
"about_to_delete_tag": "",
|
"about_to_delete_tag": "",
|
||||||
@@ -32,8 +33,17 @@
|
|||||||
"about_to_enable_managed_users": "",
|
"about_to_enable_managed_users": "",
|
||||||
"about_to_leave_project": "",
|
"about_to_leave_project": "",
|
||||||
"about_to_leave_projects": "",
|
"about_to_leave_projects": "",
|
||||||
|
"about_to_permanently_delete_accounts": "",
|
||||||
|
"about_to_permanently_delete_projects": "",
|
||||||
"about_to_remove_user_preamble": "",
|
"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_trash_projects": "",
|
||||||
|
"about_to_unset_admin_accounts": "",
|
||||||
"abstract": "",
|
"abstract": "",
|
||||||
"accept_all_cookies": "",
|
"accept_all_cookies": "",
|
||||||
"accept_and_continue": "",
|
"accept_and_continue": "",
|
||||||
@@ -59,12 +69,14 @@
|
|||||||
"account_has_been_link_to_institution_account": "",
|
"account_has_been_link_to_institution_account": "",
|
||||||
"account_has_past_due_invoice_change_plan_warning": "",
|
"account_has_past_due_invoice_change_plan_warning": "",
|
||||||
"account_help": "",
|
"account_help": "",
|
||||||
|
"account_information": "",
|
||||||
"account_managed_by_group_administrator": "",
|
"account_managed_by_group_administrator": "",
|
||||||
"account_managed_by_group_teamname": "",
|
"account_managed_by_group_teamname": "",
|
||||||
"account_not_linked_to_dropbox": "",
|
"account_not_linked_to_dropbox": "",
|
||||||
"account_settings": "",
|
"account_settings": "",
|
||||||
"acct_linked_to_institution_acct_2": "",
|
"acct_linked_to_institution_acct_2": "",
|
||||||
"actions": "",
|
"actions": "",
|
||||||
|
"activation_link": "",
|
||||||
"active": "",
|
"active": "",
|
||||||
"add": "",
|
"add": "",
|
||||||
"add_a_recovery_email_address": "",
|
"add_a_recovery_email_address": "",
|
||||||
@@ -144,6 +156,7 @@
|
|||||||
"all_projects": "",
|
"all_projects": "",
|
||||||
"all_projects_will_be_transferred_immediately": "",
|
"all_projects_will_be_transferred_immediately": "",
|
||||||
"all_templates": "",
|
"all_templates": "",
|
||||||
|
"all_users": "",
|
||||||
"all_these_experiments_are_available_exclusively": "",
|
"all_these_experiments_are_available_exclusively": "",
|
||||||
"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": "",
|
||||||
@@ -201,6 +214,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_your_projects": "",
|
"back_to_your_projects": "",
|
||||||
"basic_compile_time": "",
|
"basic_compile_time": "",
|
||||||
"before_you_use_error_assistant": "",
|
"before_you_use_error_assistant": "",
|
||||||
@@ -439,6 +453,7 @@
|
|||||||
"delete_account": "",
|
"delete_account": "",
|
||||||
"delete_account_confirmation_label": "",
|
"delete_account_confirmation_label": "",
|
||||||
"delete_account_warning_message_3": "",
|
"delete_account_warning_message_3": "",
|
||||||
|
"delete_accounts": "",
|
||||||
"delete_acct_no_existing_pw": "",
|
"delete_acct_no_existing_pw": "",
|
||||||
"delete_and_leave": "",
|
"delete_and_leave": "",
|
||||||
"delete_and_leave_projects": "",
|
"delete_and_leave_projects": "",
|
||||||
@@ -469,6 +484,7 @@
|
|||||||
"deleted_by_id": "",
|
"deleted_by_id": "",
|
||||||
"deleted_by_ip": "",
|
"deleted_by_ip": "",
|
||||||
"deleted_by_on": "",
|
"deleted_by_on": "",
|
||||||
|
"deleted_projects": "",
|
||||||
"deleted_user": "",
|
"deleted_user": "",
|
||||||
"deleting": "",
|
"deleting": "",
|
||||||
"demonstrating_git_integration": "",
|
"demonstrating_git_integration": "",
|
||||||
@@ -688,6 +704,7 @@
|
|||||||
"files_cannot_include_invalid_characters": "",
|
"files_cannot_include_invalid_characters": "",
|
||||||
"files_selected": "",
|
"files_selected": "",
|
||||||
"filter_projects": "",
|
"filter_projects": "",
|
||||||
|
"filter_users": "",
|
||||||
"find": "",
|
"find": "",
|
||||||
"find_and_check_citations": "",
|
"find_and_check_citations": "",
|
||||||
"find_out_how_to_get_the_most_out_of_your_new_subscription": "",
|
"find_out_how_to_get_the_most_out_of_your_new_subscription": "",
|
||||||
@@ -945,6 +962,7 @@
|
|||||||
"imported_from_zotero_at_date": "",
|
"imported_from_zotero_at_date": "",
|
||||||
"importing": "",
|
"importing": "",
|
||||||
"importing_and_merging_changes_in_github": "",
|
"importing_and_merging_changes_in_github": "",
|
||||||
|
"inactive_projects": "",
|
||||||
"in_order_to_match_institutional_metadata_2": "",
|
"in_order_to_match_institutional_metadata_2": "",
|
||||||
"in_order_to_match_institutional_metadata_associated": "",
|
"in_order_to_match_institutional_metadata_associated": "",
|
||||||
"include_caption": "",
|
"include_caption": "",
|
||||||
@@ -1238,11 +1256,13 @@
|
|||||||
"need_to_add_new_primary_before_remove": "",
|
"need_to_add_new_primary_before_remove": "",
|
||||||
"need_to_leave": "",
|
"need_to_leave": "",
|
||||||
"neither_agree_nor_disagree": "",
|
"neither_agree_nor_disagree": "",
|
||||||
|
"never": "",
|
||||||
"new_compile_domain_notice": "",
|
"new_compile_domain_notice": "",
|
||||||
"new_compiles_in_this_project_will_automatically_use_the_newest_version": "",
|
"new_compiles_in_this_project_will_automatically_use_the_newest_version": "",
|
||||||
"new_file": "",
|
"new_file": "",
|
||||||
"new_folder": "",
|
"new_folder": "",
|
||||||
"new_name": "",
|
"new_name": "",
|
||||||
|
"new_owner": "",
|
||||||
"new_password": "",
|
"new_password": "",
|
||||||
"new_project": "",
|
"new_project": "",
|
||||||
"new_reference": "",
|
"new_reference": "",
|
||||||
@@ -1290,6 +1310,7 @@
|
|||||||
"no_symbols_found": "",
|
"no_symbols_found": "",
|
||||||
"no_templates_found": "",
|
"no_templates_found": "",
|
||||||
"no_thanks_cancel_now": "",
|
"no_thanks_cancel_now": "",
|
||||||
|
"no_users": "",
|
||||||
"non_blinking_cursor": "",
|
"non_blinking_cursor": "",
|
||||||
"normal": "",
|
"normal": "",
|
||||||
"normally_x_price_per_month": "",
|
"normally_x_price_per_month": "",
|
||||||
@@ -1303,6 +1324,7 @@
|
|||||||
"notification_personal_and_group_subscriptions": "",
|
"notification_personal_and_group_subscriptions": "",
|
||||||
"notification_project_invite_accepted_message": "",
|
"notification_project_invite_accepted_message": "",
|
||||||
"notification_project_invite_message": "",
|
"notification_project_invite_message": "",
|
||||||
|
"notify_users_about_account_deletion": "",
|
||||||
"number_of_users": "",
|
"number_of_users": "",
|
||||||
"numbered_list": "",
|
"numbered_list": "",
|
||||||
"oauth_orcid_description": "",
|
"oauth_orcid_description": "",
|
||||||
@@ -1360,7 +1382,9 @@
|
|||||||
"overwrite": "",
|
"overwrite": "",
|
||||||
"overwriting_the_original_folder": "",
|
"overwriting_the_original_folder": "",
|
||||||
"owned_by_x": "",
|
"owned_by_x": "",
|
||||||
|
"owned_projects": "",
|
||||||
"owner": "",
|
"owner": "",
|
||||||
|
"ownership_of_projects_will_be_transferred": "",
|
||||||
"page_current": "",
|
"page_current": "",
|
||||||
"page_x_of_n": "",
|
"page_x_of_n": "",
|
||||||
"pagination_navigation": "",
|
"pagination_navigation": "",
|
||||||
@@ -1420,6 +1444,8 @@
|
|||||||
"per_month_x_annually": "",
|
"per_month_x_annually": "",
|
||||||
"per_year": "",
|
"per_year": "",
|
||||||
"percent_is_the_percentage_of_the_line_width": "",
|
"percent_is_the_percentage_of_the_line_width": "",
|
||||||
|
"permanently_delete_accounts": "",
|
||||||
|
"permanently_delete_projects": "",
|
||||||
"permanently_disables_the_preview": "",
|
"permanently_disables_the_preview": "",
|
||||||
"personal_library": "",
|
"personal_library": "",
|
||||||
"pick_up_where_you_left_off": "",
|
"pick_up_where_you_left_off": "",
|
||||||
@@ -1538,6 +1564,7 @@
|
|||||||
"publisher_account": "",
|
"publisher_account": "",
|
||||||
"publishing": "",
|
"publishing": "",
|
||||||
"pull_github_changes_into_sharelatex": "",
|
"pull_github_changes_into_sharelatex": "",
|
||||||
|
"purge": "",
|
||||||
"push_sharelatex_changes_to_github": "",
|
"push_sharelatex_changes_to_github": "",
|
||||||
"push_to_github_pull_to_overleaf": "",
|
"push_to_github_pull_to_overleaf": "",
|
||||||
"quoted_text": "",
|
"quoted_text": "",
|
||||||
@@ -1636,6 +1663,7 @@
|
|||||||
"repository_visibility": "",
|
"repository_visibility": "",
|
||||||
"republish": "",
|
"republish": "",
|
||||||
"resend": "",
|
"resend": "",
|
||||||
|
"resend_activation_email": "",
|
||||||
"resend_confirmation_code": "",
|
"resend_confirmation_code": "",
|
||||||
"resend_group_invite": "",
|
"resend_group_invite": "",
|
||||||
"resend_invite": "",
|
"resend_invite": "",
|
||||||
@@ -1648,6 +1676,8 @@
|
|||||||
"resolve_comment_error_title": "",
|
"resolve_comment_error_title": "",
|
||||||
"resolved_comments": "",
|
"resolved_comments": "",
|
||||||
"restore": "",
|
"restore": "",
|
||||||
|
"restore_accounts": "",
|
||||||
|
"restore_projects": "",
|
||||||
"restore_file": "",
|
"restore_file": "",
|
||||||
"restore_file_confirmation_message": "",
|
"restore_file_confirmation_message": "",
|
||||||
"restore_file_confirmation_title": "",
|
"restore_file_confirmation_title": "",
|
||||||
@@ -1657,6 +1687,8 @@
|
|||||||
"restore_project_to_this_version": "",
|
"restore_project_to_this_version": "",
|
||||||
"restore_this_version": "",
|
"restore_this_version": "",
|
||||||
"restoring": "",
|
"restoring": "",
|
||||||
|
"resume": "",
|
||||||
|
"resume_account": "",
|
||||||
"resync_completed": "",
|
"resync_completed": "",
|
||||||
"resync_message": "",
|
"resync_message": "",
|
||||||
"resync_project_history": "",
|
"resync_project_history": "",
|
||||||
@@ -1748,6 +1780,7 @@
|
|||||||
"select_all": "",
|
"select_all": "",
|
||||||
"select_all_entries": "",
|
"select_all_entries": "",
|
||||||
"select_all_projects": "",
|
"select_all_projects": "",
|
||||||
|
"select_all_users": "",
|
||||||
"select_an_output_file": "",
|
"select_an_output_file": "",
|
||||||
"select_an_output_file_figure_modal": "",
|
"select_an_output_file_figure_modal": "",
|
||||||
"select_bib_file": "",
|
"select_bib_file": "",
|
||||||
@@ -1764,6 +1797,7 @@
|
|||||||
"select_image_from_project_files": "",
|
"select_image_from_project_files": "",
|
||||||
"select_project": "",
|
"select_project": "",
|
||||||
"select_projects": "",
|
"select_projects": "",
|
||||||
|
"select_users": "",
|
||||||
"select_size": "",
|
"select_size": "",
|
||||||
"select_tag": "",
|
"select_tag": "",
|
||||||
"select_tax_id_type": "",
|
"select_tax_id_type": "",
|
||||||
@@ -1776,6 +1810,7 @@
|
|||||||
"send": "",
|
"send": "",
|
||||||
"send_confirmation_code": "",
|
"send_confirmation_code": "",
|
||||||
"send_message": "",
|
"send_message": "",
|
||||||
|
"send_notification_emails_to_users": "",
|
||||||
"send_request": "",
|
"send_request": "",
|
||||||
"sending": "",
|
"sending": "",
|
||||||
"sentence_completion": "",
|
"sentence_completion": "",
|
||||||
@@ -1788,6 +1823,8 @@
|
|||||||
"session_expired_redirecting_to_login": "",
|
"session_expired_redirecting_to_login": "",
|
||||||
"sessions": "",
|
"sessions": "",
|
||||||
"set_as_main_document": "",
|
"set_as_main_document": "",
|
||||||
|
"set_admin": "",
|
||||||
|
"set_admin_account": "",
|
||||||
"set_color": "",
|
"set_color": "",
|
||||||
"set_column_width": "",
|
"set_column_width": "",
|
||||||
"set_up_single_sign_on": "",
|
"set_up_single_sign_on": "",
|
||||||
@@ -1803,6 +1840,7 @@
|
|||||||
"sharing_permissions": "",
|
"sharing_permissions": "",
|
||||||
"shortcut_to_open_advanced_reference_search": "",
|
"shortcut_to_open_advanced_reference_search": "",
|
||||||
"show_all_projects": "",
|
"show_all_projects": "",
|
||||||
|
"show_all_users": "",
|
||||||
"show_breadcrumbs": "",
|
"show_breadcrumbs": "",
|
||||||
"show_document_preamble": "",
|
"show_document_preamble": "",
|
||||||
"show_equation_preview": "",
|
"show_equation_preview": "",
|
||||||
@@ -1816,6 +1854,7 @@
|
|||||||
"show_outline": "",
|
"show_outline": "",
|
||||||
"show_version_history": "",
|
"show_version_history": "",
|
||||||
"show_x_more_projects": "",
|
"show_x_more_projects": "",
|
||||||
|
"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": "",
|
||||||
@@ -1825,6 +1864,7 @@
|
|||||||
"showing_x_results_of_total": "",
|
"showing_x_results_of_total": "",
|
||||||
"sidebar": "",
|
"sidebar": "",
|
||||||
"sign_up": "",
|
"sign_up": "",
|
||||||
|
"signed_up": "",
|
||||||
"simple_search_mode": "",
|
"simple_search_mode": "",
|
||||||
"single_sign_on_sso": "",
|
"single_sign_on_sso": "",
|
||||||
"size": "",
|
"size": "",
|
||||||
@@ -1959,6 +1999,9 @@
|
|||||||
"sure_you_want_to_change_plan": "",
|
"sure_you_want_to_change_plan": "",
|
||||||
"sure_you_want_to_delete": "",
|
"sure_you_want_to_delete": "",
|
||||||
"sure_you_want_to_leave_group": "",
|
"sure_you_want_to_leave_group": "",
|
||||||
|
"suspend": "",
|
||||||
|
"suspend_account": "",
|
||||||
|
"switch_between_dark_and_light_mode": "",
|
||||||
"switch_compile_mode_for_faster_draft_compilation": "",
|
"switch_compile_mode_for_faster_draft_compilation": "",
|
||||||
"switch_to_editor": "",
|
"switch_to_editor": "",
|
||||||
"switch_to_pdf": "",
|
"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_ownership_of_projects_currently_owned_by_them_and_collaborators_will_become_read_only": "",
|
||||||
"they_will_retain_their_existing_account_on_the_free_plan": "",
|
"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": "",
|
"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_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": "",
|
||||||
@@ -2166,6 +2210,7 @@
|
|||||||
"track_changes": "",
|
"track_changes": "",
|
||||||
"tracked_change_added": "",
|
"tracked_change_added": "",
|
||||||
"tracked_change_deleted": "",
|
"tracked_change_deleted": "",
|
||||||
|
"transfer_all_projects_to": "",
|
||||||
"transfer_management_of_your_account": "",
|
"transfer_management_of_your_account": "",
|
||||||
"transfer_management_of_your_account_to_x": "",
|
"transfer_management_of_your_account_to_x": "",
|
||||||
"transfer_management_resolve_following_issues": "",
|
"transfer_management_resolve_following_issues": "",
|
||||||
@@ -2177,6 +2222,7 @@
|
|||||||
"trashed": "",
|
"trashed": "",
|
||||||
"trashed_projects": "",
|
"trashed_projects": "",
|
||||||
"trashing_projects_wont_affect_collaborators": "",
|
"trashing_projects_wont_affect_collaborators": "",
|
||||||
|
"trashing_projects_wont_affect_user_collaborators": "",
|
||||||
"trial_last_day": "",
|
"trial_last_day": "",
|
||||||
"trial_remaining_days": "",
|
"trial_remaining_days": "",
|
||||||
"tried_to_log_in_with_email": "",
|
"tried_to_log_in_with_email": "",
|
||||||
@@ -2242,6 +2288,8 @@
|
|||||||
"unpausing": "",
|
"unpausing": "",
|
||||||
"unpublish": "",
|
"unpublish": "",
|
||||||
"unpublishing": "",
|
"unpublishing": "",
|
||||||
|
"unset_admin": "",
|
||||||
|
"unset_admin_account": "",
|
||||||
"unsubscribe": "",
|
"unsubscribe": "",
|
||||||
"until_then_you_can_still": "",
|
"until_then_you_can_still": "",
|
||||||
"untrash": "",
|
"untrash": "",
|
||||||
@@ -2288,6 +2336,17 @@
|
|||||||
"used_latex_response_occasionally": "",
|
"used_latex_response_occasionally": "",
|
||||||
"used_latex_response_often": "",
|
"used_latex_response_often": "",
|
||||||
"used_when_referring_to_the_figure_elsewhere_in_the_document": "",
|
"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_error": "",
|
||||||
"user_deletion_password_reset_tip": "",
|
"user_deletion_password_reset_tip": "",
|
||||||
"user_email_attribute": "",
|
"user_email_attribute": "",
|
||||||
@@ -2296,6 +2355,7 @@
|
|||||||
"user_last_name_attribute": "",
|
"user_last_name_attribute": "",
|
||||||
"user_management": "",
|
"user_management": "",
|
||||||
"user_sessions": "",
|
"user_sessions": "",
|
||||||
|
"users_list": "",
|
||||||
"using_latex": "",
|
"using_latex": "",
|
||||||
"using_premium_features": "",
|
"using_premium_features": "",
|
||||||
"using_the_overleaf_editor": "",
|
"using_the_overleaf_editor": "",
|
||||||
|
|||||||
Binary file not shown.
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
export default /** @type {const} */ ([
|
export default /** @type {const} */ ([
|
||||||
'account_balance',
|
'account_balance',
|
||||||
|
'add_moderator',
|
||||||
'arrow_back_ios_new',
|
'arrow_back_ios_new',
|
||||||
'arrow_circle_up',
|
'arrow_circle_up',
|
||||||
'auto_delete',
|
'auto_delete',
|
||||||
@@ -28,6 +29,7 @@ export default /** @type {const} */ ([
|
|||||||
'domain',
|
'domain',
|
||||||
'edit',
|
'edit',
|
||||||
'edit_square',
|
'edit_square',
|
||||||
|
'edit',
|
||||||
'error',
|
'error',
|
||||||
'experiment',
|
'experiment',
|
||||||
'find_replace',
|
'find_replace',
|
||||||
@@ -39,7 +41,7 @@ export default /** @type {const} */ ([
|
|||||||
'help',
|
'help',
|
||||||
'image',
|
'image',
|
||||||
'info',
|
'info',
|
||||||
'info',
|
'info_i',
|
||||||
'integration_instructions',
|
'integration_instructions',
|
||||||
'lightbulb',
|
'lightbulb',
|
||||||
'lightbulb_2',
|
'lightbulb_2',
|
||||||
@@ -53,12 +55,16 @@ export default /** @type {const} */ ([
|
|||||||
'notifications',
|
'notifications',
|
||||||
'open_in_new',
|
'open_in_new',
|
||||||
'password',
|
'password',
|
||||||
|
'pause_circle',
|
||||||
'person',
|
'person',
|
||||||
'person_edit',
|
'person_edit',
|
||||||
'picture_as_pdf',
|
'picture_as_pdf',
|
||||||
|
'play_circle',
|
||||||
'push_pin',
|
'push_pin',
|
||||||
'rate_review',
|
'rate_review',
|
||||||
|
'remove_moderator',
|
||||||
'report',
|
'report',
|
||||||
|
'restore_from_trash',
|
||||||
'search',
|
'search',
|
||||||
'settings',
|
'settings',
|
||||||
'shuffle',
|
'shuffle',
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function DropdownItem(
|
|||||||
description,
|
description,
|
||||||
leadingIcon,
|
leadingIcon,
|
||||||
trailingIcon,
|
trailingIcon,
|
||||||
|
unfilled,
|
||||||
...props
|
...props
|
||||||
}: DropdownItemProps,
|
}: DropdownItemProps,
|
||||||
ref: React.ForwardedRef<typeof BS5DropdownItem>
|
ref: React.ForwardedRef<typeof BS5DropdownItem>
|
||||||
@@ -41,7 +42,7 @@ function DropdownItem(
|
|||||||
if (typeof leadingIcon === 'string') {
|
if (typeof leadingIcon === 'string') {
|
||||||
leadingIconComponent = (
|
leadingIconComponent = (
|
||||||
<MaterialIcon
|
<MaterialIcon
|
||||||
className="dropdown-item-leading-icon"
|
className={classnames('dropdown-item-leading-icon', {unfilled})}
|
||||||
type={leadingIcon}
|
type={leadingIcon}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -61,7 +62,7 @@ function DropdownItem(
|
|||||||
|
|
||||||
trailingIconComponent = (
|
trailingIconComponent = (
|
||||||
<MaterialIcon
|
<MaterialIcon
|
||||||
className="dropdown-item-trailing-icon"
|
className={classnames('dropdown-item-trailing-icon', {unfilled})}
|
||||||
type={trailingIconType}
|
type={trailingIconType}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function AdminMenu({
|
|||||||
) : null}
|
) : null}
|
||||||
{canDisplayProjectUrlLookup ? (
|
{canDisplayProjectUrlLookup ? (
|
||||||
<NavDropdownLinkItem href="/admin/project">
|
<NavDropdownLinkItem href="/admin/project">
|
||||||
Project URL Lookup
|
Manage Projects
|
||||||
</NavDropdownLinkItem>
|
</NavDropdownLinkItem>
|
||||||
) : null}
|
) : null}
|
||||||
{canDisplayAdminRedirect && adminUrl ? (
|
{canDisplayAdminRedirect && adminUrl ? (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,3 +9,5 @@
|
|||||||
@import 'symbol-palette';
|
@import 'symbol-palette';
|
||||||
@import 'writefull';
|
@import 'writefull';
|
||||||
@import 'labs';
|
@import 'labs';
|
||||||
|
@import 'admin-panel/user-list';
|
||||||
|
@import 'admin-panel/user-list-ds-nav';
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"a_new_version_of_the_rolling_texlive_build_released": "Eine neue Version des Rolling TeX Live Build wurde veröffentlicht.",
|
"a_new_version_of_the_rolling_texlive_build_released": "Eine neue Version des Rolling TeX Live Build wurde veröffentlicht.",
|
||||||
"about": "Über uns",
|
"about": "Über uns",
|
||||||
"about_to_archive_projects": "Du bist dabei, die folgenden Projekte zu archivieren:",
|
"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_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_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):",
|
"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_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_project": "Du bist dabei, dieses Projekt zu verlassen.",
|
||||||
"about_to_leave_projects": "Du bist dabei, die folgenden Projekte 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_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_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",
|
"abstract": "Abstrakt",
|
||||||
"accept": "Akzeptieren",
|
"accept": "Akzeptieren",
|
||||||
"accept_all_cookies": "Alle Cookies 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 <b>__email__</b> wurde mit dem Konto deiner Institution (<b>__institutionName__</b>) verknüpft.",
|
"account_has_been_link_to_institution_account": "Dein __appName__-Konto mit der E-Mail-Adresse <b>__email__</b> wurde mit dem Konto deiner Institution (<b>__institutionName__</b>) 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_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_help": "Konto und Hilfe",
|
||||||
|
"account_information": "Kontoinformationen",
|
||||||
"account_linking": "Kontoverknüpfung",
|
"account_linking": "Kontoverknüpfung",
|
||||||
"account_managed_by_group_administrator": "Dein Konto wird von deinem Gruppenverwalter (__admin__) verwaltet.",
|
"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__</0> verwaltet.",
|
"account_managed_by_group_teamname": "Dieses __appName__-Konto wird von <0>__teamName__</0> verwaltet.",
|
||||||
@@ -73,6 +84,7 @@
|
|||||||
"activate": "Aktivieren",
|
"activate": "Aktivieren",
|
||||||
"activate_account": "Aktiviere dein Konto",
|
"activate_account": "Aktiviere dein Konto",
|
||||||
"activating": "Aktivierung",
|
"activating": "Aktivierung",
|
||||||
|
"activation_link": "Aktivierungslink",
|
||||||
"activation_token_expired": "Dein Aktivierungs-Token ist abgelaufen, bitte fordere einen neuen an.",
|
"activation_token_expired": "Dein Aktivierungs-Token ist abgelaufen, bitte fordere einen neuen an.",
|
||||||
"active": "Aktiv",
|
"active": "Aktiv",
|
||||||
"add": "Hinzufügen",
|
"add": "Hinzufügen",
|
||||||
@@ -163,6 +175,7 @@
|
|||||||
"all_templates": "Alle Vorlagen",
|
"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_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_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.",
|
"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_an_account": "Hast du bereits ein Konto?",
|
||||||
"already_have_sl_account": "Hast du bereits ein __appName__-Konto?",
|
"already_have_sl_account": "Hast du bereits ein __appName__-Konto?",
|
||||||
@@ -225,6 +238,7 @@
|
|||||||
"back_to_editor": "Zurück zum Editor",
|
"back_to_editor": "Zurück zum Editor",
|
||||||
"back_to_log_in": "Zurück zur Anmeldung",
|
"back_to_log_in": "Zurück zur Anmeldung",
|
||||||
"back_to_subscription": "Zurück zum Abonnement",
|
"back_to_subscription": "Zurück zum Abonnement",
|
||||||
|
"back_to_user_list": "Zurück zur Benutzerliste",
|
||||||
"back_to_your_projects": "Zurück zu deinen Projekten",
|
"back_to_your_projects": "Zurück zu deinen Projekten",
|
||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"beta_feature_badge": "Betafunktionsmerkmal",
|
"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",
|
"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",
|
"coupons_not_included": "Dies beinhaltet nicht deine aktuellen Rabatte, die automatisch vor deiner nächsten Zahlung angewandt werden",
|
||||||
"create": "Erstellen",
|
"create": "Erstellen",
|
||||||
|
"create_account": "Konto erstellen",
|
||||||
"create_a_new_password_for_your_account": "Erstelle ein neues Passwort für dein Konto",
|
"create_a_new_password_for_your_account": "Erstelle ein neues Passwort für dein Konto",
|
||||||
"create_a_new_project": "Erstelle ein neues Projekt",
|
"create_a_new_project": "Erstelle ein neues Projekt",
|
||||||
"create_account": "Konto erstellen",
|
"create_account": "Konto erstellen",
|
||||||
@@ -430,6 +445,7 @@
|
|||||||
"delete_account": "Konto löschen",
|
"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__</0> gelöscht werden",
|
"delete_account_confirmation_label": "Ich verstehe, dass dadurch alle Projekte in meinem __appName__-Konto mit der E-Mail-Adresse <0>__userDefaultEmail__</0> gelöscht werden",
|
||||||
"delete_account_warning_message_3": "Du bist dabei, <strong>alle Kontodaten</strong> 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_account_warning_message_3": "Du bist dabei, <strong>alle Kontodaten</strong> 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_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": "Löschen/Verlassen",
|
||||||
"delete_and_leave_projects": "Projekte löschen und verlassen",
|
"delete_and_leave_projects": "Projekte löschen und verlassen",
|
||||||
@@ -438,14 +454,15 @@
|
|||||||
"delete_certificate": "Zertifikat löschen",
|
"delete_certificate": "Zertifikat löschen",
|
||||||
"delete_comment": "Kommentar löschen",
|
"delete_comment": "Kommentar löschen",
|
||||||
"delete_figure": "Abbildung löschen",
|
"delete_figure": "Abbildung löschen",
|
||||||
"delete_projects": "Projekte löschen",
|
|
||||||
"delete_tag": "Stichwort löschen",
|
"delete_tag": "Stichwort löschen",
|
||||||
"delete_template": "Vorlage löschen",
|
"delete_template": "Vorlage löschen",
|
||||||
"delete_token": "Token löschen",
|
"delete_token": "Token löschen",
|
||||||
|
"delete_projects": "Projekte löschen",
|
||||||
"delete_user": "Nutzer löschen",
|
"delete_user": "Nutzer löschen",
|
||||||
"delete_your_account": "Lösche dein Konto",
|
"delete_your_account": "Lösche dein Konto",
|
||||||
"deleted_at": "Gelöscht am",
|
"deleted_at": "Gelöscht am",
|
||||||
"deleted_by_on": "Gelöscht von __name__ am __date__",
|
"deleted_by_on": "Gelöscht von __name__ am __date__",
|
||||||
|
"deleted_projects": "Gelöschte Projekte",
|
||||||
"deleting": "Löschen",
|
"deleting": "Löschen",
|
||||||
"demonstrating_git_integration": "Demonstration der Git-Integration",
|
"demonstrating_git_integration": "Demonstration der Git-Integration",
|
||||||
"department": "Abteilung",
|
"department": "Abteilung",
|
||||||
@@ -600,6 +617,7 @@
|
|||||||
"files_cannot_include_invalid_characters": "Der Dateiname ist leer oder enthält ungültige Zeichen",
|
"files_cannot_include_invalid_characters": "Der Dateiname ist leer oder enthält ungültige Zeichen",
|
||||||
"files_selected": "Dateien ausgewählt.",
|
"files_selected": "Dateien ausgewählt.",
|
||||||
"filter_projects": "Projekte filtern",
|
"filter_projects": "Projekte filtern",
|
||||||
|
"filter_users": "Benutzer filtern",
|
||||||
"filters": "Filter",
|
"filters": "Filter",
|
||||||
"find_out_more": "Finde mehr heraus",
|
"find_out_more": "Finde mehr heraus",
|
||||||
"find_out_more_about_institution_login": "Erfahre mehr über den institutionellen Login",
|
"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_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__</0> verknüpft.",
|
"in_order_to_match_institutional_metadata_2": "Um deine institutionellen Metadaten abzugleichen, haben wir dein Konto mit <0>__email__</0> verknüpft.",
|
||||||
"in_order_to_match_institutional_metadata_associated": "Um deine institutionellen Metadaten abzugleichen, wird dein Konto mit der E-Mail-Adresse <b>__email__</b> verknüpft.",
|
"in_order_to_match_institutional_metadata_associated": "Um deine institutionellen Metadaten abzugleichen, wird dein Konto mit der E-Mail-Adresse <b>__email__</b> verknüpft.",
|
||||||
|
"inactive_projects": "Inaktive Projekte",
|
||||||
"include_caption": "Beschriftung anzeigen",
|
"include_caption": "Beschriftung anzeigen",
|
||||||
"include_label": "Label anzeigen",
|
"include_label": "Label anzeigen",
|
||||||
"increased_compile_timeout": "Zeitlimit beim Kompilieren erhöhen",
|
"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.</0>",
|
"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.</0>",
|
||||||
"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_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?",
|
"need_to_leave": "Du musst gehen?",
|
||||||
|
"never": "Nie",
|
||||||
"new_editor": "Neuer Editor",
|
"new_editor": "Neuer Editor",
|
||||||
"new_file": "Neue Datei",
|
"new_file": "Neue Datei",
|
||||||
"new_folder": "Neuer Ordner",
|
"new_folder": "Neuer Ordner",
|
||||||
"new_name": "Neuer Name",
|
"new_name": "Neuer Name",
|
||||||
|
"new_owner": "Neuer Besitzer",
|
||||||
"new_password": "Neues Passwort",
|
"new_password": "Neues Passwort",
|
||||||
"new_project": "Neues Projekt",
|
"new_project": "Neues Projekt",
|
||||||
"new_snippet_project": "Ohne Titel",
|
"new_snippet_project": "Ohne Titel",
|
||||||
@@ -1057,6 +1078,7 @@
|
|||||||
"no_templates_found": "Keine Vorlagen gefunden.",
|
"no_templates_found": "Keine Vorlagen gefunden.",
|
||||||
"no_thanks_cancel_now": "Nein, danke - Ich möchte nach wie vor jetzt stornieren",
|
"no_thanks_cancel_now": "Nein, danke - Ich möchte nach wie vor jetzt stornieren",
|
||||||
"no_update_email": "Nein, E-Mail-Adresse aktualisieren",
|
"no_update_email": "Nein, E-Mail-Adresse aktualisieren",
|
||||||
|
"no_users": "Keine Benutzer",
|
||||||
"non_blinking_cursor": "Nicht blinkender Cursor",
|
"non_blinking_cursor": "Nicht blinkender Cursor",
|
||||||
"normal": "Normal",
|
"normal": "Normal",
|
||||||
"normally_x_price_per_month": "Normalerweise __price__ pro Monat",
|
"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_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 <b>__projectName__</b> beigetreten",
|
"notification_project_invite_accepted_message": "Du bist <b>__projectName__</b> beigetreten",
|
||||||
"notification_project_invite_message": "<b>__userName__</b> möchte, dass du <b>__projectName__</b> beitrittst",
|
"notification_project_invite_message": "<b>__userName__</b> möchte, dass du <b>__projectName__</b> beitrittst",
|
||||||
|
"notify_users_about_account_deletion": "Benutzer über die Kontolöschung benachrichtigen",
|
||||||
"november": "November",
|
"november": "November",
|
||||||
"number_collab": "Anzahl der Mitarbeiter",
|
"number_collab": "Anzahl der Mitarbeiter",
|
||||||
"number_of_users": "Nutzeranzahl",
|
"number_of_users": "Nutzeranzahl",
|
||||||
@@ -1106,7 +1129,9 @@
|
|||||||
"overleaf_learning_center": "Overleaf-Lernzentrum",
|
"overleaf_learning_center": "Overleaf-Lernzentrum",
|
||||||
"overview": "Überblick",
|
"overview": "Überblick",
|
||||||
"owned_by_x": "Besitzer: __x__",
|
"owned_by_x": "Besitzer: __x__",
|
||||||
|
"owned_projects": "Eigene Projekte",
|
||||||
"owner": "Besitzer",
|
"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_current": "Seite __page__, Aktuelle Seite",
|
||||||
"page_not_found": "Seite nicht gefunden",
|
"page_not_found": "Seite nicht gefunden",
|
||||||
"pagination_navigation": "Seitenumbruch-Navigation",
|
"pagination_navigation": "Seitenumbruch-Navigation",
|
||||||
@@ -1136,6 +1161,8 @@
|
|||||||
"per_month": "pro Monat",
|
"per_month": "pro Monat",
|
||||||
"per_user_year": "pro Nutzer / Jahr",
|
"per_user_year": "pro Nutzer / Jahr",
|
||||||
"per_year": "pro Jahr",
|
"per_year": "pro Jahr",
|
||||||
|
"permanently_delete_accounts": "Konten endgültig löschen",
|
||||||
|
"permanently_delete_projects": "Projekte endgültig löschen:",
|
||||||
"personal": "Persönlich",
|
"personal": "Persönlich",
|
||||||
"personalized_onboarding": "Personalisiertes Onboarding",
|
"personalized_onboarding": "Personalisiertes Onboarding",
|
||||||
"pl": "Polnisch",
|
"pl": "Polnisch",
|
||||||
@@ -1211,6 +1238,7 @@
|
|||||||
"publish_as_template": "Als Vorlage veröffentlichen",
|
"publish_as_template": "Als Vorlage veröffentlichen",
|
||||||
"publishing": "Veröffentlichen",
|
"publishing": "Veröffentlichen",
|
||||||
"pull_github_changes_into_sharelatex": "GitHub-Änderungen nach __appName__ ziehen",
|
"pull_github_changes_into_sharelatex": "GitHub-Änderungen nach __appName__ ziehen",
|
||||||
|
"purge": "Vernichten",
|
||||||
"push_sharelatex_changes_to_github": "__appName__-Änderungen an GitHub senden",
|
"push_sharelatex_changes_to_github": "__appName__-Änderungen an GitHub senden",
|
||||||
"raw_logs": "Raw Logs",
|
"raw_logs": "Raw Logs",
|
||||||
"raw_logs_description": "Raw Logs vom LaTeX-Compiler",
|
"raw_logs_description": "Raw Logs vom LaTeX-Compiler",
|
||||||
@@ -1278,6 +1306,7 @@
|
|||||||
"requesting_password_reset": "Zurücksetzen des Passworts anfordern",
|
"requesting_password_reset": "Zurücksetzen des Passworts anfordern",
|
||||||
"required": "Erforderlich",
|
"required": "Erforderlich",
|
||||||
"resend": "Sende erneut",
|
"resend": "Sende erneut",
|
||||||
|
"resend_activation_email": "Aktivierungs-E-Mail erneut senden",
|
||||||
"resend_confirmation_code": "Bestätigungscode erneut senden",
|
"resend_confirmation_code": "Bestätigungscode erneut senden",
|
||||||
"resend_managed_user_invite": "Einladung zu Verwaltete Benutzer erneut senden",
|
"resend_managed_user_invite": "Einladung zu Verwaltete Benutzer erneut senden",
|
||||||
"resending_confirmation_code": "Bestätigungscode wird erneut gesendet",
|
"resending_confirmation_code": "Bestätigungscode wird erneut gesendet",
|
||||||
@@ -1286,9 +1315,13 @@
|
|||||||
"resolve": "Lösen",
|
"resolve": "Lösen",
|
||||||
"resolved_comments": "Gelöste Kommentare",
|
"resolved_comments": "Gelöste Kommentare",
|
||||||
"restore": "Wiederherstellen",
|
"restore": "Wiederherstellen",
|
||||||
|
"restore_accounts": "Konten wiederherstellen",
|
||||||
|
"restore_projects": "Projekte wiederherzustellen:",
|
||||||
"restoring": "Wiederherstellen",
|
"restoring": "Wiederherstellen",
|
||||||
"restricted": "Geschützt",
|
"restricted": "Geschützt",
|
||||||
"restricted_no_permission": "Entschuldigung, du hast nicht die Berechtigung, diese Seite anzuzeigen.",
|
"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",
|
"return_to_login_page": "Zurück zur Login-Seite",
|
||||||
"reverse_x_sort_order": "Sortierreihenfolge für __x__ umkehren",
|
"reverse_x_sort_order": "Sortierreihenfolge für __x__ umkehren",
|
||||||
"revert_pending_plan_change": "Abonnement-Änderung rückgängig machen",
|
"revert_pending_plan_change": "Abonnement-Änderung rückgängig machen",
|
||||||
@@ -1328,8 +1361,10 @@
|
|||||||
"security": "Sicherheit",
|
"security": "Sicherheit",
|
||||||
"see_your_current_location_in_the_project": "Zeige deinen aktuellen Standort im Projekt an",
|
"see_your_current_location_in_the_project": "Zeige deinen aktuellen Standort im Projekt an",
|
||||||
"select_a_file": "Datei auswählen",
|
"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_a_project": "Projekt auswählen",
|
||||||
"select_all_projects": "Alle Projekte auswählen",
|
"select_all_projects": "Alle Projekte auswählen",
|
||||||
|
"select_all_users": "Alle Benutzer auswählen",
|
||||||
"select_an_output_file": "Ausgabedatei auswählen",
|
"select_an_output_file": "Ausgabedatei auswählen",
|
||||||
"select_color": "Farbe __name__ auswählen",
|
"select_color": "Farbe __name__ auswählen",
|
||||||
"select_from_output_files": "aus Ausgabedateien auswählen",
|
"select_from_output_files": "aus Ausgabedateien auswählen",
|
||||||
@@ -1338,9 +1373,12 @@
|
|||||||
"select_project": "__project__ auswählen",
|
"select_project": "__project__ auswählen",
|
||||||
"select_projects": "Projekte auswählen",
|
"select_projects": "Projekte auswählen",
|
||||||
"select_tag": "Stichwort __tagName__ auswählen",
|
"select_tag": "Stichwort __tagName__ auswählen",
|
||||||
|
"select_user": "Benutzer auswählen",
|
||||||
|
"select_users": "Benutzer auswählen",
|
||||||
"selected": "Ausgewählt",
|
"selected": "Ausgewählt",
|
||||||
"selected_by_overleaf_staff": "Ausgewählt von Overleaf-Mitarbeitern",
|
"selected_by_overleaf_staff": "Ausgewählt von Overleaf-Mitarbeitern",
|
||||||
"send": "Absenden",
|
"send": "Absenden",
|
||||||
|
"send_notification_emails_to_users": "Eine Benachrichtigung an den aktuellen und den neuen Besitzer senden",
|
||||||
"send_confirmation_code": "Bestätigungscode senden",
|
"send_confirmation_code": "Bestätigungscode senden",
|
||||||
"send_test_email": "Test-Mail senden",
|
"send_test_email": "Test-Mail senden",
|
||||||
"sending": "Wird gesendet",
|
"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_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",
|
"session_expired_redirecting_to_login": "Sitzung abgelaufen. Du wirst in __seconds__ Sekunden auf die Anmeldungsseite umgeleitet",
|
||||||
"sessions": "Sessions",
|
"sessions": "Sessions",
|
||||||
|
"set_admin": "Admin setzen",
|
||||||
|
"set_admin_account": "Administratorrechte vergeben",
|
||||||
"set_color": "Farbe festlegen",
|
"set_color": "Farbe festlegen",
|
||||||
"set_new_password": "Neues Passwort eingeben",
|
"set_new_password": "Neues Passwort eingeben",
|
||||||
"set_password": "Passwort setzen",
|
"set_password": "Passwort setzen",
|
||||||
@@ -1361,19 +1401,23 @@
|
|||||||
"shared_with_you": "Mit dir geteilt",
|
"shared_with_you": "Mit dir geteilt",
|
||||||
"sharelatex_beta_program": "__appName__ Beta-Programm",
|
"sharelatex_beta_program": "__appName__ Beta-Programm",
|
||||||
"show_all_projects": "Alle Projekte anzeigen",
|
"show_all_projects": "Alle Projekte anzeigen",
|
||||||
|
"show_all_users": "Alle Benutzer anzeigen",
|
||||||
"show_in_code": "Im Code anzeigen",
|
"show_in_code": "Im Code anzeigen",
|
||||||
"show_in_pdf": "Im PDF anzeigen",
|
"show_in_pdf": "Im PDF anzeigen",
|
||||||
"show_less": "Weniger anzeigen",
|
"show_less": "Weniger anzeigen",
|
||||||
"show_live_equation_previews_while_typing": "Live-Gleichungsvorschau beim Tippen anzeigen",
|
"show_live_equation_previews_while_typing": "Live-Gleichungsvorschau beim Tippen anzeigen",
|
||||||
"show_outline": "Dateigliederung anzeigen",
|
"show_outline": "Dateigliederung anzeigen",
|
||||||
"show_x_more_projects": "__x__ weitere Projekte 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": "1 Ergebnis wird angezeigt",
|
||||||
"showing_1_result_of_total": "Zeige 1 Ergebnis von __total__",
|
"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_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": "Es werden __x__ Ergebnisse angezeigt",
|
||||||
"showing_x_results_of_total": "Es werden __x__ Ergebnisse von __total__ angezeigt",
|
"showing_x_results_of_total": "Es werden __x__ Ergebnisse von __total__ angezeigt",
|
||||||
"single_sign_on_sso": "Single Sign-On (SSO)",
|
"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",
|
"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",
|
"skip_to_content": "Zum Inhalt springen",
|
||||||
"solutions": "Lösungen",
|
"solutions": "Lösungen",
|
||||||
"something_went_wrong_canceling_your_subscription": "Beim Kündigen deines Abonnements ist etwas schief gelaufen. Bitte wende dich an den Support.",
|
"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__</0> wechseln möchtest?",
|
"sure_you_want_to_change_plan": "Bist du sicher, dass du zum Abonnement <0>__planName__</0> wechseln möchtest?",
|
||||||
"sure_you_want_to_delete": "Möchtest du die folgenden Dateien wirklich löschen?",
|
"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?",
|
"sure_you_want_to_leave_group": "Bist du sicher, dass du diese Gruppe verlassen möchtest?",
|
||||||
|
"suspend": "Sperren",
|
||||||
|
"suspend_account": "Konto sperren",
|
||||||
"sv": "Schwedisch",
|
"sv": "Schwedisch",
|
||||||
"switch_compile_mode_for_faster_draft_compilation": "Kompiliermodus für schnellere Entwurfskompilierung wechseln",
|
"switch_compile_mode_for_faster_draft_compilation": "Kompiliermodus für schnellere Entwurfskompilierung wechseln",
|
||||||
"symbol_palette": "Symbolpalette",
|
"symbol_palette": "Symbolpalette",
|
||||||
@@ -1486,6 +1532,7 @@
|
|||||||
"there_was_an_error_opening_your_content": "Beim Erstellen deines Projekts ist ein Fehler aufgetreten",
|
"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.",
|
"these_settings_might_change_in_the_future": "Diese Einstellungen können sich in Zukunft ändern.",
|
||||||
"thesis": "Doktorarbeit",
|
"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_action_cannot_be_undone": "Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
"this_field_is_required": "Dieses Feld wird benötigt",
|
"this_field_is_required": "Dieses Feld wird benötigt",
|
||||||
"this_grants_access_to_features_2": "Dadurch erhältst du Zugriff auf die <0>__featureType__</0> Funktionen von <0>__appName__</0>.",
|
"this_grants_access_to_features_2": "Dadurch erhältst du Zugriff auf die <0>__featureType__</0> Funktionen von <0>__appName__</0>.",
|
||||||
@@ -1526,6 +1573,7 @@
|
|||||||
"track_changes": "Änderungen verfolgen",
|
"track_changes": "Änderungen verfolgen",
|
||||||
"tracked_change_added": "Hinzugefügt",
|
"tracked_change_added": "Hinzugefügt",
|
||||||
"tracked_change_deleted": "Gelöscht",
|
"tracked_change_deleted": "Gelöscht",
|
||||||
|
"transfer_all_projects_to": "Alle Projekte der Benutzer übertragen an:",
|
||||||
"trash": "Löschen",
|
"trash": "Löschen",
|
||||||
"trash_projects": "Lösche Projekte",
|
"trash_projects": "Lösche Projekte",
|
||||||
"trashed_projects": "Gelöschte Projekte",
|
"trashed_projects": "Gelöschte Projekte",
|
||||||
@@ -1579,6 +1627,8 @@
|
|||||||
"unlinking": "Verknüpfung wird aufgehoben",
|
"unlinking": "Verknüpfung wird aufgehoben",
|
||||||
"unpublish": "Veröffentlichung aufheben",
|
"unpublish": "Veröffentlichung aufheben",
|
||||||
"unpublishing": "Veröffentlichung aufheben",
|
"unpublishing": "Veröffentlichung aufheben",
|
||||||
|
"unset_admin": "Admin entfernen",
|
||||||
|
"unset_admin_account": "Administratorrechte entziehen",
|
||||||
"unsubscribe": "Abbestellen",
|
"unsubscribe": "Abbestellen",
|
||||||
"unsubscribed": "Abbestellt",
|
"unsubscribed": "Abbestellt",
|
||||||
"unsubscribing": "Abbestellen läuft",
|
"unsubscribing": "Abbestellen läuft",
|
||||||
@@ -1602,12 +1652,24 @@
|
|||||||
"use_a_different_email": "Verwende eine <0>andere E-Mail-Adresse</0>.",
|
"use_a_different_email": "Verwende eine <0>andere E-Mail-Adresse</0>.",
|
||||||
"use_a_different_password": "Bitte verwende ein anderes Passwort",
|
"use_a_different_password": "Bitte verwende ein anderes Passwort",
|
||||||
"use_your_own_machine": "Verwende deine eigene Maschine mit deinem eigenen Setup",
|
"use_your_own_machine": "Verwende deine eigene Maschine mit deinem eigenen Setup",
|
||||||
|
"user_activity": "Benutzeraktivität",
|
||||||
"user_already_added": "Nutzer bereits hinzugefügt",
|
"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_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</0> und versuche es 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</0> und versuche es erneut.",
|
||||||
"user_management": "Nutzerverwaltung",
|
"user_management": "Nutzerverwaltung",
|
||||||
"user_not_found": "Nutzer wurde nicht gefunden",
|
"user_not_found": "Nutzer wurde nicht gefunden",
|
||||||
"user_wants_you_to_see_project": "__username__ möchte, dass Du __projectname__ beitreten",
|
"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",
|
"validation_issue_entry_description": "Ein Validierungsproblem, das die Kompilierung dieses Projekts verhindert hat",
|
||||||
"vat": "MwSt.",
|
"vat": "MwSt.",
|
||||||
"vat_number": "Umsatzsteuernummer",
|
"vat_number": "Umsatzsteuernummer",
|
||||||
|
|||||||
@@ -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.",
|
"a_new_version_of_the_rolling_texlive_build_released": "A new version of the Rolling TeX Live build has been released.",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"about_to_archive_projects": "You are about to archive the following projects:",
|
"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_cert": "You are about to delete the following certificate:",
|
||||||
"about_to_delete_projects": "You are about to delete the following projects:",
|
"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):",
|
"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_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_project": "You are about to leave this project.",
|
||||||
"about_to_leave_projects": "You are about to leave the following projects:",
|
"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_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_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",
|
"abstract": "Abstract",
|
||||||
"accept": "Accept",
|
"accept": "Accept",
|
||||||
"accept_all_cookies": "Accept all cookies",
|
"accept_all_cookies": "Accept all cookies",
|
||||||
@@ -66,6 +76,7 @@
|
|||||||
"account_has_been_link_to_institution_account": "Your __appName__ account on <b>__email__</b> has been linked to your <b>__institutionName__</b> institutional account.",
|
"account_has_been_link_to_institution_account": "Your __appName__ account on <b>__email__</b> has been linked to your <b>__institutionName__</b> 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_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_help": "Account and help",
|
||||||
|
"account_information": "Account information",
|
||||||
"account_linking": "Account linking",
|
"account_linking": "Account linking",
|
||||||
"account_managed_by_group_administrator": "Your account is managed by your group administrator (__admin__)",
|
"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__</0>.",
|
"account_managed_by_group_teamname": "This __appName__ account is managed by <0>__teamName__</0>.",
|
||||||
@@ -77,6 +88,7 @@
|
|||||||
"activate": "Activate",
|
"activate": "Activate",
|
||||||
"activate_account": "Activate your account",
|
"activate_account": "Activate your account",
|
||||||
"activating": "Activating",
|
"activating": "Activating",
|
||||||
|
"activation_link": "Activation link",
|
||||||
"activation_token_expired": "Your activation token has expired, you will need to get another one sent to you.",
|
"activation_token_expired": "Your activation token has expired, you will need to get another one sent to you.",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
@@ -185,6 +197,7 @@
|
|||||||
"all_templates": "All templates",
|
"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_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_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",
|
"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).",
|
"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?",
|
"already_have_an_account": "Already have an account?",
|
||||||
@@ -264,6 +277,7 @@
|
|||||||
"back_to_my_projects": "Back to my projects",
|
"back_to_my_projects": "Back to my projects",
|
||||||
"back_to_pdf": "Back to PDF",
|
"back_to_pdf": "Back to PDF",
|
||||||
"back_to_subscription": "Back to subscription",
|
"back_to_subscription": "Back to subscription",
|
||||||
|
"back_to_user_list": "Back to user list",
|
||||||
"back_to_your_projects": "Back to your projects",
|
"back_to_your_projects": "Back to your projects",
|
||||||
"basic": "Basic",
|
"basic": "Basic",
|
||||||
"basic_ai_allowance": "Basic AI allowance",
|
"basic_ai_allowance": "Basic AI allowance",
|
||||||
@@ -588,6 +602,7 @@
|
|||||||
"delete_account": "Delete account",
|
"delete_account": "Delete account",
|
||||||
"delete_account_confirmation_label": "I understand this will delete all projects in my __appName__ account with email address <0>__userDefaultEmail__</0>",
|
"delete_account_confirmation_label": "I understand this will delete all projects in my __appName__ account with email address <0>__userDefaultEmail__</0>",
|
||||||
"delete_account_warning_message_3": "You are about to permanently <strong>delete all of your account data</strong>, including your projects and settings. Please type your account email address and password in the boxes below to proceed.",
|
"delete_account_warning_message_3": "You are about to permanently <strong>delete all of your account data</strong>, 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_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": "Delete / Leave",
|
||||||
"delete_and_leave_projects": "Delete and Leave Projects",
|
"delete_and_leave_projects": "Delete and Leave Projects",
|
||||||
@@ -618,6 +633,7 @@
|
|||||||
"deleted_by_id": "Deleted By ID",
|
"deleted_by_id": "Deleted By ID",
|
||||||
"deleted_by_ip": "Deleted By IP",
|
"deleted_by_ip": "Deleted By IP",
|
||||||
"deleted_by_on": "Deleted by __name__ on __date__",
|
"deleted_by_on": "Deleted by __name__ on __date__",
|
||||||
|
"deleted_projects": "Deleted projects",
|
||||||
"deleted_user": "Deleted user",
|
"deleted_user": "Deleted user",
|
||||||
"deleting": "Deleting",
|
"deleting": "Deleting",
|
||||||
"demonstrating_git_integration": "Demonstrating Git integration",
|
"demonstrating_git_integration": "Demonstrating Git integration",
|
||||||
@@ -903,6 +919,7 @@
|
|||||||
"files_cannot_include_invalid_characters": "File name is empty or contains invalid characters",
|
"files_cannot_include_invalid_characters": "File name is empty or contains invalid characters",
|
||||||
"files_selected": "files selected.",
|
"files_selected": "files selected.",
|
||||||
"filter_projects": "Filter projects",
|
"filter_projects": "Filter projects",
|
||||||
|
"filter_users": "Filter users",
|
||||||
"filters": "Filters",
|
"filters": "Filters",
|
||||||
"find": "Find",
|
"find": "Find",
|
||||||
"find_and_check_citations": "Find and check citations",
|
"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_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__</0>.",
|
"in_order_to_match_institutional_metadata_2": "In order to match your institutional metadata, we’ve linked your account using <0>__email__</0>.",
|
||||||
"in_order_to_match_institutional_metadata_associated": "In order to match your institutional metadata, your account is associated with the email <b>__email__</b>.",
|
"in_order_to_match_institutional_metadata_associated": "In order to match your institutional metadata, your account is associated with the email <b>__email__</b>.",
|
||||||
|
"inactive_projects": "Inactive projects",
|
||||||
"include_caption": "Include caption",
|
"include_caption": "Include caption",
|
||||||
"include_label": "Include label",
|
"include_label": "Include label",
|
||||||
"include_results_from_your_reference_manager": "Include results from your reference manager",
|
"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_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?",
|
"need_to_leave": "Need to leave?",
|
||||||
"neither_agree_nor_disagree": "Neither agree nor disagree",
|
"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__</0>. This could be caused by network blocking or a strict browser plugin rule. Please follow our <1>troubleshooting guide</1>.",
|
"new_compile_domain_notice": "Something might be blocking your browser from accessing Overleaf’s PDF download location, <0>__compilesUserContentDomain__</0>. This could be caused by network blocking or a strict browser plugin rule. Please follow our <1>troubleshooting guide</1>.",
|
||||||
"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</0>",
|
"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</0>",
|
||||||
"new_file": "New file",
|
"new_file": "New file",
|
||||||
"new_folder": "New folder",
|
"new_folder": "New folder",
|
||||||
"new_name": "New name",
|
"new_name": "New name",
|
||||||
|
"new_owner": "New owner",
|
||||||
"new_password": "New password",
|
"new_password": "New password",
|
||||||
"new_project": "New project",
|
"new_project": "New project",
|
||||||
"new_reference": "__count__ new reference",
|
"new_reference": "__count__ new reference",
|
||||||
@@ -1683,6 +1703,7 @@
|
|||||||
"no_templates_found": "No templates found.",
|
"no_templates_found": "No templates found.",
|
||||||
"no_thanks_cancel_now": "No thanks, I still want to cancel",
|
"no_thanks_cancel_now": "No thanks, I still want to cancel",
|
||||||
"no_update_email": "No, update email",
|
"no_update_email": "No, update email",
|
||||||
|
"no_users": "No users",
|
||||||
"non_blinking_cursor": "Non-blinking cursor",
|
"non_blinking_cursor": "Non-blinking cursor",
|
||||||
"non_deletable_entity": "The specified entity may not be deleted",
|
"non_deletable_entity": "The specified entity may not be deleted",
|
||||||
"normal": "Normal",
|
"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_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 <b>__projectName__</b>",
|
"notification_project_invite_accepted_message": "You’ve joined <b>__projectName__</b>",
|
||||||
"notification_project_invite_message": "<b>__userName__</b> would like you to join <b>__projectName__</b>",
|
"notification_project_invite_message": "<b>__userName__</b> would like you to join <b>__projectName__</b>",
|
||||||
|
"notify_users_about_account_deletion": "Notify users about account deletion",
|
||||||
"november": "November",
|
"november": "November",
|
||||||
"number_collab": "Number of collaborators",
|
"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.",
|
"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",
|
"overwrite": "Overwrite",
|
||||||
"overwriting_the_original_folder": "Overwriting the original folder will delete it and all the files it contains.",
|
"overwriting_the_original_folder": "Overwriting the original folder will delete it and all the files it contains.",
|
||||||
"owned_by_x": "owned by __x__",
|
"owned_by_x": "owned by __x__",
|
||||||
|
"owned_projects": "User's projects",
|
||||||
"owner": "Owner",
|
"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_current": "Page __page__, Current Page",
|
||||||
"page_not_found": "Page Not Found",
|
"page_not_found": "Page Not Found",
|
||||||
"page_x_of_n": "Page __x__ of __n__",
|
"page_x_of_n": "Page __x__ of __n__",
|
||||||
@@ -1873,6 +1897,8 @@
|
|||||||
"per_year": "per year",
|
"per_year": "per year",
|
||||||
"percent_is_the_percentage_of_the_line_width": "% is the percentage of the line width",
|
"percent_is_the_percentage_of_the_line_width": "% is the percentage of the line width",
|
||||||
"performance": "Performance",
|
"performance": "Performance",
|
||||||
|
"permanently_delete_accounts": "Permanently delete accounts",
|
||||||
|
"permanently_delete_projects": "Permanently delete projects",
|
||||||
"permanently_disables_the_preview": "Permanently disables the preview",
|
"permanently_disables_the_preview": "Permanently disables the preview",
|
||||||
"personal": "Personal",
|
"personal": "Personal",
|
||||||
"personal_library": "Personal library",
|
"personal_library": "Personal library",
|
||||||
@@ -2020,6 +2046,7 @@
|
|||||||
"publisher_account": "Publisher Account",
|
"publisher_account": "Publisher Account",
|
||||||
"publishing": "Publishing",
|
"publishing": "Publishing",
|
||||||
"pull_github_changes_into_sharelatex": "Pull GitHub changes into __appName__",
|
"pull_github_changes_into_sharelatex": "Pull GitHub changes into __appName__",
|
||||||
|
"purge": "Purge",
|
||||||
"push_sharelatex_changes_to_github": "Push __appName__ changes to GitHub",
|
"push_sharelatex_changes_to_github": "Push __appName__ changes to GitHub",
|
||||||
"push_to_github_pull_to_overleaf": "Push to GitHub, pull to __appName__",
|
"push_to_github_pull_to_overleaf": "Push to GitHub, pull to __appName__",
|
||||||
"quoted_text": "Quoted text",
|
"quoted_text": "Quoted text",
|
||||||
@@ -2145,6 +2172,7 @@
|
|||||||
"requesting_password_reset": "Requesting password reset",
|
"requesting_password_reset": "Requesting password reset",
|
||||||
"required": "Required",
|
"required": "Required",
|
||||||
"resend": "Resend",
|
"resend": "Resend",
|
||||||
|
"resend_activation_email": "Resend activation email",
|
||||||
"resend_confirmation_code": "Resend confirmation code",
|
"resend_confirmation_code": "Resend confirmation code",
|
||||||
"resend_group_invite": "Resend group invite",
|
"resend_group_invite": "Resend group invite",
|
||||||
"resend_invite": "Resend invite",
|
"resend_invite": "Resend invite",
|
||||||
@@ -2163,6 +2191,8 @@
|
|||||||
"resolved_comments": "Resolved comments",
|
"resolved_comments": "Resolved comments",
|
||||||
"resources": "Resources",
|
"resources": "Resources",
|
||||||
"restore": "Restore",
|
"restore": "Restore",
|
||||||
|
"restore_accounts": "Restore accounts",
|
||||||
|
"restore_projects": "Restore projects",
|
||||||
"restore_file": "Restore file",
|
"restore_file": "Restore file",
|
||||||
"restore_file_confirmation_message": "Your current file will restore to the version from __date__ at __time__.",
|
"restore_file_confirmation_message": "Your current file will restore to the version from __date__ at __time__.",
|
||||||
"restore_file_confirmation_title": "Restore this version?",
|
"restore_file_confirmation_title": "Restore this version?",
|
||||||
@@ -2174,6 +2204,8 @@
|
|||||||
"restoring": "Restoring",
|
"restoring": "Restoring",
|
||||||
"restricted": "Restricted",
|
"restricted": "Restricted",
|
||||||
"restricted_no_permission": "Restricted, sorry you don’t have permission to load this page.",
|
"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_completed": "Resync completed!",
|
||||||
"resync_message": "Resyncing project history can take several minutes depending on the size of the project.",
|
"resync_message": "Resyncing project history can take several minutes depending on the size of the project.",
|
||||||
"resync_project_history": "Resync Project History",
|
"resync_project_history": "Resync Project History",
|
||||||
@@ -2239,6 +2271,7 @@
|
|||||||
"search_command_replace": "Replace",
|
"search_command_replace": "Replace",
|
||||||
"search_in_all_projects": "Search in all projects",
|
"search_in_all_projects": "Search in all projects",
|
||||||
"search_in_archived_projects": "Search in archived 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_shared_projects": "Search in projects shared with you",
|
||||||
"search_in_trashed_projects": "Search in trashed projects",
|
"search_in_trashed_projects": "Search in trashed projects",
|
||||||
"search_in_your_projects": "Search in your projects",
|
"search_in_your_projects": "Search in your projects",
|
||||||
@@ -2279,6 +2312,7 @@
|
|||||||
"select_all": "Select all",
|
"select_all": "Select all",
|
||||||
"select_all_entries": "Select all entries",
|
"select_all_entries": "Select all entries",
|
||||||
"select_all_projects": "Select all projects",
|
"select_all_projects": "Select all projects",
|
||||||
|
"select_all_users": "Select all users",
|
||||||
"select_an_output_file": "Select an Output File",
|
"select_an_output_file": "Select an Output File",
|
||||||
"select_an_output_file_figure_modal": "Select an output file",
|
"select_an_output_file_figure_modal": "Select an output file",
|
||||||
"select_bib_file": "Select .bib file",
|
"select_bib_file": "Select .bib file",
|
||||||
@@ -2300,6 +2334,7 @@
|
|||||||
"select_tax_id_type": "Select tax ID type",
|
"select_tax_id_type": "Select tax ID type",
|
||||||
"select_user": "Select user",
|
"select_user": "Select user",
|
||||||
"select_user_role": "Select user role",
|
"select_user_role": "Select user role",
|
||||||
|
"select_users": "Select users",
|
||||||
"selected": "Selected",
|
"selected": "Selected",
|
||||||
"selected_by_overleaf_staff": "Selected by Overleaf staff",
|
"selected_by_overleaf_staff": "Selected by Overleaf staff",
|
||||||
"selected_lowercase": "selected",
|
"selected_lowercase": "selected",
|
||||||
@@ -2307,6 +2342,7 @@
|
|||||||
"selected_plural": "selected",
|
"selected_plural": "selected",
|
||||||
"selection_deleted": "Selection deleted",
|
"selection_deleted": "Selection deleted",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
|
"send_notification_emails_to_users": "Send notification emails to the current and the new owners",
|
||||||
"send_confirmation_code": "Send confirmation code",
|
"send_confirmation_code": "Send confirmation code",
|
||||||
"send_message": "Send message",
|
"send_message": "Send message",
|
||||||
"send_request": "Send request",
|
"send_request": "Send request",
|
||||||
@@ -2326,6 +2362,8 @@
|
|||||||
"session_expired_redirecting_to_login": "Session Expired. Redirecting to login page in __seconds__ seconds",
|
"session_expired_redirecting_to_login": "Session Expired. Redirecting to login page in __seconds__ seconds",
|
||||||
"sessions": "Sessions",
|
"sessions": "Sessions",
|
||||||
"set_as_main_document": "Set as main document",
|
"set_as_main_document": "Set as main document",
|
||||||
|
"set_admin": "Set admin",
|
||||||
|
"set_admin_account": "Grant admin privileges",
|
||||||
"set_color": "set color",
|
"set_color": "set color",
|
||||||
"set_column_width": "Set column width",
|
"set_column_width": "Set column width",
|
||||||
"set_new_password": "Set new password",
|
"set_new_password": "Set new password",
|
||||||
@@ -2344,6 +2382,7 @@
|
|||||||
"sharing_permissions": "Sharing permissions",
|
"sharing_permissions": "Sharing permissions",
|
||||||
"shortcut_to_open_advanced_reference_search": "(<strong>__ctrlSpace__</strong> or <strong>__altSpace__</strong>)",
|
"shortcut_to_open_advanced_reference_search": "(<strong>__ctrlSpace__</strong> or <strong>__altSpace__</strong>)",
|
||||||
"show_all_projects": "Show all projects",
|
"show_all_projects": "Show all projects",
|
||||||
|
"show_all_users": "Show all users",
|
||||||
"show_breadcrumbs": "Show breadcrumbs",
|
"show_breadcrumbs": "Show breadcrumbs",
|
||||||
"show_document_preamble": "Show document preamble",
|
"show_document_preamble": "Show document preamble",
|
||||||
"show_equation_preview": "Show equation preview",
|
"show_equation_preview": "Show equation preview",
|
||||||
@@ -2357,6 +2396,7 @@
|
|||||||
"show_outline": "Show File outline",
|
"show_outline": "Show File outline",
|
||||||
"show_version_history": "Show version history",
|
"show_version_history": "Show version history",
|
||||||
"show_x_more_projects": "Show __x__ more projects",
|
"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": "Showing 1 result",
|
||||||
"showing_1_result_of_total": "Showing 1 result of __total__",
|
"showing_1_result_of_total": "Showing 1 result of __total__",
|
||||||
"showing_pdf_preview_with_inverted_colors": "Showing PDF preview with inverted colors",
|
"showing_pdf_preview_with_inverted_colors": "Showing PDF preview with inverted colors",
|
||||||
@@ -2368,6 +2408,7 @@
|
|||||||
"sign_up": "Sign up",
|
"sign_up": "Sign up",
|
||||||
"sign_up_for_free": "Sign up for free",
|
"sign_up_for_free": "Sign up for free",
|
||||||
"sign_up_for_free_account": "Sign up for a free account and receive regular updates",
|
"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_pricing_for_individuals_and_teams": "Simple pricing for individuals and teams",
|
||||||
"simple_search_mode": "Simple search",
|
"simple_search_mode": "Simple search",
|
||||||
"single_sign_on_sso": "Single Sign-On (SSO)",
|
"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__</0>?",
|
"sure_you_want_to_change_plan": "Are you sure you want to change plan to <0>__planName__</0>?",
|
||||||
"sure_you_want_to_delete": "Are you sure you want to permanently delete the following files?",
|
"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?",
|
"sure_you_want_to_leave_group": "Are you sure you want to leave this group?",
|
||||||
|
"suspend": "Suspend",
|
||||||
|
"suspend_account": "Suspend account",
|
||||||
"sv": "Swedish",
|
"sv": "Swedish",
|
||||||
"switch_compile_mode_for_faster_draft_compilation": "Switch compile mode for faster draft compilation",
|
"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.",
|
"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_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_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.",
|
"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_reversed": "This action cannot be reversed.",
|
||||||
"this_action_cannot_be_undone": "This action cannot be undone.",
|
"this_action_cannot_be_undone": "This action cannot be undone.",
|
||||||
"this_action_will_also_disable_domain_capture": "This action will also disable domain capture.",
|
"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.",
|
"track_changes_explanation": "Make and see track changes.",
|
||||||
"tracked_change_added": "Added",
|
"tracked_change_added": "Added",
|
||||||
"tracked_change_deleted": "Deleted",
|
"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": "Transfer management of your Overleaf account",
|
||||||
"transfer_management_of_your_account_to_x": "Transfer management of your Overleaf account to __groupName__",
|
"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:",
|
"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": "Trashed",
|
||||||
"trashed_projects": "Trashed projects",
|
"trashed_projects": "Trashed projects",
|
||||||
"trashing_projects_wont_affect_collaborators": "Trashing projects won’t affect your collaborators.",
|
"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 <b>Overleaf Premium</b> trial",
|
"trial_last_day": "This is the last day of your <b>Overleaf Premium</b> trial",
|
||||||
"trial_remaining_days": "__days__ more days on your <b>Overleaf Premium</b> trial",
|
"trial_remaining_days": "__days__ more days on your <b>Overleaf Premium</b> trial",
|
||||||
"tried_to_log_in_with_email": "You’ve tried to log in with <b>__email__</b>.",
|
"tried_to_log_in_with_email": "You’ve tried to log in with <b>__email__</b>.",
|
||||||
@@ -2867,6 +2913,8 @@
|
|||||||
"unpausing": "Unpausing",
|
"unpausing": "Unpausing",
|
||||||
"unpublish": "Unpublish",
|
"unpublish": "Unpublish",
|
||||||
"unpublishing": "Unpublishing",
|
"unpublishing": "Unpublishing",
|
||||||
|
"unset_admin": "Unset admin",
|
||||||
|
"unset_admin_account": "Revoke admin privileges",
|
||||||
"unsubscribe": "Unsubscribe",
|
"unsubscribe": "Unsubscribe",
|
||||||
"unsubscribed": "Unsubscribed",
|
"unsubscribed": "Unsubscribed",
|
||||||
"unsubscribing": "Unsubscribing",
|
"unsubscribing": "Unsubscribing",
|
||||||
@@ -2919,10 +2967,21 @@
|
|||||||
"used_latex_response_occasionally": "I’ve used it occasionally",
|
"used_latex_response_occasionally": "I’ve used it occasionally",
|
||||||
"used_latex_response_often": "I use it often",
|
"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",
|
"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": "User administration",
|
||||||
"user_administration_and_usage_metrics": "User administration and usage metrics",
|
"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_administration_explanation": "Dashboard for adding and removing users on a subscription, and usage metrics",
|
||||||
"user_already_added": "User already added",
|
"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_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</0> and try again.",
|
"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</0> and try again.",
|
||||||
"user_email_attribute": "User email attribute",
|
"user_email_attribute": "User email attribute",
|
||||||
@@ -2935,6 +2994,7 @@
|
|||||||
"user_not_found": "User not found",
|
"user_not_found": "User not found",
|
||||||
"user_sessions": "User Sessions",
|
"user_sessions": "User Sessions",
|
||||||
"user_wants_you_to_see_project": "__username__ would like you to join __projectname__",
|
"user_wants_you_to_see_project": "__username__ would like you to join __projectname__",
|
||||||
|
"users_list": "Users list",
|
||||||
"using_latex": "Using LaTeX",
|
"using_latex": "Using LaTeX",
|
||||||
"using_premium_features": "Using premium features",
|
"using_premium_features": "Using premium features",
|
||||||
"using_the_overleaf_editor": "Using the __appName__ Editor",
|
"using_the_overleaf_editor": "Using the __appName__ Editor",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"a_more_comprehensive_list_of_keyboard_shortcuts": "Более полный лист горячих клавиш находится в <0>этом шаблоне проекта __appName__</0>",
|
"a_more_comprehensive_list_of_keyboard_shortcuts": "Более полный лист горячих клавиш находится в <0>этом шаблоне проекта __appName__</0>",
|
||||||
"about": "О",
|
"about": "О",
|
||||||
"about_to_archive_projects": "Вы собираетесь архивировать следующие проекты:",
|
"about_to_archive_projects": "Вы собираетесь архивировать следующие проекты:",
|
||||||
|
"about_to_delete_accounts": "Вы собираетесь удалить аккаунты следующих пользователей, включая все их проекты:",
|
||||||
"about_to_delete_cert": "Вы собираетесь удалить следующий сертификат:",
|
"about_to_delete_cert": "Вы собираетесь удалить следующий сертификат:",
|
||||||
"about_to_delete_projects": "Следующие проекты будут удалены:",
|
"about_to_delete_projects": "Следующие проекты будут удалены:",
|
||||||
"about_to_delete_template": "Следующий шаблон будет удален:",
|
"about_to_delete_template": "Следующий шаблон будет удален:",
|
||||||
@@ -24,6 +25,16 @@
|
|||||||
"about_to_delete_the_following_projects": "Вы собираетесь удалить следующие проекты:",
|
"about_to_delete_the_following_projects": "Вы собираетесь удалить следующие проекты:",
|
||||||
"about_to_leave_project": "Вы собираетесь покинуть этот проект:",
|
"about_to_leave_project": "Вы собираетесь покинуть этот проект:",
|
||||||
"about_to_leave_projects": "Вы собираетесь покинуть следующие проекты:",
|
"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": "Аннотация",
|
"abstract": "Аннотация",
|
||||||
"accept": "Принять",
|
"accept": "Принять",
|
||||||
"accept_and_continue": "Принять и продолжить",
|
"accept_and_continue": "Принять и продолжить",
|
||||||
@@ -31,12 +42,14 @@
|
|||||||
"accept_invitation": "Принять приглашение",
|
"accept_invitation": "Принять приглашение",
|
||||||
"accepting_invite_as": "Вы принимаете приглашение как",
|
"accepting_invite_as": "Вы принимаете приглашение как",
|
||||||
"account": "Аккаунт",
|
"account": "Аккаунт",
|
||||||
|
"account_information": "Информация об аккаунте",
|
||||||
"account_not_linked_to_dropbox": "Ваш аккаунт не синхронизирован с Dropbox",
|
"account_not_linked_to_dropbox": "Ваш аккаунт не синхронизирован с Dropbox",
|
||||||
"account_settings": "Настройки профиля",
|
"account_settings": "Настройки профиля",
|
||||||
"actions": "Действия",
|
"actions": "Действия",
|
||||||
"activate": "Активировать",
|
"activate": "Активировать",
|
||||||
"activate_account": "Активируйте Ваш аккаунт",
|
"activate_account": "Активируйте Ваш аккаунт",
|
||||||
"activating": "Активация",
|
"activating": "Активация",
|
||||||
|
"activation_link": "Ссылка для активации",
|
||||||
"activation_token_expired": "Срок действия Вашего ключа истёк. Вам необходимо запросить новый ключ активации.",
|
"activation_token_expired": "Срок действия Вашего ключа истёк. Вам необходимо запросить новый ключ активации.",
|
||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
"added": "добавлены",
|
"added": "добавлены",
|
||||||
@@ -45,6 +58,7 @@
|
|||||||
"admin": "администратор",
|
"admin": "администратор",
|
||||||
"all_projects": "Все проекты",
|
"all_projects": "Все проекты",
|
||||||
"all_templates": "Все шаблоны",
|
"all_templates": "Все шаблоны",
|
||||||
|
"all_users": "Все пользователи",
|
||||||
"already_have_sl_account": "Уже есть аккаунт __appName__?",
|
"already_have_sl_account": "Уже есть аккаунт __appName__?",
|
||||||
"and": "и",
|
"and": "и",
|
||||||
"annual": "Цена за год",
|
"annual": "Цена за год",
|
||||||
@@ -54,6 +68,7 @@
|
|||||||
"auto_complete": "Автодополнение",
|
"auto_complete": "Автодополнение",
|
||||||
"autocomplete": "Автозавершение",
|
"autocomplete": "Автозавершение",
|
||||||
"autocomplete_references": "Автодополнение ссылок (внутри блока <code>\\cite{}</code>)",
|
"autocomplete_references": "Автодополнение ссылок (внутри блока <code>\\cite{}</code>)",
|
||||||
|
"back_to_user_list": "Назад к списку пользователей",
|
||||||
"back_to_your_projects": "Назад к списку проектов",
|
"back_to_your_projects": "Назад к списку проектов",
|
||||||
"basic": "Базовый",
|
"basic": "Базовый",
|
||||||
"basic_compile_timeout_on_fast_servers": "Базовый таймаут компиляции на быстрых серверах",
|
"basic_compile_timeout_on_fast_servers": "Базовый таймаут компиляции на быстрых серверах",
|
||||||
@@ -112,6 +127,7 @@
|
|||||||
"country": "Страна",
|
"country": "Страна",
|
||||||
"coupon_code": "код купона",
|
"coupon_code": "код купона",
|
||||||
"create": "Создать",
|
"create": "Создать",
|
||||||
|
"create_account": "Создать аккаунт",
|
||||||
"create_new_subscription": "Создать новую подписку",
|
"create_new_subscription": "Создать новую подписку",
|
||||||
"create_project_in_github": "Создать проект на GitHub",
|
"create_project_in_github": "Создать проект на GitHub",
|
||||||
"creating": "Создание",
|
"creating": "Создание",
|
||||||
@@ -125,10 +141,13 @@
|
|||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"delete_account": "Удалить аккаунт",
|
"delete_account": "Удалить аккаунт",
|
||||||
"delete_account_warning_message_3": "Вы собираетесь <strong>удалить все данные Вашего аккаунта</strong>, включая все Ваши проекты и настройки. Пожалуйста, введите адрес электронной почты и пароль Вашего аккаунта в форму внизу для продолжения.",
|
"delete_account_warning_message_3": "Вы собираетесь <strong>удалить все данные Вашего аккаунта</strong>, включая все Ваши проекты и настройки. Пожалуйста, введите адрес электронной почты и пароль Вашего аккаунта в форму внизу для продолжения.",
|
||||||
|
"delete_accounts": "Удалить аккаунты",
|
||||||
"delete_and_leave_projects": "Удалить или оставить проекты",
|
"delete_and_leave_projects": "Удалить или оставить проекты",
|
||||||
"delete_projects": "Удалить проекты",
|
"delete_projects": "Удалить проекты",
|
||||||
"delete_template": "Удалить шаблон",
|
"delete_template": "Удалить шаблон",
|
||||||
"delete_your_account": "Удалить аккаунт",
|
"delete_your_account": "Удалить аккаунт",
|
||||||
|
"deleted_at": "Удалено",
|
||||||
|
"deleted_projects": "Удаленные проекты",
|
||||||
"deleting": "Удаление",
|
"deleting": "Удаление",
|
||||||
"disconnected": "Разъединен",
|
"disconnected": "Разъединен",
|
||||||
"do_you_want_to_overwrite_it": "Перезаписать?",
|
"do_you_want_to_overwrite_it": "Перезаписать?",
|
||||||
@@ -142,7 +161,8 @@
|
|||||||
"dropbox_sync_description": "Синхронизируйте Ваши __appName__ проекты с Вашим Dropbox. Изменения в __appName__ автоматически сохраняются в Вашем Dropbox, и наоборот.",
|
"dropbox_sync_description": "Синхронизируйте Ваши __appName__ проекты с Вашим Dropbox. Изменения в __appName__ автоматически сохраняются в Вашем Dropbox, и наоборот.",
|
||||||
"edit_template": "Редактировать шаблон",
|
"edit_template": "Редактировать шаблон",
|
||||||
"editing": "Редактор",
|
"editing": "Редактор",
|
||||||
"email": "Адрес электронной почты",
|
"email": "E-mail",
|
||||||
|
"email_address": "Адрес электронной почты",
|
||||||
"email_already_registered": "Этот адрес уже зарегистрирован.",
|
"email_already_registered": "Этот адрес уже зарегистрирован.",
|
||||||
"email_link_expired": "Срок действия ссылки истёк. Пожалуйста, повторите запрос!",
|
"email_link_expired": "Срок действия ссылки истёк. Пожалуйста, повторите запрос!",
|
||||||
"email_or_password_wrong_try_again": "Неверный адрес электронной почты или пароль. Пожалуйста, попробуйте снова",
|
"email_or_password_wrong_try_again": "Неверный адрес электронной почты или пароль. Пожалуйста, попробуйте снова",
|
||||||
@@ -157,6 +177,8 @@
|
|||||||
"features": "Возможности",
|
"features": "Возможности",
|
||||||
"february": "Февраль",
|
"february": "Февраль",
|
||||||
"files_cannot_include_invalid_characters": "Файлы не могут содержать символы ’*’ и ’/’",
|
"files_cannot_include_invalid_characters": "Файлы не могут содержать символы ’*’ и ’/’",
|
||||||
|
"filter_projects": "Фильтровать проекты",
|
||||||
|
"filter_users": "Фильтровать пользователей",
|
||||||
"first_name": "Имя",
|
"first_name": "Имя",
|
||||||
"folders": "Папки",
|
"folders": "Папки",
|
||||||
"footer_about_us": "О нас",
|
"footer_about_us": "О нас",
|
||||||
@@ -193,6 +215,7 @@
|
|||||||
"import_to_sharelatex": "Импортировать в __appName__",
|
"import_to_sharelatex": "Импортировать в __appName__",
|
||||||
"importing": "Импорт",
|
"importing": "Импорт",
|
||||||
"importing_and_merging_changes_in_github": "Импорт и слияние изменений в GitHub",
|
"importing_and_merging_changes_in_github": "Импорт и слияние изменений в GitHub",
|
||||||
|
"inactive_projects": "Неактивные проекты",
|
||||||
"info": "Информация",
|
"info": "Информация",
|
||||||
"institution": "Организация",
|
"institution": "Организация",
|
||||||
"invalid_file_name": "Неверное имя файла",
|
"invalid_file_name": "Неверное имя файла",
|
||||||
@@ -258,9 +281,11 @@
|
|||||||
"nearly_activated": "Вы в одном шаге от активации Вашего аккаунта для __appName__!",
|
"nearly_activated": "Вы в одном шаге от активации Вашего аккаунта для __appName__!",
|
||||||
"need_anything_contact_us_at": "Если у Вас есть какие-либо вопросы и пожелания, пожалуйста, пишите нам по адресу",
|
"need_anything_contact_us_at": "Если у Вас есть какие-либо вопросы и пожелания, пожалуйста, пишите нам по адресу",
|
||||||
"need_to_leave": "Удалить аккаунт?",
|
"need_to_leave": "Удалить аккаунт?",
|
||||||
|
"never": "Никогда",
|
||||||
"new_file": "Новый файл",
|
"new_file": "Новый файл",
|
||||||
"new_folder": "Новая папка",
|
"new_folder": "Новая папка",
|
||||||
"new_name": "Введите название",
|
"new_name": "Введите название",
|
||||||
|
"new_owner": "Новый владелец",
|
||||||
"new_password": "Новый пароль",
|
"new_password": "Новый пароль",
|
||||||
"new_project": "Создать проект",
|
"new_project": "Создать проект",
|
||||||
"next_payment_of_x_collectected_on_y": "Следующий платёж в размере <0>__paymentAmmount__</0> будет списан <1>__collectionDate__</1>",
|
"next_payment_of_x_collectected_on_y": "Следующий платёж в размере <0>__paymentAmmount__</0> будет списан <1>__collectionDate__</1>",
|
||||||
@@ -275,8 +300,10 @@
|
|||||||
"no_search_results": "Ничего не найдено",
|
"no_search_results": "Ничего не найдено",
|
||||||
"no_thanks_cancel_now": "Нет, спасибо - я хочу удалить сейчас",
|
"no_thanks_cancel_now": "Нет, спасибо - я хочу удалить сейчас",
|
||||||
"no_templates_found": "Шаблоны не найдены.",
|
"no_templates_found": "Шаблоны не найдены.",
|
||||||
|
"no_users": "Нет пользователей",
|
||||||
"normal": "нормальный",
|
"normal": "нормальный",
|
||||||
"not_now": "Не сейчас",
|
"not_now": "Не сейчас",
|
||||||
|
"notify_users_about_account_deletion": "Уведомить пользователей об удалении аккаунтов",
|
||||||
"november": "Ноябрь",
|
"november": "Ноябрь",
|
||||||
"october": "Октябрь",
|
"october": "Октябрь",
|
||||||
"off": "Откл.",
|
"off": "Откл.",
|
||||||
@@ -288,7 +315,9 @@
|
|||||||
"or": "или",
|
"or": "или",
|
||||||
"other_logs_and_files": "Другие логи и файлы",
|
"other_logs_and_files": "Другие логи и файлы",
|
||||||
"over": "свыше",
|
"over": "свыше",
|
||||||
|
"owned_projects": "Собственные проекты",
|
||||||
"owner": "Владелец",
|
"owner": "Владелец",
|
||||||
|
"ownership_of_projects_will_be_transferred": "Владение следующими проектами будет передано другому пользователю:",
|
||||||
"page_not_found": "Страница не найдена",
|
"page_not_found": "Страница не найдена",
|
||||||
"password": "Пароль",
|
"password": "Пароль",
|
||||||
"password_reset": "Сбросить пароль",
|
"password_reset": "Сбросить пароль",
|
||||||
@@ -296,6 +325,8 @@
|
|||||||
"password_reset_token_expired": "Ваш код восстановления пароля истёк. Пожалуйста, запросите восстановление пароля по почте ещё раз и перейдите по ссылке в письме.",
|
"password_reset_token_expired": "Ваш код восстановления пароля истёк. Пожалуйста, запросите восстановление пароля по почте ещё раз и перейдите по ссылке в письме.",
|
||||||
"pdf_viewer": "Просмотрщик PDF",
|
"pdf_viewer": "Просмотрщик PDF",
|
||||||
"pending": "В ожидании",
|
"pending": "В ожидании",
|
||||||
|
"permanently_delete_accounts": "Окончательно удалить аккаунты",
|
||||||
|
"permanently_delete_projects": "Окончательно удалить проекты",
|
||||||
"personal": "Личный",
|
"personal": "Личный",
|
||||||
"pl": "Польский",
|
"pl": "Польский",
|
||||||
"planned_maintenance": "Плановые работы",
|
"planned_maintenance": "Плановые работы",
|
||||||
@@ -317,6 +348,7 @@
|
|||||||
"problem_with_subscription_contact_us": "Возникли проблемы с Вашей подпиской. Пожалуйста, свяжитесь с нами, чтобы узнать подробности.",
|
"problem_with_subscription_contact_us": "Возникли проблемы с Вашей подпиской. Пожалуйста, свяжитесь с нами, чтобы узнать подробности.",
|
||||||
"processing": "обработка",
|
"processing": "обработка",
|
||||||
"professional": "Профессионал",
|
"professional": "Профессионал",
|
||||||
|
"project_categories_tags": "Категории и теги проектов",
|
||||||
"project_last_published_at": "В последний раз проект был опубликован",
|
"project_last_published_at": "В последний раз проект был опубликован",
|
||||||
"project_name": "Название проекта",
|
"project_name": "Название проекта",
|
||||||
"project_not_linked_to_github": "Этот проект не связан ни с одним проектом на GitHub. Вы можете создать для него проект на GitHub:",
|
"project_not_linked_to_github": "Этот проект не связан ни с одним проектом на GitHub. Вы можете создать для него проект на GitHub:",
|
||||||
@@ -324,12 +356,14 @@
|
|||||||
"project_too_large_please_reduce": "В этом проекте слишком много текста. Пожалуйста, попробуйте уменьшить количество.",
|
"project_too_large_please_reduce": "В этом проекте слишком много текста. Пожалуйста, попробуйте уменьшить количество.",
|
||||||
"project_url": "URL проекта",
|
"project_url": "URL проекта",
|
||||||
"projects": "Проекты",
|
"projects": "Проекты",
|
||||||
|
"projects_list": "Список проектов",
|
||||||
"pt": "Португальский",
|
"pt": "Португальский",
|
||||||
"public": "Открытый",
|
"public": "Открытый",
|
||||||
"publish": "Опубликовать",
|
"publish": "Опубликовать",
|
||||||
"publish_as_template": "Создать шаблон",
|
"publish_as_template": "Создать шаблон",
|
||||||
"publishing": "Публикация",
|
"publishing": "Публикация",
|
||||||
"pull_github_changes_into_sharelatex": "Скачать изменения с GitHub в __appName__",
|
"pull_github_changes_into_sharelatex": "Скачать изменения с GitHub в __appName__",
|
||||||
|
"purge": "Уничтожить",
|
||||||
"push_sharelatex_changes_to_github": "Загрузить изменения из __appName__ на GitHub",
|
"push_sharelatex_changes_to_github": "Загрузить изменения из __appName__ на GitHub",
|
||||||
"recent_commits_in_github": "Последние коммиты на GitHub",
|
"recent_commits_in_github": "Последние коммиты на GitHub",
|
||||||
"recompile": "Компилировать",
|
"recompile": "Компилировать",
|
||||||
@@ -352,13 +386,18 @@
|
|||||||
"republish": "Переопубликовать",
|
"republish": "Переопубликовать",
|
||||||
"request_sent_thank_you": "Спасибо, Ваш запрос отправлен!",
|
"request_sent_thank_you": "Спасибо, Ваш запрос отправлен!",
|
||||||
"required": "обязательно",
|
"required": "обязательно",
|
||||||
"resend": "Отправить еще раз",
|
"resend": "Отправить повторно",
|
||||||
|
"resend_activation_email": "Повторно отправить письмо активации",
|
||||||
"reset_password": "Сбросить пароль",
|
"reset_password": "Сбросить пароль",
|
||||||
"reset_your_password": "Сбросить пароль",
|
"reset_your_password": "Сбросить пароль",
|
||||||
"restore": "Восстановить",
|
"restore": "Восстановить",
|
||||||
|
"restore_accounts": "Восстановить учетные записи",
|
||||||
|
"restore_projects": "Восстановить проекты",
|
||||||
"restoring": "Восстановление",
|
"restoring": "Восстановление",
|
||||||
"restricted": "Доступ ограничен",
|
"restricted": "Доступ ограничен",
|
||||||
"restricted_no_permission": "Извините, у Вас недостаточно прав для просмотра данной страницы.",
|
"restricted_no_permission": "Извините, у Вас недостаточно прав для просмотра данной страницы.",
|
||||||
|
"resume": "Возобновить",
|
||||||
|
"resume_account": "Возобновить доступ к учетной записи",
|
||||||
"return_to_login_page": "Вернуться на страницу входа",
|
"return_to_login_page": "Вернуться на страницу входа",
|
||||||
"revoke_invite": "Отозвать приглашение",
|
"revoke_invite": "Отозвать приглашение",
|
||||||
"ro": "Румынский",
|
"ro": "Румынский",
|
||||||
@@ -366,23 +405,41 @@
|
|||||||
"ru": "Русский",
|
"ru": "Русский",
|
||||||
"saving": "Сохранение",
|
"saving": "Сохранение",
|
||||||
"saving_notification_with_seconds": "Сохранение __docname__... (__seconds__ секунд с последнего сохранения)",
|
"saving_notification_with_seconds": "Сохранение __docname__... (__seconds__ секунд с последнего сохранения)",
|
||||||
|
"search": "Поиск",
|
||||||
"search_bib_files": "Поиск по автору, названию, году",
|
"search_bib_files": "Поиск по автору, названию, году",
|
||||||
"search_projects": "Поиск по проектам",
|
"search_projects": "Поиск по проектам",
|
||||||
"search_references": "Поиск .bib файлов в проекте",
|
"search_references": "Поиск .bib файлов в проекте",
|
||||||
"security": "Безопасность",
|
"security": "Безопасность",
|
||||||
|
"select_a_new_owner_for_projects": "Выберите нового владельца для проектов этого пользователя",
|
||||||
|
"select_all_projects": "Выбрать все проекты",
|
||||||
|
"select_all_users": "Выбрать всех пользователей",
|
||||||
"select_github_repository": "Выберите проект на GitHub для импорта в __appName__",
|
"select_github_repository": "Выберите проект на GitHub для импорта в __appName__",
|
||||||
|
"select_project": "Выбрать __project__",
|
||||||
|
"select_projects": "Выбрать проекты",
|
||||||
|
"select_user": "Выбрать пользователя",
|
||||||
|
"select_users": "Выбрать пользователей",
|
||||||
|
"send_notification_emails_to_users": "Отправить уведовление текущему и новому владельцам",
|
||||||
"september": "Сентябрь",
|
"september": "Сентябрь",
|
||||||
"server_error": "Ошибка сервера",
|
"server_error": "Ошибка сервера",
|
||||||
"services": "Сервисы",
|
"services": "Сервисы",
|
||||||
"session_created_at": "Сессия создана",
|
"session_created_at": "Сессия создана",
|
||||||
"session_expired_redirecting_to_login": "Срок сессии истёк. Перенаправление на страницу входа через __seconds__ секунд(ы)",
|
"session_expired_redirecting_to_login": "Срок сессии истёк. Перенаправление на страницу входа через __seconds__ секунд(ы)",
|
||||||
"sessions": "Сессии",
|
"sessions": "Сессии",
|
||||||
|
"set_admin": "Сделать админом",
|
||||||
|
"set_admin_account": "Предоставить права администратора",
|
||||||
"set_new_password": "Введите новый пароль",
|
"set_new_password": "Введите новый пароль",
|
||||||
"set_password": "Установить пароль",
|
"set_password": "Установить пароль",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"share": "Открыть доступ",
|
"share": "Открыть доступ",
|
||||||
"share_project": "Открыть доступ к проекту",
|
"share_project": "Открыть доступ к проекту",
|
||||||
"shared_with_you": "Доступные мне",
|
"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 и многое другое.",
|
"site_description": "Простой в использовании онлайн редактор LaTeX. Не требует установки, поддерживает совместную работу в реальном времени, контроль версий, сотни шаблонов LaTeX и многое другое.",
|
||||||
"somthing_went_wrong_compiling": "К сожалению, что-то пошло не так и мы не смогли скомпИлировать Ваш проект. Попробуйте еще раз через пару минут.",
|
"somthing_went_wrong_compiling": "К сожалению, что-то пошло не так и мы не смогли скомпИлировать Ваш проект. Попробуйте еще раз через пару минут.",
|
||||||
"source": "Исходный код",
|
"source": "Исходный код",
|
||||||
@@ -399,6 +456,8 @@
|
|||||||
"sure_you_want_to_change_plan": "Вы уверены, что хотите сменить тарифный план на <0>__planName__</0>?",
|
"sure_you_want_to_change_plan": "Вы уверены, что хотите сменить тарифный план на <0>__planName__</0>?",
|
||||||
"sure_you_want_to_delete": "Вы уверены, что хотите перманентно удалить следующие файлы?",
|
"sure_you_want_to_delete": "Вы уверены, что хотите перманентно удалить следующие файлы?",
|
||||||
"sure_you_want_to_leave_group": "Вы уверены, что хотите покинуть группу?",
|
"sure_you_want_to_leave_group": "Вы уверены, что хотите покинуть группу?",
|
||||||
|
"suspend": "Заблокировать",
|
||||||
|
"suspend_account": "Заблокировать учетную запись",
|
||||||
"sv": "Шведский",
|
"sv": "Шведский",
|
||||||
"sync": "Синхронизация",
|
"sync": "Синхронизация",
|
||||||
"sync_project_to_github_explanation": "Все изменения, сделанные Вами в __appName__ будут интегрированы (commit и merge) со всеми обновлениями на GitHub.",
|
"sync_project_to_github_explanation": "Все изменения, сделанные Вами в __appName__ будут интегрированы (commit и merge) со всеми обновлениями на GitHub.",
|
||||||
@@ -418,6 +477,8 @@
|
|||||||
"thanks_settings_updated": "Спасибо, изменения сохранены",
|
"thanks_settings_updated": "Спасибо, изменения сохранены",
|
||||||
"theme": "Тема",
|
"theme": "Тема",
|
||||||
"thesis": "Диссертация",
|
"thesis": "Диссертация",
|
||||||
|
"this_action_can_be_undone_within_limited_period": "Эта операция может быть отменена только в течение ограниченного периода.",
|
||||||
|
"this_action_cannot_be_undone": "Эту операцию нельзя отменить.",
|
||||||
"this_is_your_template": "Это шаблон из Вашего проекта",
|
"this_is_your_template": "Это шаблон из Вашего проекта",
|
||||||
"this_project_is_public": "Это открытый проект. Он может быть изменен любым человеком, знающим адрес (URL)",
|
"this_project_is_public": "Это открытый проект. Он может быть изменен любым человеком, знающим адрес (URL)",
|
||||||
"this_project_is_public_read_only": "Этот проект открыт для всех, у кого есть ссылка (но без возможности редактирования)",
|
"this_project_is_public_read_only": "Этот проект открыт для всех, у кого есть ссылка (но без возможности редактирования)",
|
||||||
@@ -431,6 +492,11 @@
|
|||||||
"too_recently_compiled": "Этот проект был скомпилирован совсем недавно, поэтому компиляция была пропущена.",
|
"too_recently_compiled": "Этот проект был скомпилирован совсем недавно, поэтому компиляция была пропущена.",
|
||||||
"total_words": "Количество слов",
|
"total_words": "Количество слов",
|
||||||
"tr": "Турецкий",
|
"tr": "Турецкий",
|
||||||
|
"transfer_all_projects_to": "Передать все проекты пользователей новому владельцу:",
|
||||||
|
"trash_projects": "Переместить проекты в корзину",
|
||||||
|
"trashed_projects": "Проекты в корзине",
|
||||||
|
"trashing_projects_wont_affect_collaborators": "Перемещение проекта в корзину не повлияет на других разработчиков проекта.",
|
||||||
|
"trashing_projects_wont_affect_user_collaborators": "Перемещение проекта в корзину не повлияет на других разработчиков проекта.",
|
||||||
"try_now": "Попробуйте",
|
"try_now": "Попробуйте",
|
||||||
"try_recompile_project": "Попробуйте скомпилировать проект заново.",
|
"try_recompile_project": "Попробуйте скомпилировать проект заново.",
|
||||||
"uk": "Украинский",
|
"uk": "Украинский",
|
||||||
@@ -442,6 +508,8 @@
|
|||||||
"unlink_github_warning": "Все проекты, которые Вы синхронизировали с GitHub, будут отсоединены и больше не будут синхронизироваться с GitHub. Вы уверены, что хотите отсоединить Ваш GitHub аккаунт?",
|
"unlink_github_warning": "Все проекты, которые Вы синхронизировали с GitHub, будут отсоединены и больше не будут синхронизироваться с GitHub. Вы уверены, что хотите отсоединить Ваш GitHub аккаунт?",
|
||||||
"unpublish": "Отменить публикацию",
|
"unpublish": "Отменить публикацию",
|
||||||
"unpublishing": "Отмена публикации",
|
"unpublishing": "Отмена публикации",
|
||||||
|
"unset_admin": "Убрать админина",
|
||||||
|
"unset_admin_account": "Отозвать права администратора",
|
||||||
"unsubscribe": "Отменить подписку",
|
"unsubscribe": "Отменить подписку",
|
||||||
"unsubscribed": "Не подписан",
|
"unsubscribed": "Не подписан",
|
||||||
"unsubscribing": "Отмена подписки",
|
"unsubscribing": "Отмена подписки",
|
||||||
@@ -456,7 +524,19 @@
|
|||||||
"upload_file": "Загрузить файл",
|
"upload_file": "Загрузить файл",
|
||||||
"upload_project": "Загрузить проект",
|
"upload_project": "Загрузить проект",
|
||||||
"upload_zipped_project": "Загрузить архив проекта (*.zip)",
|
"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__",
|
"user_wants_you_to_see_project": "__username__ приглашает вас к просмотру проекта __projectname__",
|
||||||
|
"users_list": "Список пользователей",
|
||||||
"vat_number": "Номер плательщика НДС",
|
"vat_number": "Номер плательщика НДС",
|
||||||
"view_all": "Показать все",
|
"view_all": "Показать все",
|
||||||
"view_in_template_gallery": "Посмотреть в галерее шаблонов",
|
"view_in_template_gallery": "Посмотреть в галерее шаблонов",
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
570
services/web/modules/admin-tools/app/src/UserListController.mjs
Normal file
570
services/web/modules/admin-tools/app/src/UserListController.mjs
Normal file
@@ -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),
|
||||||
|
}
|
||||||
1
services/web/modules/admin-tools/app/src/tsconfig.json
Normal file
1
services/web/modules/admin-tools/app/src/tsconfig.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "extends": "../../../../tsconfig.backend.json" }
|
||||||
75
services/web/modules/admin-tools/app/views/activate.pug
Normal file
75
services/web/modules/admin-tools/app/views/activate.pug
Normal file
@@ -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')}…
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 (
|
||||||
|
<SplitTestProvider>
|
||||||
|
<UserSettingsProvider>
|
||||||
|
<UserListProvider>
|
||||||
|
<ProjectListProvider projectsOwnerId={null}>
|
||||||
|
<ProjectListRoot />
|
||||||
|
</ProjectListProvider>
|
||||||
|
</UserListProvider>
|
||||||
|
</UserSettingsProvider>
|
||||||
|
</SplitTestProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withErrorBoundary(ManageProjectsRoot, () => (
|
||||||
|
<GenericErrorBoundaryFallback />
|
||||||
|
))
|
||||||
|
|
||||||
@@ -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 (
|
||||||
|
<ProjectListProvider projectsOwnerId={page.userId}>
|
||||||
|
<ProjectListRoot />
|
||||||
|
</ProjectListProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <UserListRoot />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ManageUsersRoot() {
|
||||||
|
const { isReady } = useWaitForI18n()
|
||||||
|
|
||||||
|
if (!isReady) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SplitTestProvider>
|
||||||
|
<UserSettingsProvider>
|
||||||
|
<UsersPageProvider>
|
||||||
|
<UserListProvider>
|
||||||
|
<UsersPageSelector />
|
||||||
|
</UserListProvider>
|
||||||
|
</UsersPageProvider>
|
||||||
|
</UserSettingsProvider>
|
||||||
|
</SplitTestProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withErrorBoundary(ManageUsersRoot, () => (
|
||||||
|
<GenericErrorBoundaryFallback />
|
||||||
|
))
|
||||||
@@ -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(<ManageProjectsRoot />)
|
||||||
|
}
|
||||||
@@ -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(<ManageUsersRoot />)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Dropdown align="end">
|
||||||
|
<DropdownToggle
|
||||||
|
id={`project-actions-dropdown-toggle-btn-${project.id}`}
|
||||||
|
bsPrefix="dropdown-table-button-toggle"
|
||||||
|
>
|
||||||
|
<MaterialIcon type="more_vert" accessibilityLabel={t('actions')} />
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu flip={false}>
|
||||||
|
<DownloadProjectButton project={project}>
|
||||||
|
{(text, downloadProject) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={downloadProject}
|
||||||
|
leadingIcon="download"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</DownloadProjectButton>
|
||||||
|
<TransferProjectButton project={project}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
leadingIcon="swap_horiz"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</TransferProjectButton>
|
||||||
|
<TrashProjectButton project={project}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
leadingIcon="delete"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</TrashProjectButton>
|
||||||
|
<UntrashProjectButton project={project}>
|
||||||
|
{(text, untrashProject) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={untrashProject}
|
||||||
|
leadingIcon="restore_page"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</UntrashProjectButton>
|
||||||
|
<DeleteProjectButton project={project}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
leadingIcon="block"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</DeleteProjectButton>
|
||||||
|
<RestoreProjectButton project={project}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
leadingIcon="restore"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</RestoreProjectButton>
|
||||||
|
<PurgeProjectButton project={project}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
leadingIcon="delete_forever"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</PurgeProjectButton>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ActionsDropdown
|
||||||
@@ -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 (
|
||||||
|
<ProjectsFilterMenu filter={filter}>
|
||||||
|
{isActive => (
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleClick}
|
||||||
|
trailingIcon={isActive ? 'check' : undefined}
|
||||||
|
active={isActive}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
</ProjectsFilterMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectsDropdown() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [title, setTitle] = useState(() => t('all_projects'))
|
||||||
|
const { filter } = useProjectListContext()
|
||||||
|
const filterTranslations = useRef<Record<Filter, string>>({
|
||||||
|
owned: t('all_projects'),
|
||||||
|
inactive: t('inactive_projects'),
|
||||||
|
trashed: t('trashed_projects'),
|
||||||
|
deleted: t('deleted_projects'),
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle(filterTranslations.current[filter])
|
||||||
|
}, [filter, t])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownToggle
|
||||||
|
id="projects-types-dropdown-toggle-btn"
|
||||||
|
className="ps-0 mb-0 btn-transparent h4"
|
||||||
|
size="lg"
|
||||||
|
aria-label={t('filter_projects')}
|
||||||
|
>
|
||||||
|
<span className="text-truncate" aria-hidden>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu flip={false}>
|
||||||
|
<li role="none">
|
||||||
|
<Item filter="owned" text={t('all_projects')} />
|
||||||
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<Item filter="inactive" text={t('inactive_projects')} />
|
||||||
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<Item filter="trashed" text={t('trashed_projects')} />
|
||||||
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<Item filter="deleted" text={t('deleted_projects')} />
|
||||||
|
</li>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectsDropdown
|
||||||
@@ -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 (
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={onClick}
|
||||||
|
trailingIcon={iconType}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Record<Sort['by'], string>>({
|
||||||
|
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 (
|
||||||
|
<Dropdown className="projects-sort-dropdown" align="end">
|
||||||
|
<DropdownToggle
|
||||||
|
id="projects-sort-dropdown"
|
||||||
|
className="pe-0 mb-0 btn-transparent"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t('sort_projects')}
|
||||||
|
>
|
||||||
|
<span className="text-truncate" aria-hidden>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu flip={false}>
|
||||||
|
<DropdownHeader className="text-uppercase">
|
||||||
|
{t('sort_by')}:
|
||||||
|
</DropdownHeader>
|
||||||
|
<ItemWithContent
|
||||||
|
column="title"
|
||||||
|
text={t('title')}
|
||||||
|
sort={sort}
|
||||||
|
onClick={() => handleClick('title')}
|
||||||
|
/>
|
||||||
|
<ItemWithContent
|
||||||
|
column="owner"
|
||||||
|
text={t('owner')}
|
||||||
|
sort={sort}
|
||||||
|
onClick={() => handleClick('owner')}
|
||||||
|
/>
|
||||||
|
{ filter !== 'deleted' ? (
|
||||||
|
<ItemWithContent
|
||||||
|
column="lastUpdated"
|
||||||
|
text={t('last_modified')}
|
||||||
|
sort={sort}
|
||||||
|
onClick={() => handleClick('lastUpdated')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ItemWithContent
|
||||||
|
column="deletedAt"
|
||||||
|
text={t('deleted_at')}
|
||||||
|
sort={sort}
|
||||||
|
onClick={() => handleClick('deletedAt')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SortByDropdown
|
||||||
@@ -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 (
|
||||||
|
<div className="text-center">
|
||||||
|
{hiddenProjectsCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<OLButton
|
||||||
|
variant="secondary"
|
||||||
|
className="project-list-load-more-button"
|
||||||
|
onClick={() => loadMoreProjects()}
|
||||||
|
>
|
||||||
|
{t('show_x_more_projects', { x: loadMoreCount })}
|
||||||
|
</OLButton>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<p>
|
||||||
|
{hiddenProjectsCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<span aria-live="polite">
|
||||||
|
{t('showing_x_out_of_n_projects', {
|
||||||
|
x: visibleProjects.length,
|
||||||
|
n: visibleProjects.length + hiddenProjectsCount,
|
||||||
|
})}
|
||||||
|
</span>{' '}
|
||||||
|
<OLButton
|
||||||
|
variant="link"
|
||||||
|
onClick={() => showAllProjects()}
|
||||||
|
className="btn-inline-link"
|
||||||
|
>
|
||||||
|
{t('show_all_projects')}
|
||||||
|
</OLButton>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span aria-live="polite">
|
||||||
|
{t('showing_x_out_of_n_projects', {
|
||||||
|
x: visibleProjects.length,
|
||||||
|
n: visibleProjects.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<typeof ProjectsActionModal>,
|
||||||
|
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||||
|
>
|
||||||
|
|
||||||
|
function DeleteProjectModal({
|
||||||
|
projects,
|
||||||
|
actionHandler,
|
||||||
|
showModal,
|
||||||
|
handleCloseModal,
|
||||||
|
}: DeleteProjectModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showModal) {
|
||||||
|
setProjectsToDisplay(displayProjects => {
|
||||||
|
return displayProjects.length ? displayProjects : projects
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setProjectsToDisplay([])
|
||||||
|
}
|
||||||
|
}, [showModal, projects])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectsActionModal
|
||||||
|
action="delete"
|
||||||
|
actionHandler={actionHandler}
|
||||||
|
title={t('delete_projects')}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
projects={projects}
|
||||||
|
>
|
||||||
|
<p>{t('about_to_delete_projects')}</p>
|
||||||
|
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
|
||||||
|
<Notification
|
||||||
|
content={t('this_action_can_be_undone_within_limited_period')}
|
||||||
|
type="warning"
|
||||||
|
/>
|
||||||
|
</ProjectsActionModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeleteProjectModal
|
||||||
@@ -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<void>
|
||||||
|
handleCloseModal: () => void
|
||||||
|
projects: Array<Project>
|
||||||
|
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<Array<any>>([])
|
||||||
|
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<Project>, 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 (
|
||||||
|
<OLModal
|
||||||
|
animation
|
||||||
|
show={showModal}
|
||||||
|
onHide={handleCloseModal}
|
||||||
|
id="admin-action-project-modal"
|
||||||
|
backdrop="static"
|
||||||
|
>
|
||||||
|
<OLModalHeader>
|
||||||
|
<OLModalTitle>{title}</OLModalTitle>
|
||||||
|
</OLModalHeader>
|
||||||
|
<OLModalBody>
|
||||||
|
{children}
|
||||||
|
{!isProcessing &&
|
||||||
|
errors.length > 0 &&
|
||||||
|
errors.map((error, i) => (
|
||||||
|
<div className="notification-list" key={i}>
|
||||||
|
<Notification
|
||||||
|
type="error"
|
||||||
|
title={error.projectName}
|
||||||
|
content={getUserFacingMessage(error.error) as string}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</OLModalBody>
|
||||||
|
<OLModalFooter>
|
||||||
|
<OLButton variant="secondary" onClick={handleCloseModal}>
|
||||||
|
{t('cancel')}
|
||||||
|
</OLButton>
|
||||||
|
<OLButton
|
||||||
|
variant={variant}
|
||||||
|
onClick={() => handleActionForProjects(projects, options)}
|
||||||
|
disabled={isProcessing}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</OLButton>
|
||||||
|
</OLModalFooter>
|
||||||
|
</OLModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ProjectsActionModal)
|
||||||
@@ -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 (
|
||||||
|
<ul>
|
||||||
|
{projectsToDisplay.map(project => (
|
||||||
|
<li
|
||||||
|
key={`projects-action-list-${project.id}`}
|
||||||
|
className={classnames({
|
||||||
|
'list-style-check-green': !projects.some(
|
||||||
|
({ id }) => id === project.id
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<b>{project.name}</b>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectsList
|
||||||
@@ -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<typeof ProjectsActionModal>,
|
||||||
|
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||||
|
>
|
||||||
|
|
||||||
|
function PurgeProjectModal({
|
||||||
|
projects,
|
||||||
|
actionHandler,
|
||||||
|
showModal,
|
||||||
|
handleCloseModal,
|
||||||
|
}: PurgeProjectModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showModal) {
|
||||||
|
setProjectsToDisplay(displayProjects => {
|
||||||
|
return displayProjects.length ? displayProjects : projects
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setProjectsToDisplay([])
|
||||||
|
}
|
||||||
|
}, [showModal, projects])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectsActionModal
|
||||||
|
action="purge"
|
||||||
|
actionHandler={actionHandler}
|
||||||
|
title={t('permanently_delete_projects')}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
projects={projects}
|
||||||
|
>
|
||||||
|
<p>{t('about_to_permanently_delete_projects')}</p>
|
||||||
|
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
|
||||||
|
<Notification
|
||||||
|
content={t('this_action_cannot_be_undone')}
|
||||||
|
type="warning"
|
||||||
|
/>
|
||||||
|
</ProjectsActionModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PurgeProjectModal
|
||||||
@@ -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<typeof ProjectsActionModal>,
|
||||||
|
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||||
|
>
|
||||||
|
|
||||||
|
function RestoreProjectModal({
|
||||||
|
projects,
|
||||||
|
actionHandler,
|
||||||
|
showModal,
|
||||||
|
handleCloseModal,
|
||||||
|
}: RestoreProjectModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showModal) {
|
||||||
|
setProjectsToDisplay(displayProjects => {
|
||||||
|
return displayProjects.length ? displayProjects : projects
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setProjectsToDisplay([])
|
||||||
|
}
|
||||||
|
}, [showModal, projects])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectsActionModal
|
||||||
|
action="restore"
|
||||||
|
actionHandler={actionHandler}
|
||||||
|
title={t('restore_projects')}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
projects={projects}
|
||||||
|
>
|
||||||
|
<p>{t('about_to_restore_projects')}</p>
|
||||||
|
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
|
||||||
|
</ProjectsActionModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RestoreProjectModal
|
||||||
@@ -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<typeof ProjectsActionModal>,
|
||||||
|
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||||
|
>
|
||||||
|
|
||||||
|
function TransferProjectModal({
|
||||||
|
projects,
|
||||||
|
actionHandler,
|
||||||
|
showModal,
|
||||||
|
handleCloseModal,
|
||||||
|
}: TransferProjectModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
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<UserRef | null>(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<HTMLInputElement>) => {
|
||||||
|
setSendEmails(e.currentTarget.checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectsActionModal
|
||||||
|
action="transfer"
|
||||||
|
actionHandler={actionHandler}
|
||||||
|
title={t('change_project_owner')}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
projects={projects}
|
||||||
|
options={options}
|
||||||
|
>
|
||||||
|
<p>{t('ownership_of_projects_will_be_transferred')}</p>
|
||||||
|
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
|
||||||
|
|
||||||
|
<OLForm className="add-collabs">
|
||||||
|
<OLFormGroup>
|
||||||
|
<SelectOwnerForm
|
||||||
|
loading={!potentialOwners}
|
||||||
|
users={potentialOwners || []}
|
||||||
|
value={newOwner}
|
||||||
|
onChange={setNewOwner}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
|
||||||
|
<OLFormGroup controlId="send_notification_emails_checkbox">
|
||||||
|
<OLFormCheckbox
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={handleCheckboxChange}
|
||||||
|
name="sendEmails"
|
||||||
|
label={t('send_notification_emails_to_users')}
|
||||||
|
checked={sendEmails}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
</OLForm>
|
||||||
|
</ProjectsActionModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TransferProjectModal
|
||||||
@@ -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<typeof ProjectsActionModal>,
|
||||||
|
'projects' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||||
|
>
|
||||||
|
|
||||||
|
function TrashProjectModal({
|
||||||
|
projects,
|
||||||
|
actionHandler,
|
||||||
|
showModal,
|
||||||
|
handleCloseModal,
|
||||||
|
}: TrashProjectPropsModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [projectsToDisplay, setProjectsToDisplay] = useState<typeof projects>(
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showModal) {
|
||||||
|
setProjectsToDisplay(displayProjects => {
|
||||||
|
return displayProjects.length ? displayProjects : projects
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setProjectsToDisplay([])
|
||||||
|
}
|
||||||
|
}, [showModal, projects])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectsActionModal
|
||||||
|
action="trash"
|
||||||
|
actionHandler={actionHandler}
|
||||||
|
title={t('trash_projects')}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
projects={projects}
|
||||||
|
>
|
||||||
|
<p>{t('about_to_trash_projects')}</p>
|
||||||
|
<ProjectsList projects={projects} projectsToDisplay={projectsToDisplay} />
|
||||||
|
<p>
|
||||||
|
{t('trashing_projects_wont_affect_user_collaborators')}
|
||||||
|
</p>
|
||||||
|
</ProjectsActionModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrashProjectModal
|
||||||
@@ -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 = (
|
||||||
|
<div className="pt-2 pb-3 d-md-none d-flex gap-3">
|
||||||
|
<div className="pt-1 fs-5 fw-bold" translate="no">
|
||||||
|
{userName}
|
||||||
|
</div>
|
||||||
|
<SearchForm
|
||||||
|
inputValue={searchText}
|
||||||
|
setInputValue={setSearchText}
|
||||||
|
className="overflow-hidden flex-grow-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="project-ds-nav-page website-redesign">
|
||||||
|
<DefaultNavbar
|
||||||
|
{...navbarProps}
|
||||||
|
overleafLogo={overleafLogo}
|
||||||
|
showCloseIcon
|
||||||
|
/>
|
||||||
|
<div className="project-list-wrapper">
|
||||||
|
<SidebarDsNav />
|
||||||
|
<div className="project-ds-nav-content-and-messages">
|
||||||
|
<div className="project-ds-nav-content">
|
||||||
|
<div className="project-ds-nav-main">
|
||||||
|
{error ? <DashApiError /> : ''}
|
||||||
|
<main aria-labelledby="main-content">
|
||||||
|
<div className="project-list-header-row position-relative">
|
||||||
|
<ProjectListTitle
|
||||||
|
filter={filter}
|
||||||
|
className="text-truncate d-none d-md-block"
|
||||||
|
/>
|
||||||
|
<div className="project-tools">
|
||||||
|
<div className="d-none d-md-block">
|
||||||
|
{selectedProjects.length !== 0 && <ProjectTools />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="project-ds-nav-project-list">
|
||||||
|
<OLRow className="d-none d-md-block">
|
||||||
|
<OLCol lg={7}>
|
||||||
|
<SearchForm
|
||||||
|
inputValue={searchText}
|
||||||
|
setInputValue={setSearchText}
|
||||||
|
/>
|
||||||
|
</OLCol>
|
||||||
|
</OLRow>
|
||||||
|
<div className="mt-1 d-md-none">
|
||||||
|
<div
|
||||||
|
role="toolbar"
|
||||||
|
className="projects-toolbar"
|
||||||
|
aria-label={t('projects')}
|
||||||
|
>
|
||||||
|
<ProjectsDropdown />
|
||||||
|
<SortByDropdown />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<TableContainer bordered>
|
||||||
|
{tableTopArea}
|
||||||
|
<ProjectListTable />
|
||||||
|
</TableContainer>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<LoadMore />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<Footer {...footerProps} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<LoadingBranded loadProgress={loadProgress} label={t('loading')} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DsNavStyleProvider>
|
||||||
|
<ProjectListDsNav />
|
||||||
|
</DsNavStyleProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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<typeof OLForm>,
|
||||||
|
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 (
|
||||||
|
<OLForm
|
||||||
|
className={classnames('project-search', className)}
|
||||||
|
role="search"
|
||||||
|
onSubmit={e => e.preventDefault()}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<OLFormGroup>
|
||||||
|
<OLCol>
|
||||||
|
<OLFormControl
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
aria-label={placeholder}
|
||||||
|
prepend={<MaterialIcon type="search" />}
|
||||||
|
append={
|
||||||
|
inputValue.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="form-control-search-clear-btn"
|
||||||
|
aria-label={t('clear_search')}
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
<MaterialIcon type="clear" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</OLCol>
|
||||||
|
</OLFormGroup>
|
||||||
|
</OLForm>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchForm
|
||||||
@@ -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<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!forwardedRef) return
|
||||||
|
|
||||||
|
if (typeof forwardedRef === 'function') {
|
||||||
|
forwardedRef(inputRef.current)
|
||||||
|
} else {
|
||||||
|
forwardedRef.current = inputRef.current
|
||||||
|
}
|
||||||
|
}, [forwardedRef])
|
||||||
|
|
||||||
|
const lastSelectedRef = useRef<UserRef | null>(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<UserRef>({
|
||||||
|
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 (
|
||||||
|
<div className="tags-input tags-new">
|
||||||
|
<OLFormLabel className="small" {...getLabelProps()}>
|
||||||
|
{t('new_owner')}
|
||||||
|
{loading && <OLSpinner size="sm" className="ms-2" />}
|
||||||
|
</OLFormLabel>
|
||||||
|
|
||||||
|
<div className="host">
|
||||||
|
<div
|
||||||
|
className="tags form-control d-flex align-items-center gap-1"
|
||||||
|
onClick={focusInput}
|
||||||
|
>
|
||||||
|
{value && <MaterialIcon type="person" />}
|
||||||
|
|
||||||
|
<input
|
||||||
|
{...getInputProps({
|
||||||
|
ref: inputRef,
|
||||||
|
className: 'input',
|
||||||
|
size: inputValue.length ? inputValue.length + 5 : 5,
|
||||||
|
type: 'email',
|
||||||
|
placeholder: t('select_a_new_owner_for_projects'),
|
||||||
|
onBlur: () => {
|
||||||
|
const last = lastSelectedRef.current
|
||||||
|
if (last) {
|
||||||
|
setInputValue(getDisplayName(last))
|
||||||
|
} else {
|
||||||
|
setInputValue('')
|
||||||
|
onChange(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onKeyDown: e => {
|
||||||
|
if (e.key === 'Enter' && highlightedIndex === -1) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
{...getMenuProps()}
|
||||||
|
className={classnames(
|
||||||
|
'dropdown-menu select-dropdown-menu w-100',
|
||||||
|
{ show: isOpen }
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isOpen && filteredOptions.length === 0 && (
|
||||||
|
<li className="dropdown-item text-muted">
|
||||||
|
{t('No results')}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOpen &&
|
||||||
|
filteredOptions.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
{...getItemProps({ item, index })}
|
||||||
|
>
|
||||||
|
<DropdownItem
|
||||||
|
as="span"
|
||||||
|
role={undefined}
|
||||||
|
leadingIcon="person"
|
||||||
|
className={classnames({
|
||||||
|
active: index === highlightedIndex,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{getDisplayName(item)}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default SelectOwnerForm
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="project-list-sidebar-wrapper-react d-none d-md-flex"
|
||||||
|
{...getTargetProps({
|
||||||
|
style: {
|
||||||
|
...(mousePos?.x && { flexBasis: `${mousePos.x}px` }),
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
className="flex-grow flex-shrink"
|
||||||
|
aria-label={t('project_categories_tags')}
|
||||||
|
>
|
||||||
|
<div className="ps-3 fs-5 fw-bold" translate="no">
|
||||||
|
{ownerName}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="project-list-sidebar-scroll"
|
||||||
|
ref={containerRef}
|
||||||
|
data-testid="project-list-sidebar-scroll"
|
||||||
|
>
|
||||||
|
<SidebarFilters />
|
||||||
|
{projectsOwnerId && (
|
||||||
|
<ul className="list-unstyled project-list-filters">
|
||||||
|
<li>
|
||||||
|
<button type="button" onClick={handleBack}>
|
||||||
|
{t('back_to_user_list')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li aria-hidden="true">
|
||||||
|
<hr />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div
|
||||||
|
className={classnames(
|
||||||
|
'ds-nav-sidebar-lower',
|
||||||
|
scrolledUp && 'show-shadow'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
className="d-flex flex-row gap-3 mb-2"
|
||||||
|
aria-label={t('account_help')}
|
||||||
|
>
|
||||||
|
{sessionUser && (
|
||||||
|
<>
|
||||||
|
<Dropdown
|
||||||
|
className="ds-nav-icon-dropdown"
|
||||||
|
onToggle={show => {
|
||||||
|
setShowAccountDropdown(show)
|
||||||
|
if (show) {
|
||||||
|
sendMB('menu-expand', {
|
||||||
|
item: 'account',
|
||||||
|
location: 'sidebar',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<Dropdown.Toggle role="menuitem" aria-label={t('Account')}>
|
||||||
|
<OLTooltip
|
||||||
|
description={t('Account')}
|
||||||
|
id="open-account"
|
||||||
|
overlayProps={{
|
||||||
|
placement: 'top',
|
||||||
|
}}
|
||||||
|
hidden={showAccountDropdown}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<UserIcon size={24} />
|
||||||
|
</div>
|
||||||
|
</OLTooltip>
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu
|
||||||
|
as="ul"
|
||||||
|
role="menu"
|
||||||
|
align="end"
|
||||||
|
popperConfig={{
|
||||||
|
modifiers: [
|
||||||
|
{ name: 'offset', options: { offset: [-50, 5] } },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccountMenuItems
|
||||||
|
sessionUser={sessionUser}
|
||||||
|
showSubscriptionLink={false}
|
||||||
|
showThemeToggle={themedDsNav}
|
||||||
|
/>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
<div className="ds-nav-ds-name" translate="no">
|
||||||
|
<span>Extended CE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
{...getHandleProps({
|
||||||
|
style: {
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: 1,
|
||||||
|
top: 0,
|
||||||
|
right: '-2px',
|
||||||
|
height: '100%',
|
||||||
|
width: '4px',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SidebarDsNav
|
||||||
@@ -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 (
|
||||||
|
<ProjectsFilterMenu filter={filter}>
|
||||||
|
{isActive => (
|
||||||
|
<li className={isActive ? 'active' : ''}>
|
||||||
|
<button type="button" onClick={() => selectFilter(filter)}>
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ProjectsFilterMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SidebarFilters() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="list-unstyled project-list-filters">
|
||||||
|
<SidebarFilter filter="owned" text={t('all_projects')} />
|
||||||
|
<SidebarFilter filter="inactive" text={t('inactive_projects')} />
|
||||||
|
<SidebarFilter filter="trashed" text={t('trashed_projects')} />
|
||||||
|
<SidebarFilter filter="deleted" text={t('deleted_projects')} />
|
||||||
|
<li aria-hidden="true">
|
||||||
|
<hr />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<T extends SortBtnOwnProps>(
|
||||||
|
WrappedComponent: React.ComponentType<T & WithContentProps>
|
||||||
|
) {
|
||||||
|
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 (
|
||||||
|
<WrappedComponent
|
||||||
|
{...hocProps}
|
||||||
|
iconType={iconType}
|
||||||
|
screenReaderText={screenReaderText}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return WithContent
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withContent
|
||||||
@@ -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)}
|
||||||
|
<DeleteProjectModal
|
||||||
|
projects={[project]}
|
||||||
|
actionHandler={handleDeleteProject}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteProjectButtonTooltip = memo(function DeleteProjectButtonTooltip({
|
||||||
|
project,
|
||||||
|
}: Pick<DeleteProjectButtonProps, 'project'>) {
|
||||||
|
return (
|
||||||
|
<DeleteProjectButton project={project}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<OLTooltip
|
||||||
|
key={`tooltip-delete-project-${project.id}`}
|
||||||
|
id={`delete-project-${project.id}`}
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
variant="link"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
className="action-btn"
|
||||||
|
icon="block"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
)}
|
||||||
|
</DeleteProjectButton>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default memo(DeleteProjectButton)
|
||||||
|
export { DeleteProjectButtonTooltip }
|
||||||
@@ -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<DownloadProjectButtonProps, 'project'>) {
|
||||||
|
return (
|
||||||
|
<DownloadProjectButton project={project}>
|
||||||
|
{(text, downloadProject) => (
|
||||||
|
<OLTooltip
|
||||||
|
key={`tooltip-download-project-${project.id}`}
|
||||||
|
id={`download-project-${project.id}`}
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={downloadProject}
|
||||||
|
variant="link"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
className="action-btn"
|
||||||
|
icon="download"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
)}
|
||||||
|
</DownloadProjectButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default memo(DownloadProjectButton)
|
||||||
|
export { DownloadProjectButtonTooltip }
|
||||||
@@ -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)}
|
||||||
|
<PurgeProjectModal
|
||||||
|
projects={[project]}
|
||||||
|
actionHandler={handlePurgeProject}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PurgeProjectButtonTooltip = memo(function PurgeProjectButtonTooltip({
|
||||||
|
project,
|
||||||
|
}: Pick<PurgeProjectButtonProps, 'project'>) {
|
||||||
|
return (
|
||||||
|
<PurgeProjectButton project={project}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<OLTooltip
|
||||||
|
key={`tooltip-purge-project-${project.id}`}
|
||||||
|
id={`purge-project-${project.id}`}
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
variant="link"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
className="action-btn"
|
||||||
|
icon="delete_forever"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
)}
|
||||||
|
</PurgeProjectButton>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default memo(PurgeProjectButton)
|
||||||
|
export { PurgeProjectButtonTooltip }
|
||||||
@@ -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)}
|
||||||
|
<RestoreProjectModal
|
||||||
|
projects={[project]}
|
||||||
|
actionHandler={handleRestoreProject}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RestoreProjectButtonTooltip = memo(function RestoreProjectButtonTooltip({
|
||||||
|
project,
|
||||||
|
}: Pick<RestoreProjectButtonProps, 'project'>) {
|
||||||
|
return (
|
||||||
|
<RestoreProjectButton project={project}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<OLTooltip
|
||||||
|
key={`tooltip-restore-project-${project.id}`}
|
||||||
|
id={`restore-project-${project.id}`}
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
variant="link"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
className="action-btn"
|
||||||
|
icon="restore"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
)}
|
||||||
|
</RestoreProjectButton>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default memo(RestoreProjectButton)
|
||||||
|
export { RestoreProjectButtonTooltip }
|
||||||
@@ -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)}
|
||||||
|
<TransferProjectModal
|
||||||
|
projects={[project]}
|
||||||
|
actionHandler={handleTransferProject}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TransferProjectButtonTooltip = memo(function TransferProjectButtonTooltip({
|
||||||
|
project,
|
||||||
|
}: Pick<TransferProjectButtonProps, 'project'>) {
|
||||||
|
return (
|
||||||
|
<TransferProjectButton project={project}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<OLTooltip
|
||||||
|
key={`tooltip-transfer-project-${project.id}`}
|
||||||
|
id={`trnsfer-project-${project.id}`}
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
variant="link"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
className="action-btn"
|
||||||
|
icon="swap_horiz"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
)}
|
||||||
|
</TransferProjectButton>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default memo(TransferProjectButton)
|
||||||
|
export { TransferProjectButtonTooltip }
|
||||||
@@ -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)}
|
||||||
|
<TrashProjectModal
|
||||||
|
projects={[project]}
|
||||||
|
actionHandler={handleTrashProject}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TrashProjectButtonTooltip = memo(function TrashProjectButtonTooltip({
|
||||||
|
project,
|
||||||
|
}: Pick<TrashProjectButtonProps, 'project'>) {
|
||||||
|
return (
|
||||||
|
<TrashProjectButton project={project}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<OLTooltip
|
||||||
|
key={`tooltip-trash-project-${project.id}`}
|
||||||
|
id={`trash-project-${project.id}`}
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
variant="link"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
className="action-btn"
|
||||||
|
icon="delete"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
)}
|
||||||
|
</TrashProjectButton>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default memo(TrashProjectButton)
|
||||||
|
export { TrashProjectButtonTooltip }
|
||||||
@@ -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<void>
|
||||||
|
) => 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<UntrashProjectButtonProps, 'project'>) {
|
||||||
|
return (
|
||||||
|
<UntrashProjectButton project={project}>
|
||||||
|
{(text, handleUntrashProject) => (
|
||||||
|
<OLTooltip
|
||||||
|
key={`tooltip-untrash-project-${project.id}`}
|
||||||
|
id={`untrash-project-${project.id}`}
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={handleUntrashProject}
|
||||||
|
variant="link"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
className="action-btn"
|
||||||
|
icon="restore_page"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
)}
|
||||||
|
</UntrashProjectButton>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default memo(UntrashProjectButton)
|
||||||
|
export { UntrashProjectButtonTooltip }
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<DownloadProjectButtonTooltip project={project} />
|
||||||
|
<TransferProjectButtonTooltip project={project} />
|
||||||
|
<TrashProjectButtonTooltip project={project} />
|
||||||
|
<UntrashProjectButtonTooltip project={project} />
|
||||||
|
<DeleteProjectButtonTooltip project={project} />
|
||||||
|
<RestoreProjectButtonTooltip project={project} />
|
||||||
|
<PurgeProjectButtonTooltip project={project} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<OLTooltip
|
||||||
|
key={`tooltip-date-${projectId}`}
|
||||||
|
id={`tooltip-date-${projectId}`}
|
||||||
|
description={tooltipText}
|
||||||
|
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<span translate="no">
|
||||||
|
{t('last_updated_date_by_x', {
|
||||||
|
lastUpdatedDate: fromNow,
|
||||||
|
person: actorName,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</OLTooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<HTMLInputElement>) => {
|
||||||
|
toggleSelectedProject(projectId, event.target.checked)
|
||||||
|
},
|
||||||
|
[projectId, toggleSelectedProject]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OLFormCheckbox
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={handleCheckboxChange}
|
||||||
|
checked={selectedProjectIds.has(projectId)}
|
||||||
|
aria-label={t('select_project', { project: projectName })}
|
||||||
|
data-project-id={projectId}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ProjectCheckbox.displayName = 'ProjectCheckbox'
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export const ProjectListOwnerName = memo<{ ownerName: string }>(
|
||||||
|
({ ownerName }) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return <span translate="no"> — {t('owned_by_x', { x: ownerName })}</span>
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ProjectListOwnerName.displayName = 'ProjectListOwnerName'
|
||||||
@@ -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 (
|
||||||
|
<tr className={selected ? 'table-active' : undefined}>
|
||||||
|
<td className="dash-cell-checkbox d-none d-md-table-cell">
|
||||||
|
<ProjectCheckbox projectId={project.id} projectName={project.name} />
|
||||||
|
</td>
|
||||||
|
<td className="dash-cell-name" translate="no">
|
||||||
|
{project.name}
|
||||||
|
</td>
|
||||||
|
<td className="dash-cell-date-owner pb-0 d-md-none">
|
||||||
|
<DateCell
|
||||||
|
projectId={project.id}
|
||||||
|
actorName={actorName}
|
||||||
|
date={eventDate}
|
||||||
|
/>
|
||||||
|
<ProjectListOwnerName ownerName={ownerName} />
|
||||||
|
</td>
|
||||||
|
<td className="dash-cell-owner d-none d-md-table-cell">
|
||||||
|
<span translate="no">{ownerName}</span>
|
||||||
|
</td>
|
||||||
|
<td className="dash-cell-date d-none d-md-table-cell">
|
||||||
|
<DateCell
|
||||||
|
projectId={project.id}
|
||||||
|
actorName={actorName}
|
||||||
|
date={eventDate}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="dash-cell-actions">
|
||||||
|
<div className="d-none d-md-block">
|
||||||
|
<ActionsCell project={project} />
|
||||||
|
</div>
|
||||||
|
<div className="d-md-none">
|
||||||
|
<ActionsDropdown project={project} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
export default memo(ProjectListTableRow)
|
||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
className="table-header-sort-btn d-none d-md-inline-block"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={screenReaderText}
|
||||||
|
>
|
||||||
|
<span>{text}</span>
|
||||||
|
{iconType && <MaterialIcon type={iconType} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortByButton = withContent(SortBtn)
|
||||||
|
|
||||||
|
function ProjectListTable() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const {
|
||||||
|
visibleProjects,
|
||||||
|
sort,
|
||||||
|
selectedProjects,
|
||||||
|
selectOrUnselectAllProjects,
|
||||||
|
filter,
|
||||||
|
} = useProjectListContext()
|
||||||
|
const { handleSort } = useSort()
|
||||||
|
const checkAllRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleAllProjectsCheckboxChange = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
selectOrUnselectAllProjects(event.target.checked)
|
||||||
|
},
|
||||||
|
[selectOrUnselectAllProjects]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (checkAllRef.current) {
|
||||||
|
checkAllRef.current.indeterminate =
|
||||||
|
selectedProjects.length > 0 &&
|
||||||
|
selectedProjects.length !== visibleProjects.length
|
||||||
|
}
|
||||||
|
}, [selectedProjects, visibleProjects])
|
||||||
|
return (
|
||||||
|
<OLTable className="project-dash-table" container={false} hover>
|
||||||
|
<caption className="visually-hidden">{t('projects_list')}</caption>
|
||||||
|
<thead className="visually-hidden-max-md">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
className="dash-cell-checkbox d-none d-md-table-cell"
|
||||||
|
aria-label={t('select_projects')}
|
||||||
|
>
|
||||||
|
<OLFormCheckbox
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={handleAllProjectsCheckboxChange}
|
||||||
|
checked={
|
||||||
|
visibleProjects.length === selectedProjects.length &&
|
||||||
|
visibleProjects.length !== 0
|
||||||
|
}
|
||||||
|
disabled={visibleProjects.length === 0}
|
||||||
|
aria-label={t('select_all_projects')}
|
||||||
|
inputRef={checkAllRef}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="dash-cell-name"
|
||||||
|
aria-label={t('title')}
|
||||||
|
aria-sort={
|
||||||
|
sort.by === 'title'
|
||||||
|
? sort.order === 'asc'
|
||||||
|
? 'ascending'
|
||||||
|
: 'descending'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SortByButton
|
||||||
|
column="title"
|
||||||
|
text={t('title')}
|
||||||
|
sort={sort}
|
||||||
|
onClick={() => handleSort('title')}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="dash-cell-date-owner d-md-none"
|
||||||
|
aria-label={t('date_and_owner')}
|
||||||
|
>
|
||||||
|
{t('date_and_owner')}
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="dash-cell-owner d-none d-md-table-cell"
|
||||||
|
aria-label={t('owner')}
|
||||||
|
aria-sort={
|
||||||
|
sort.by === 'owner'
|
||||||
|
? sort.order === 'asc'
|
||||||
|
? 'ascending'
|
||||||
|
: 'descending'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SortByButton
|
||||||
|
column="owner"
|
||||||
|
text={t('owner')}
|
||||||
|
sort={sort}
|
||||||
|
onClick={() => handleSort('owner')}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
{filter !== 'deleted' ? (
|
||||||
|
<th
|
||||||
|
className="dash-cell-date d-none d-md-table-cell"
|
||||||
|
aria-label={t('last_modified')}
|
||||||
|
aria-sort={
|
||||||
|
sort.by === 'lastUpdated'
|
||||||
|
? sort.order === 'asc'
|
||||||
|
? 'ascending'
|
||||||
|
: 'descending'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SortByButton
|
||||||
|
column="lastUpdated"
|
||||||
|
text={t('last_modified')}
|
||||||
|
sort={sort}
|
||||||
|
onClick={() => handleSort('lastUpdated')}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
) : (
|
||||||
|
<th
|
||||||
|
className="dash-cell-date d-none d-md-table-cell"
|
||||||
|
aria-label={t('deleted_at')}
|
||||||
|
aria-sort={
|
||||||
|
sort.by === 'deletedAt'
|
||||||
|
? sort.order === 'asc'
|
||||||
|
? 'ascending'
|
||||||
|
: 'descending'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SortByButton
|
||||||
|
column="deletedAt"
|
||||||
|
text={t('deleted_at')}
|
||||||
|
sort={sort}
|
||||||
|
onClick={() => handleSort('deletedAt')}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
<th className="dash-cell-actions" aria-label={t('actions')}>
|
||||||
|
{t('actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibleProjects.length > 0 ? (
|
||||||
|
visibleProjects.map(p => (
|
||||||
|
<ProjectListTableRow
|
||||||
|
project={p}
|
||||||
|
selected={selectedProjects.some(({ id }) => id === p.id)}
|
||||||
|
key={p.id}
|
||||||
|
filter={filter}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr className="no-projects">
|
||||||
|
<td className="text-center" colSpan={4}>
|
||||||
|
{t('no_projects')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</OLTable>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectListTable
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<OLButton variant="danger" onClick={handleOpenModal}>
|
||||||
|
{t('delete')}
|
||||||
|
</OLButton>
|
||||||
|
<DeleteProjectModal
|
||||||
|
projects={selectedProjects}
|
||||||
|
actionHandler={handleDeleteProject}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeleteProjectsButton
|
||||||
@@ -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 (
|
||||||
|
<OLTooltip
|
||||||
|
id="tooltip-download-projects"
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={handleDownloadProjects}
|
||||||
|
variant="secondary"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
icon="download"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(DownloadProjectsButton)
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<OLButton variant="danger" onClick={handleOpenModal}>
|
||||||
|
{t('purge')}
|
||||||
|
</OLButton>
|
||||||
|
<PurgeProjectModal
|
||||||
|
projects={selectedProjects}
|
||||||
|
actionHandler={handlePurgeProject}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PurgeProjectsButton
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<OLButton variant="primary" onClick={handleOpenModal}>
|
||||||
|
{t('restore')}
|
||||||
|
</OLButton>
|
||||||
|
<RestoreProjectModal
|
||||||
|
projects={selectedProjects}
|
||||||
|
actionHandler={handleRestoreProject}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RestoreProjectsButton
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<OLTooltip
|
||||||
|
id="tooltip-transfer-projects"
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
variant="secondary"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
icon="swap_horiz"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
<TransferProjectModal
|
||||||
|
projects={selectedProjects}
|
||||||
|
actionHandler={handleTransferProject}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(TransferProjectsButton)
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<OLTooltip
|
||||||
|
id="tooltip-trash-projects"
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
variant="secondary"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
icon="delete"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
<TrashProjectModal
|
||||||
|
projects={selectedProjects}
|
||||||
|
actionHandler={handleTrashProject}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(TrashProjectsButton)
|
||||||
@@ -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 (
|
||||||
|
<OLTooltip
|
||||||
|
id="tooltip-download-projects"
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'bottom', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={handleUntrashProjects}
|
||||||
|
variant="secondary"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
icon="restore"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 (
|
||||||
|
<OLButtonToolbar aria-label={t('toolbar_selected_projects')}>
|
||||||
|
<OLButtonGroup
|
||||||
|
aria-label={t('toolbar_selected_projects_management_actions')}
|
||||||
|
>
|
||||||
|
{filter !== 'deleted' && <DownloadProjectsButton />}
|
||||||
|
{filter !== 'deleted' && <TransferProjectsButton />}
|
||||||
|
{filter !== 'deleted' && filter !== 'trashed' && <TrashProjectsButton />}
|
||||||
|
{filter === 'trashed' && <UntrashProjectsButton />}
|
||||||
|
</OLButtonGroup>
|
||||||
|
{filter === 'trashed' && (
|
||||||
|
<OLButtonGroup aria-label={t('toolbar_selected_projects_remove')}>
|
||||||
|
<DeleteProjectsButton />
|
||||||
|
</OLButtonGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(filter === 'deleted') && (
|
||||||
|
<>
|
||||||
|
<OLButtonGroup
|
||||||
|
aria-label={t('toolbar_selected_projects_restore')}
|
||||||
|
>
|
||||||
|
<RestoreProjectsButton />
|
||||||
|
</OLButtonGroup>
|
||||||
|
<OLButtonGroup
|
||||||
|
aria-label={t('toolbar_selected_projects_purge')}
|
||||||
|
>
|
||||||
|
<PurgeProjectsButton />
|
||||||
|
</OLButtonGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</OLButtonToolbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ProjectTools)
|
||||||
@@ -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 (
|
||||||
|
<h1
|
||||||
|
id="main-content"
|
||||||
|
tabIndex={-1}
|
||||||
|
className={classnames('project-list-title', className)}
|
||||||
|
{...extraProps}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</h1>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectListTitle
|
||||||
@@ -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: 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<typeof useAsync>['isLoading']
|
||||||
|
loadMoreCount: number
|
||||||
|
loadMoreProjects: () => void
|
||||||
|
loadProgress: number
|
||||||
|
removeProjectFromView: (project: Project) => void
|
||||||
|
selectFilter: (filter: Filter) => void
|
||||||
|
selectedProjectIds: Set<string>
|
||||||
|
selectedProjects: Project[]
|
||||||
|
selectOrUnselectAllProjects: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
|
searchText: string
|
||||||
|
setSearchText: React.Dispatch<React.SetStateAction<string>>
|
||||||
|
setSelectedProjectIds: React.Dispatch<React.SetStateAction<Set<string>>>
|
||||||
|
setSort: React.Dispatch<React.SetStateAction<Sort>>
|
||||||
|
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<Project[]>(
|
||||||
|
prefetchedProjectsBlob?.projects ?? []
|
||||||
|
)
|
||||||
|
|
||||||
|
const [maxVisibleProjects, setMaxVisibleProjects] =
|
||||||
|
useState(MAX_PROJECT_PER_PAGE)
|
||||||
|
|
||||||
|
const [loadProgress, setLoadProgress] = useState(
|
||||||
|
prefetchedProjectsBlob ? 100 : 20
|
||||||
|
)
|
||||||
|
|
||||||
|
const [totalProjectsCount, setTotalProjectsCount] = useState<number>(
|
||||||
|
prefetchedProjectsBlob?.totalSize ?? 0
|
||||||
|
)
|
||||||
|
|
||||||
|
const [filter, setFilter] = usePersistedState<Filter>(
|
||||||
|
'admin-project-list-filter',
|
||||||
|
'owned'
|
||||||
|
)
|
||||||
|
const [sort, setSort] = useState<Sort>({
|
||||||
|
by: filter === 'deleted' ? 'deletedAt' : 'lastUpdated',
|
||||||
|
order: 'desc',
|
||||||
|
})
|
||||||
|
const prevSortRef = useRef<Sort>(sort)
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState('')
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading: loading,
|
||||||
|
isIdle,
|
||||||
|
error,
|
||||||
|
runAsync,
|
||||||
|
} = useAsync<GetProjectsResponseBody>({
|
||||||
|
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<string>()
|
||||||
|
)
|
||||||
|
|
||||||
|
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<ProjectListContextValue>(
|
||||||
|
() => ({
|
||||||
|
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 (
|
||||||
|
<ProjectListContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</ProjectListContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProjectListContext() {
|
||||||
|
const context = useContext(ProjectListContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
'ProjectListContext is only available inside ProjectListProvider'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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<GetProjectsResponseBody> {
|
||||||
|
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 } })
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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]'
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="create-account-button-wrapper">
|
||||||
|
<OLButton
|
||||||
|
id={id}
|
||||||
|
className="create-account-button"
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleButtonClick}
|
||||||
|
>
|
||||||
|
{buttonText || t('create_account')}
|
||||||
|
</OLButton>
|
||||||
|
|
||||||
|
{showModal && (
|
||||||
|
<CreateAccountModal onHide={() => setShowModal(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateAccountButton
|
||||||
|
|
||||||
@@ -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 (
|
||||||
|
<OLModal
|
||||||
|
show
|
||||||
|
animation
|
||||||
|
onHide={onHide}
|
||||||
|
id="blank-user-modal"
|
||||||
|
backdrop="static"
|
||||||
|
>
|
||||||
|
<ModalContentNewUserForm handleCloseModal={onHide} />
|
||||||
|
</OLModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CreateAccountModal
|
||||||
@@ -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<HTMLInputElement>()
|
||||||
|
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<CreateUserResult>()
|
||||||
|
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.currentTarget
|
||||||
|
setUserData(prev => ({ ...prev, [name]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, checked } = e.currentTarget
|
||||||
|
setUserData(prev => ({ ...prev, [name]: checked }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
createAccount()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<OLModalHeader>
|
||||||
|
<OLModalTitle>{t('create_account')}</OLModalTitle>
|
||||||
|
</OLModalHeader>
|
||||||
|
|
||||||
|
<OLModalBody>
|
||||||
|
{isError && (
|
||||||
|
<div className="notification-list">
|
||||||
|
<Notification
|
||||||
|
type="error"
|
||||||
|
content={t(getUserFacingMessage(error)) as string}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<OLForm onSubmit={handleSubmit}>
|
||||||
|
<OLFormGroup controlId="email-address">
|
||||||
|
<OLFormLabel>{t('email_address')}</OLFormLabel>
|
||||||
|
<OLFormControl
|
||||||
|
maxLength="128"
|
||||||
|
autoComplete="off"
|
||||||
|
type="text"
|
||||||
|
name="email"
|
||||||
|
placeholder="example@email.com"
|
||||||
|
ref={autoFocusedRef}
|
||||||
|
onChange={handleTextChange}
|
||||||
|
value={userData.email}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
<OLFormGroup controlId="first-name">
|
||||||
|
<OLFormLabel>{t('first_name')}</OLFormLabel>
|
||||||
|
<OLFormControl
|
||||||
|
maxLength="128"
|
||||||
|
autoComplete="off"
|
||||||
|
type="text"
|
||||||
|
name="firstName"
|
||||||
|
placeholder="Erika"
|
||||||
|
onChange={handleTextChange}
|
||||||
|
value={userData.firstName}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
<OLFormGroup controlId="last-name">
|
||||||
|
<OLFormLabel>{t('last_name')}</OLFormLabel>
|
||||||
|
<OLFormControl
|
||||||
|
maxLength="128"
|
||||||
|
autoComplete="off"
|
||||||
|
type="text"
|
||||||
|
name="lastName"
|
||||||
|
placeholder="Mustermann"
|
||||||
|
onChange={handleTextChange}
|
||||||
|
value={userData.lastName}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
<OLRow>
|
||||||
|
<OLCol xs={6}>
|
||||||
|
<OLFormGroup controlId="is-admin-checkbox">
|
||||||
|
<OLFormCheckbox
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={handleCheckboxChange}
|
||||||
|
name="isAdmin"
|
||||||
|
label={t('set_admin_account')}
|
||||||
|
checked={userData.isAdmin}
|
||||||
|
aria-label={t('set_admin_account')}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
</OLCol>
|
||||||
|
{(!onlyLocalAuthEnabled &&
|
||||||
|
<OLCol xs={6}>
|
||||||
|
<OLFormGroup controlId="is-external-checkbox">
|
||||||
|
<OLFormCheckbox
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={handleCheckboxChange}
|
||||||
|
name="isExternal"
|
||||||
|
label="External authentication"
|
||||||
|
checked={userData.isExternal}
|
||||||
|
aria-label={"External authentication"}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
</OLCol>
|
||||||
|
)}
|
||||||
|
</OLRow>
|
||||||
|
</OLForm>
|
||||||
|
</OLModalBody>
|
||||||
|
|
||||||
|
<OLModalFooter>
|
||||||
|
<OLButton variant="secondary" onClick={handleCloseModal}>
|
||||||
|
{t('cancel')}
|
||||||
|
</OLButton>
|
||||||
|
<OLButton
|
||||||
|
variant="primary"
|
||||||
|
onClick={createAccount}
|
||||||
|
disabled={userData.email.trim() === '' ||
|
||||||
|
userData.lastName.trim() === '' ||
|
||||||
|
userData.firstName.trim() === '' ||
|
||||||
|
isLoading || redirecting}
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadingLabel={t('creating')}
|
||||||
|
>
|
||||||
|
{t('create')}
|
||||||
|
</OLButton>
|
||||||
|
</OLModalFooter>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModalContentNewUserForm
|
||||||
@@ -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 (
|
||||||
|
<Dropdown align="end">
|
||||||
|
<DropdownToggle
|
||||||
|
id={`user-actions-dropdown-toggle-btn-${user.id}`}
|
||||||
|
bsPrefix="dropdown-table-button-toggle"
|
||||||
|
>
|
||||||
|
<MaterialIcon type="more_vert" accessibilityLabel={t('actions')} />
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu flip={false}>
|
||||||
|
<ShowUserInfoButton user={user}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
leadingIcon="info"
|
||||||
|
unfilled={true}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ShowUserInfoButton>
|
||||||
|
<UpdateUserButton user={user}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
leadingIcon="edit"
|
||||||
|
unfilled={true}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</UpdateUserButton>
|
||||||
|
{!isSelf && (
|
||||||
|
<>
|
||||||
|
{flagActions.map(({ action, icon, unfilled }) => (
|
||||||
|
<FlagUserButton key={action} user={user} action={action}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
leadingIcon={icon}
|
||||||
|
unfilled={unfilled}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</FlagUserButton>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<DeleteUserButton user={user}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
leadingIcon="delete"
|
||||||
|
unfilled
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</DeleteUserButton>
|
||||||
|
|
||||||
|
<RestoreUserButton user={user}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
leadingIcon="restore"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</RestoreUserButton>
|
||||||
|
|
||||||
|
<PurgeUserButton user={user}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
leadingIcon="delete_forever"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</PurgeUserButton>
|
||||||
|
{(user.authMethods.includes('local') && !user.suspended) && (
|
||||||
|
<SendRegEmailButton user={user}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<li role="none">
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
leadingIcon="mail"
|
||||||
|
unfilled
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</SendRegEmailButton>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ActionsDropdown
|
||||||
@@ -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 (
|
||||||
|
<li role="presentation" className={className}>
|
||||||
|
<button
|
||||||
|
className="menu-item-button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={onClick}
|
||||||
|
{...buttonProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
{afterNode}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={onClick}
|
||||||
|
trailingIcon={iconType}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Record<Sort['by'], string>>({
|
||||||
|
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 (
|
||||||
|
<Dropdown className="projects-sort-dropdown" align="end">
|
||||||
|
<DropdownToggle
|
||||||
|
id="projects-sort-dropdown"
|
||||||
|
className="pe-0 mb-0 btn-transparent"
|
||||||
|
size="sm"
|
||||||
|
aria-label={t('sort_projects')}
|
||||||
|
>
|
||||||
|
<span className="text-truncate" aria-hidden>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu flip={false}>
|
||||||
|
<DropdownHeader className="text-uppercase">
|
||||||
|
{t('sort_by')}:
|
||||||
|
</DropdownHeader>
|
||||||
|
<ItemWithContent
|
||||||
|
column="name"
|
||||||
|
text={t('name')}
|
||||||
|
sort={sort}
|
||||||
|
onClick={() => handleClick('name')}
|
||||||
|
/>
|
||||||
|
<ItemWithContent
|
||||||
|
column="email"
|
||||||
|
text={t('email')}
|
||||||
|
sort={sort}
|
||||||
|
onClick={() => handleClick('email')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ filter !== 'deleted' ? (
|
||||||
|
<ItemWithContent
|
||||||
|
column="signUpDate"
|
||||||
|
text={t('signed_up')}
|
||||||
|
sort={sort}
|
||||||
|
onClick={() => handleClick('signUpDate')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ItemWithContent
|
||||||
|
column="deletedAt"
|
||||||
|
text={t('deleted_at')}
|
||||||
|
sort={sort}
|
||||||
|
onClick={() => handleClick('deletedAt')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ItemWithContent
|
||||||
|
column="lastActive"
|
||||||
|
text={t('last_active')}
|
||||||
|
sort={sort}
|
||||||
|
onClick={() => handleClick('lastActive')}
|
||||||
|
/>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SortByDropdown
|
||||||
@@ -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 (
|
||||||
|
<UsersFilterMenu filter={filter}>
|
||||||
|
{isActive => (
|
||||||
|
<DropdownItem
|
||||||
|
as="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={handleClick}
|
||||||
|
trailingIcon={isActive ? 'check' : undefined}
|
||||||
|
active={isActive}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
</UsersFilterMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersDropdown() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { filter, filterTranslations } = useUserListContext()
|
||||||
|
|
||||||
|
const title = filterTranslations.get(filter) ?? t('user_category_all')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown>
|
||||||
|
<DropdownToggle
|
||||||
|
id="users-types-dropdown-toggle-btn"
|
||||||
|
className="ps-0 mb-0 btn-transparent h3"
|
||||||
|
size="lg"
|
||||||
|
aria-label={t('filter_users')}
|
||||||
|
>
|
||||||
|
<span className="text-truncate" aria-hidden>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</DropdownToggle>
|
||||||
|
<DropdownMenu flip={false}>
|
||||||
|
{[...filterTranslations.entries()].map(([key, text]) => (
|
||||||
|
<li role="none" key={key}>
|
||||||
|
<Item filter={key} text={text} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</DropdownMenu>
|
||||||
|
</Dropdown>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsersDropdown
|
||||||
@@ -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 (
|
||||||
|
<div className="text-center">
|
||||||
|
{hiddenUsersCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<OLButton
|
||||||
|
variant="secondary"
|
||||||
|
className="user-list-load-more-button"
|
||||||
|
onClick={() => loadMoreUsers()}
|
||||||
|
>
|
||||||
|
{t('show_x_more_users', { x: loadMoreCount })}
|
||||||
|
</OLButton>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<p>
|
||||||
|
{hiddenUsersCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<span aria-live="polite">
|
||||||
|
{t('showing_x_out_of_n_users', {
|
||||||
|
x: visibleUsers.length,
|
||||||
|
n: visibleUsers.length + hiddenUsersCount,
|
||||||
|
})}
|
||||||
|
</span>{' '}
|
||||||
|
<OLButton
|
||||||
|
variant="link"
|
||||||
|
onClick={() => showAllUsers()}
|
||||||
|
className="btn-inline-link"
|
||||||
|
>
|
||||||
|
{t('show_all_users')}
|
||||||
|
</OLButton>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span aria-live="polite">
|
||||||
|
{t('showing_x_out_of_n_users', {
|
||||||
|
x: visibleUsers.length,
|
||||||
|
n: visibleUsers.length,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<typeof UsersActionModal>,
|
||||||
|
'users' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||||
|
>
|
||||||
|
|
||||||
|
function DeleteUserModal({
|
||||||
|
users,
|
||||||
|
actionHandler,
|
||||||
|
showModal,
|
||||||
|
handleCloseModal,
|
||||||
|
}: DeleteUserModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { loadedUsers } = useUserListContext()
|
||||||
|
|
||||||
|
const [usersToDisplay, setUsersToDisplay] = useState<typeof users>([])
|
||||||
|
const [sendEmail, setSendEmail] = useState<boolean>(false)
|
||||||
|
const [transferProjects, setTransferProjects] = useState<boolean>(false)
|
||||||
|
const [newOwner, setNewOwner] = useState<UserRef | null>(null)
|
||||||
|
|
||||||
|
const selectOwnerInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||||
|
setSendEmail(e.currentTarget.checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTransferProjectsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<UsersActionModal
|
||||||
|
action="delete"
|
||||||
|
actionHandler={actionHandler}
|
||||||
|
title={t('delete_accounts')}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
users={users}
|
||||||
|
options={options}
|
||||||
|
actionIsDisabled={transferProjects && !newOwner}
|
||||||
|
>
|
||||||
|
<p>{t('about_to_delete_accounts')}</p>
|
||||||
|
<UsersList users={users} usersToDisplay={usersToDisplay} />
|
||||||
|
<Notification
|
||||||
|
content={t('this_action_can_be_undone_within_limited_period')}
|
||||||
|
type="warning"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OLForm className="mt-4">
|
||||||
|
<OLFormGroup controlId="send-email-checkbox" className="d-flex">
|
||||||
|
<OLFormCheckbox
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={handleSendEmailChange}
|
||||||
|
name="sendEmail"
|
||||||
|
label={t('notify_users_about_account_deletion')}
|
||||||
|
checked={sendEmail}
|
||||||
|
area-label={t('notify_users_about_account_deletion')}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
|
||||||
|
<OLFormGroup controlId="transfer-projects-checkbox" className="mt-3">
|
||||||
|
<OLFormCheckbox
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={handleTransferProjectsChange}
|
||||||
|
name="transferProjects"
|
||||||
|
label={t('transfer_all_projects_to')}
|
||||||
|
checked={transferProjects}
|
||||||
|
area-label={t('transfer_all_projects_to')}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
|
||||||
|
{transferProjects && (
|
||||||
|
<OLFormGroup className="mt-2">
|
||||||
|
<SelectOwnerForm
|
||||||
|
ref={selectOwnerInputRef}
|
||||||
|
loading={!potentialOwners.length}
|
||||||
|
users={potentialOwners}
|
||||||
|
value={newOwner}
|
||||||
|
onChange={setNewOwner}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
)}
|
||||||
|
</OLForm>
|
||||||
|
</UsersActionModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DeleteUserModal
|
||||||
|
|
||||||
@@ -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<typeof UsersActionModal>,
|
||||||
|
'users' | 'action' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||||
|
>
|
||||||
|
|
||||||
|
function FlagUserModal({
|
||||||
|
users,
|
||||||
|
action,
|
||||||
|
actionHandler,
|
||||||
|
showModal,
|
||||||
|
handleCloseModal,
|
||||||
|
}: FlagUserModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [usersToDisplay, setUsersToDisplay] = useState<typeof users>(
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<UsersActionModal
|
||||||
|
action={action}
|
||||||
|
actionHandler={actionHandler}
|
||||||
|
title={t(`${action}_account`)}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
users={users}
|
||||||
|
options={{userData}}
|
||||||
|
>
|
||||||
|
<p>{t(`about_to_${action}_accounts`)}</p>
|
||||||
|
<UsersList users={users} usersToDisplay={usersToDisplay} />
|
||||||
|
</UsersActionModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FlagUserModal
|
||||||
@@ -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<typeof UsersActionModal>,
|
||||||
|
'users' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||||
|
>
|
||||||
|
|
||||||
|
function PurgeUserModal({
|
||||||
|
users,
|
||||||
|
actionHandler,
|
||||||
|
showModal,
|
||||||
|
handleCloseModal,
|
||||||
|
}: PurgeUserModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [usersToDisplay, setUsersToDisplay] = useState<typeof users>(
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showModal) {
|
||||||
|
setUsersToDisplay(displayUsers => {
|
||||||
|
return displayUsers.length ? displayUsers : users
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setUsersToDisplay([])
|
||||||
|
}
|
||||||
|
}, [showModal, users])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UsersActionModal
|
||||||
|
action="purge"
|
||||||
|
actionHandler={actionHandler}
|
||||||
|
title={t('permanently_delete_accounts')}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
users={users}
|
||||||
|
>
|
||||||
|
<p>{t('about_to_permanently_delete_accounts')}</p>
|
||||||
|
<UsersList users={users} usersToDisplay={usersToDisplay} />
|
||||||
|
<Notification
|
||||||
|
content={t('this_action_cannot_be_undone')}
|
||||||
|
type="warning"
|
||||||
|
/>
|
||||||
|
</UsersActionModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PurgeUserModal
|
||||||
@@ -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<typeof UsersActionModal>,
|
||||||
|
'users' | 'actionHandler' | 'showModal' | 'handleCloseModal'
|
||||||
|
>
|
||||||
|
|
||||||
|
function RestoreUserModal({
|
||||||
|
users,
|
||||||
|
actionHandler,
|
||||||
|
showModal,
|
||||||
|
handleCloseModal,
|
||||||
|
}: RestoreUserModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [usersToDisplay, setUsersToDisplay] = useState<typeof users>(
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showModal) {
|
||||||
|
setUsersToDisplay(displayUsers => {
|
||||||
|
return displayUsers.length ? displayUsers : users
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setUsersToDisplay([])
|
||||||
|
}
|
||||||
|
}, [showModal, users])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UsersActionModal
|
||||||
|
action="restore"
|
||||||
|
actionHandler={actionHandler}
|
||||||
|
title={t('restore_accounts')}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
users={users}
|
||||||
|
>
|
||||||
|
<p>{t('about_to_restore_accounts')}</p>
|
||||||
|
<UsersList users={users} usersToDisplay={usersToDisplay} />
|
||||||
|
</UsersActionModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RestoreUserModal
|
||||||
@@ -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<typeof UsersActionModal>,
|
||||||
|
'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<typeof users>([])
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<UsersActionModal
|
||||||
|
action="resend"
|
||||||
|
actionHandler={actionHandler}
|
||||||
|
title={t('resend_activation_email')}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
users={localUsers}
|
||||||
|
>
|
||||||
|
<p>{t('about_to_resend_activation_email')}</p>
|
||||||
|
<UsersList users={localUsers} usersToDisplay={usersToDisplay} />
|
||||||
|
</UsersActionModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SendRegEmailModal
|
||||||
|
|
||||||
@@ -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<typeof UsersActionModal>,
|
||||||
|
'users' | 'showModal' | 'handleCloseModal'
|
||||||
|
>
|
||||||
|
|
||||||
|
function InfoRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OLRow className="mb-2">
|
||||||
|
<OLCol xs={4} className="fw-semibold text-muted">
|
||||||
|
{label}
|
||||||
|
</OLCol>
|
||||||
|
<OLCol xs={8}>{value}</OLCol>
|
||||||
|
</OLRow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShowUserInfoModal({
|
||||||
|
users,
|
||||||
|
showModal,
|
||||||
|
handleCloseModal,
|
||||||
|
}: ShowUserInfoModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
if (users.length !== 1) return null
|
||||||
|
const user = users[0]
|
||||||
|
|
||||||
|
const [activationLink, setActivationLink] = useState<string | null>(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 (
|
||||||
|
<UsersActionModal
|
||||||
|
action="info"
|
||||||
|
title={t('account_information')}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
users={users}
|
||||||
|
>
|
||||||
|
<OLCard className="mb-3">
|
||||||
|
{(Body) => (
|
||||||
|
<>
|
||||||
|
<Card.Header>{t('Account')}</Card.Header>
|
||||||
|
<Body>
|
||||||
|
<InfoRow label={t('email_address')} value={user.email} />
|
||||||
|
<InfoRow label={t('first_name')} value={user.firstName || '—'} />
|
||||||
|
<InfoRow label={t('last_name')} value={user.lastName || '—'} />
|
||||||
|
|
||||||
|
{user.isAdmin && (
|
||||||
|
<InfoRow
|
||||||
|
label={t('role')}
|
||||||
|
value={
|
||||||
|
<OLBadge bg="danger">
|
||||||
|
{t('user_category_admin')}
|
||||||
|
</OLBadge>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activationLink && (
|
||||||
|
<InfoRow
|
||||||
|
label={t('activation_link')}
|
||||||
|
value={
|
||||||
|
<span
|
||||||
|
style={{ cursor: 'pointer', textDecoration: 'underline' }}
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{activationLink}
|
||||||
|
{copied && (
|
||||||
|
<span className="ms-2 text-success">
|
||||||
|
({t('copied')})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Body>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</OLCard>
|
||||||
|
|
||||||
|
<OLCard>
|
||||||
|
{(Body) => (
|
||||||
|
<>
|
||||||
|
<Card.Header>{t('user_activity')}</Card.Header>
|
||||||
|
<Body>
|
||||||
|
<InfoRow
|
||||||
|
label={t('signed_up')}
|
||||||
|
value={formatDate(user.signUpDate)}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label={t('last_logged_in')}
|
||||||
|
value={
|
||||||
|
user.lastLoggedIn
|
||||||
|
? formatDate(user.lastLoggedIn)
|
||||||
|
: t('never')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label={t('last_active')}
|
||||||
|
value={
|
||||||
|
user.lastActive
|
||||||
|
? formatDate(user.lastActive)
|
||||||
|
: t('never')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label={t('login_count')}
|
||||||
|
value={user.loginCount}
|
||||||
|
/>
|
||||||
|
</Body>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</OLCard>
|
||||||
|
</UsersActionModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShowUserInfoModal
|
||||||
|
|
||||||
@@ -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<typeof UsersActionModal>,
|
||||||
|
'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<HTMLInputElement>()
|
||||||
|
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.currentTarget
|
||||||
|
setUserData(prev => ({ ...prev, [name]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, checked } = e.currentTarget
|
||||||
|
setUserData(prev => ({ ...prev, [name]: checked }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UsersActionModal
|
||||||
|
action="update"
|
||||||
|
actionHandler={actionHandler}
|
||||||
|
title={t('update_account_info')}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
users={users}
|
||||||
|
options={{ userData }}
|
||||||
|
>
|
||||||
|
<OLFormGroup controlId="email-address">
|
||||||
|
<OLFormLabel>{t('email_address')}</OLFormLabel>
|
||||||
|
<OLFormControl
|
||||||
|
ref={autoFocusedRef}
|
||||||
|
maxLength="128"
|
||||||
|
autoComplete="off"
|
||||||
|
type="text"
|
||||||
|
name="email"
|
||||||
|
onChange={handleTextChange}
|
||||||
|
value={userData.email}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
<OLFormGroup controlId="first-name">
|
||||||
|
<OLFormLabel>{t('first_name')}</OLFormLabel>
|
||||||
|
<OLFormControl
|
||||||
|
maxLength="128"
|
||||||
|
autoComplete="off"
|
||||||
|
type="text"
|
||||||
|
name="firstName"
|
||||||
|
onChange={handleTextChange}
|
||||||
|
value={userData.firstName}
|
||||||
|
disabled={!allowUpdateDetails}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
<OLFormGroup controlId="last-name">
|
||||||
|
<OLFormLabel>{t('last_name')}</OLFormLabel>
|
||||||
|
<OLFormControl
|
||||||
|
maxLength="128"
|
||||||
|
autoComplete="off"
|
||||||
|
type="text"
|
||||||
|
name="lastName"
|
||||||
|
onChange={handleTextChange}
|
||||||
|
value={userData.lastName}
|
||||||
|
disabled={!allowUpdateDetails}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
<OLRow>
|
||||||
|
<OLCol xs={6}>
|
||||||
|
<OLFormGroup controlId="is-admin-checkbox">
|
||||||
|
<OLFormCheckbox
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={handleCheckboxChange}
|
||||||
|
name="isAdmin"
|
||||||
|
label={t('set_admin_account')}
|
||||||
|
checked={userData.isAdmin}
|
||||||
|
disabled={isSelf || !allowUpdateIsAdmin}
|
||||||
|
/>
|
||||||
|
</OLFormGroup>
|
||||||
|
</OLCol>
|
||||||
|
</OLRow>
|
||||||
|
</UsersActionModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UpdateUserModal
|
||||||
@@ -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<void>
|
||||||
|
handleCloseModal: () => void
|
||||||
|
users: Array<User>
|
||||||
|
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<Array<any>>([])
|
||||||
|
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<User>, 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 (
|
||||||
|
<OLModal
|
||||||
|
animation
|
||||||
|
show={showModal}
|
||||||
|
onHide={handleCloseModal}
|
||||||
|
id="action-user-modal"
|
||||||
|
backdrop="static"
|
||||||
|
>
|
||||||
|
<OLModalHeader>
|
||||||
|
<OLModalTitle>{title}</OLModalTitle>
|
||||||
|
</OLModalHeader>
|
||||||
|
<OLModalBody>
|
||||||
|
{children}
|
||||||
|
{!isProcessing &&
|
||||||
|
errors.length > 0 &&
|
||||||
|
errors.map((error, i) => (
|
||||||
|
<div className="notification-list" key={i}>
|
||||||
|
<Notification
|
||||||
|
type="error"
|
||||||
|
title={error.userName}
|
||||||
|
content={getUserFacingMessage(error.error) as string}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</OLModalBody>
|
||||||
|
<OLModalFooter>
|
||||||
|
{action !== 'info' && (
|
||||||
|
<OLButton variant="secondary" onClick={handleCloseModal}>
|
||||||
|
{t('cancel')}
|
||||||
|
</OLButton>
|
||||||
|
)}
|
||||||
|
<OLButton
|
||||||
|
variant={variant}
|
||||||
|
onClick={() => handleActionForUsers(users, options)}
|
||||||
|
disabled={isProcessing || actionIsDisabled}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</OLButton>
|
||||||
|
</OLModalFooter>
|
||||||
|
</OLModal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(UsersActionModal)
|
||||||
@@ -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 (
|
||||||
|
<ul>
|
||||||
|
{usersToDisplay.map(user => (
|
||||||
|
<li
|
||||||
|
key={`users-action-list-${user.id}`}
|
||||||
|
className={classnames({
|
||||||
|
'list-style-check-green': !users.some(
|
||||||
|
({ id }) => id === user.id
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<b>{`${getUserName(user)} <${user.email}>`}</b>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UsersList
|
||||||
@@ -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<typeof OLForm>,
|
||||||
|
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 (
|
||||||
|
<OLForm
|
||||||
|
className={classnames('user-search', className)}
|
||||||
|
role="search"
|
||||||
|
onSubmit={e => e.preventDefault()}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<OLFormGroup>
|
||||||
|
<OLCol>
|
||||||
|
<OLFormControl
|
||||||
|
name="search"
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
aria-label={placeholder}
|
||||||
|
prepend={<MaterialIcon type="search" />}
|
||||||
|
append={
|
||||||
|
inputValue.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="form-control-search-clear-btn"
|
||||||
|
aria-label={t('clear_search')}
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
<MaterialIcon type="clear" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</OLCol>
|
||||||
|
</OLFormGroup>
|
||||||
|
</OLForm>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SearchForm
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="user-list-sidebar-wrapper-react d-none d-md-flex"
|
||||||
|
{...getTargetProps({
|
||||||
|
style: {
|
||||||
|
...(mousePos?.x && { flexBasis: `${mousePos.x}px` }),
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
className="flex-grow flex-shrink"
|
||||||
|
aria-label={t('user_categories')}
|
||||||
|
>
|
||||||
|
<CreateAccountButton
|
||||||
|
id="create-account-button-sidebar"
|
||||||
|
className={scrolledDown ? 'show-shadow' : undefined}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="user-list-sidebar-scroll"
|
||||||
|
ref={containerRef}
|
||||||
|
data-testid="user-list-sidebar-scroll"
|
||||||
|
>
|
||||||
|
<SidebarFilters />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div
|
||||||
|
className={classnames(
|
||||||
|
'ds-nav-sidebar-lower',
|
||||||
|
scrolledUp && 'show-shadow'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
className="d-flex flex-row gap-3 mb-2"
|
||||||
|
aria-label={t('account_help')}
|
||||||
|
>
|
||||||
|
{sessionUser && (
|
||||||
|
<>
|
||||||
|
<Dropdown
|
||||||
|
className="ds-nav-icon-dropdown"
|
||||||
|
onToggle={show => {
|
||||||
|
setShowAccountDropdown(show)
|
||||||
|
if (show) {
|
||||||
|
sendMB('menu-expand', {
|
||||||
|
item: 'account',
|
||||||
|
location: 'sidebar',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<Dropdown.Toggle role="menuitem" aria-label={t('Account')}>
|
||||||
|
<OLTooltip
|
||||||
|
description={t('Account')}
|
||||||
|
id="open-account"
|
||||||
|
overlayProps={{
|
||||||
|
placement: 'top',
|
||||||
|
}}
|
||||||
|
hidden={showAccountDropdown}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<UserIcon size={24} />
|
||||||
|
</div>
|
||||||
|
</OLTooltip>
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
<Dropdown.Menu
|
||||||
|
as="ul"
|
||||||
|
role="menu"
|
||||||
|
align="end"
|
||||||
|
popperConfig={{
|
||||||
|
modifiers: [
|
||||||
|
{ name: 'offset', options: { offset: [-50, 5] } },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AccountMenuItems
|
||||||
|
sessionUser={sessionUser}
|
||||||
|
showSubscriptionLink={false}
|
||||||
|
showThemeToggle={themedDsNav}
|
||||||
|
/>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
<div className="ds-nav-ds-name" translate="no">
|
||||||
|
<span>Extended CE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
{...getHandleProps({
|
||||||
|
style: {
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: 1,
|
||||||
|
top: 0,
|
||||||
|
right: '-2px',
|
||||||
|
height: '100%',
|
||||||
|
width: '4px',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SidebarDsNav
|
||||||
@@ -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 (
|
||||||
|
<UsersFilterMenu filter={filter}>
|
||||||
|
{isActive => (
|
||||||
|
<li className={isActive ? 'active' : ''}>
|
||||||
|
<button type="button" onClick={() => selectFilter(filter)}>
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</UsersFilterMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SidebarFilters() {
|
||||||
|
const { filterTranslations } = useUserListContext()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="list-unstyled user-list-filters">
|
||||||
|
{[...filterTranslations.entries()].map(([key, text]) => (
|
||||||
|
<SidebarFilter key={key} filter={key} text={text} />
|
||||||
|
))}
|
||||||
|
<li aria-hidden="true">
|
||||||
|
<hr />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<T extends SortBtnOwnProps>(
|
||||||
|
WrappedComponent: React.ComponentType<T & WithContentProps>
|
||||||
|
) {
|
||||||
|
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 (
|
||||||
|
<WrappedComponent
|
||||||
|
{...hocProps}
|
||||||
|
iconType={iconType}
|
||||||
|
screenReaderText={screenReaderText}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return WithContent
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withContent
|
||||||
@@ -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)}
|
||||||
|
<DeleteUserModal
|
||||||
|
users={[user]}
|
||||||
|
actionHandler={handleDeleteUser}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DeleteUserButtonTooltip = memo(function DeleteUserButtonTooltip({
|
||||||
|
user,
|
||||||
|
}: Pick<DeleteUserButtonProps, 'user'>) {
|
||||||
|
return (
|
||||||
|
<DeleteUserButton user={user}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<OLTooltip
|
||||||
|
key={`tooltip-delete-user-${user.id}`}
|
||||||
|
id={`delete-user-${user.id}`}
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
variant="link"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
className="action-btn"
|
||||||
|
icon="delete"
|
||||||
|
unfilled="true"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
)}
|
||||||
|
</DeleteUserButton>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default memo(DeleteUserButton)
|
||||||
|
export { DeleteUserButtonTooltip }
|
||||||
@@ -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)}
|
||||||
|
<FlagUserModal
|
||||||
|
users={[user]}
|
||||||
|
action={action}
|
||||||
|
actionHandler={handleFlagUser}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FlagUserButtonTooltip = memo(function FlagUserButtonTooltip({
|
||||||
|
user, flag
|
||||||
|
}: Pick<FlagUserButtonProps, 'user' | 'flag'>) {
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<FlagUserButton user={user} action={action}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<OLTooltip
|
||||||
|
key={`tooltip-${action}-user-${user.id}`}
|
||||||
|
id={`${action}-user-${user.id}`}
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
variant="link"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
className="action-btn"
|
||||||
|
icon={icon}
|
||||||
|
unfilled={unfilled}
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
)}
|
||||||
|
</FlagUserButton>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default memo(FlagUserButton)
|
||||||
|
export { FlagUserButtonTooltip }
|
||||||
@@ -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)}
|
||||||
|
<PurgeUserModal
|
||||||
|
users={[user]}
|
||||||
|
actionHandler={handlePurgeUser}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PurgeUserButtonTooltip = memo(function PurgeUserButtonTooltip({
|
||||||
|
user,
|
||||||
|
}: Pick<PurgeUserButtonProps, 'user'>) {
|
||||||
|
return (
|
||||||
|
<PurgeUserButton user={user}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<OLTooltip
|
||||||
|
key={`tooltip-purge-user-${user.id}`}
|
||||||
|
id={`purge-user-${user.id}`}
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
variant="link"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
className="action-btn"
|
||||||
|
icon="delete_forever"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
)}
|
||||||
|
</PurgeUserButton>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default memo(PurgeUserButton)
|
||||||
|
export { PurgeUserButtonTooltip }
|
||||||
@@ -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)}
|
||||||
|
<RestoreUserModal
|
||||||
|
users={[user]}
|
||||||
|
actionHandler={handleRestoreUser}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const RestoreUserButtonTooltip = memo(function RestoreUserButtonTooltip({
|
||||||
|
user,
|
||||||
|
}: Pick<RestoreUserButtonProps, 'user'>) {
|
||||||
|
return (
|
||||||
|
<RestoreUserButton user={user}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<OLTooltip
|
||||||
|
key={`tooltip-restore-user-${user.id}`}
|
||||||
|
id={`restore-user-${user.id}`}
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
variant="link"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
className="action-btn"
|
||||||
|
icon="restore"
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
)}
|
||||||
|
</RestoreUserButton>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default memo(RestoreUserButton)
|
||||||
|
export { RestoreUserButtonTooltip }
|
||||||
@@ -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 (
|
||||||
|
<span style={isHidden ? { visibility: 'hidden' } : undefined }>
|
||||||
|
{children(text, handleOpenModal)}
|
||||||
|
<SendRegEmailModal
|
||||||
|
users={[user]}
|
||||||
|
actionHandler={handleSendRegEmail}
|
||||||
|
showModal={showModal}
|
||||||
|
handleCloseModal={handleCloseModal}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SendRegEmailButtonTooltip = memo(function SendRegEmailButtonTooltip({
|
||||||
|
user,
|
||||||
|
}: Pick<SendRegEmailButtonProps, 'user'>) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SendRegEmailButton user={user}>
|
||||||
|
{(text, handleOpenModal) => (
|
||||||
|
<OLTooltip
|
||||||
|
key={`tooltip-send-reg-email-${user.id}`}
|
||||||
|
id={`send-reg-email-${user.id}`}
|
||||||
|
description={text}
|
||||||
|
overlayProps={{ placement: 'top', trigger: ['hover', 'focus'] }}
|
||||||
|
>
|
||||||
|
<OLIconButton
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
variant="link"
|
||||||
|
accessibilityLabel={text}
|
||||||
|
className="action-btn"
|
||||||
|
icon={'mail'}
|
||||||
|
unfilled={true}
|
||||||
|
/>
|
||||||
|
</OLTooltip>
|
||||||
|
)}
|
||||||
|
</SendRegEmailButton>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default memo(SendRegEmailButton)
|
||||||
|
export { SendRegEmailButtonTooltip }
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user