Admin Tools: Manage users and Manage projects pages

This commit is contained in:
yu-i-i
2026-01-26 03:06:40 +01:00
parent b8dee2866e
commit 9af534586f
132 changed files with 10113 additions and 12 deletions

View File

@@ -157,6 +157,7 @@ const transferOwnershipSchema = z.object({
}),
body: z.object({
user_id: zz.objectId(),
skipEmails: z.boolean().optional(),
}),
})
@@ -173,6 +174,7 @@ async function transferOwnership(req, res, next) {
allowTransferToNonCollaborators: hasAdminAccess(sessionUser),
sessionUserId: new ObjectId(sessionUser._id),
ipAddress: req.ip,
skipEmails: body.skipEmails
}
)
res.sendStatus(204)

View File

@@ -79,7 +79,7 @@ async function unmarkAsDeletedByExternalSource(projectId) {
).exec()
}
async function deleteUsersProjects(userId) {
async function deleteUsersProjects(userId, options = {}) {
const projects = await Project.find({ owner_ref: userId }).exec()
logger.info(
{ userId, projectCount: projects.length },
@@ -87,6 +87,7 @@ async function deleteUsersProjects(userId) {
)
await promiseMapWithLimit(5, projects, project =>
deleteProject(project._id, {
...options,
deletedReason: DeletedProjectReasons.ACCOUNT_DELETION,
})
)
@@ -235,6 +236,7 @@ async function deleteProject(projectId, options = {}) {
})
}
const deleterData = {
deletedAt: new Date(),
deleterId:
@@ -306,7 +308,7 @@ async function undeleteProject(projectId, options = {}) {
// if we're undeleting, we want the document to show up
restored.name = await ProjectDetailsHandler.promises.generateUniqueName(
deletedProject.deleterData.deletedProjectOwnerId,
restored.name + ' (Restored)'
restored.name + (options.suffix ?? ' (Restored)')
)
restored.archived = undefined
@@ -330,6 +332,7 @@ async function undeleteProject(projectId, options = {}) {
await db.projects.insertOne(restored)
await DeletedProject.deleteOne({ _id: deletedProject._id }).exec()
return restored
}
async function expireDeletedProject(projectId) {

View File

@@ -63,7 +63,7 @@ async function deleteUser(userId, options) {
logger.info({ userId }, 'creating deleted user record')
await _createDeletedUser(user, options)
logger.info({ userId }, 'deleting user projects')
await ProjectDeleter.promises.deleteUsersProjects(user._id)
await ProjectDeleter.promises.deleteUsersProjects(user._id, options)
if (options.skipEmail) {
logger.info({ userId }, 'skipping sending deletion email to user')
} else {

View File

@@ -15,10 +15,12 @@ import OError from '@overleaf/o-error'
const UserRegistrationHandler = {
_registrationRequestIsValid(body) {
const invalidEmail = AuthenticationManager.validateEmail(body.email || '')
if (invalidEmail) throw new OError('InvalidEmailError')
const invalidPassword = AuthenticationManager.validatePassword(
body.password || '',
body.email
)
if (invalidPassword) throw new OError('InvalidPasswordError')
return !(invalidEmail || invalidPassword)
},

View File

@@ -66,7 +66,7 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(
+dropdown-menu-link-item(href='/admin') Manage Site
+dropdown-menu-link-item(href='/admin/user') Manage Users
if canDisplayProjectUrlLookup
+dropdown-menu-link-item(href='/admin/project') Project URL Lookup
+dropdown-menu-link-item(href='/admin/project') Manage Projects
if canDisplayAdminRedirect
+dropdown-menu-link-item(href=settings.adminUrl) Switch to Admin
if canDisplaySplitTestMenu

View File

@@ -1093,13 +1093,13 @@ module.exports = {
'history-v1',
'launchpad',
'server-ce-scripts',
'user-activate',
'sandboxed-compiles',
'symbol-palette',
'track-changes',
'authentication/ldap',
'authentication/saml',
'authentication/oidc',
'admin-tools', // import after authentication
'template-gallery',
],
viewIncludes: {},

View File

@@ -22,6 +22,7 @@
"a_new_reference_was_added_to_file_from_provider": "",
"a_new_version_of_the_rolling_texlive_build_released": "",
"about_to_archive_projects": "",
"about_to_delete_accounts": "",
"about_to_delete_cert": "",
"about_to_delete_projects": "",
"about_to_delete_tag": "",
@@ -32,8 +33,17 @@
"about_to_enable_managed_users": "",
"about_to_leave_project": "",
"about_to_leave_projects": "",
"about_to_permanently_delete_accounts": "",
"about_to_permanently_delete_projects": "",
"about_to_remove_user_preamble": "",
"about_to_resend_activation_email": "",
"about_to_restore_accounts": "",
"about_to_restore_projects": "",
"about_to_resume_accounts": "",
"about_to_set_admin_accounts": "",
"about_to_suspend_accounts": "",
"about_to_trash_projects": "",
"about_to_unset_admin_accounts": "",
"abstract": "",
"accept_all_cookies": "",
"accept_and_continue": "",
@@ -59,12 +69,14 @@
"account_has_been_link_to_institution_account": "",
"account_has_past_due_invoice_change_plan_warning": "",
"account_help": "",
"account_information": "",
"account_managed_by_group_administrator": "",
"account_managed_by_group_teamname": "",
"account_not_linked_to_dropbox": "",
"account_settings": "",
"acct_linked_to_institution_acct_2": "",
"actions": "",
"activation_link": "",
"active": "",
"add": "",
"add_a_recovery_email_address": "",
@@ -144,6 +156,7 @@
"all_projects": "",
"all_projects_will_be_transferred_immediately": "",
"all_templates": "",
"all_users": "",
"all_these_experiments_are_available_exclusively": "",
"allocate_license": "",
"allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "",
@@ -201,6 +214,7 @@
"back_to_my_projects": "",
"back_to_pdf": "",
"back_to_subscription": "",
"back_to_user_list": "",
"back_to_your_projects": "",
"basic_compile_time": "",
"before_you_use_error_assistant": "",
@@ -439,6 +453,7 @@
"delete_account": "",
"delete_account_confirmation_label": "",
"delete_account_warning_message_3": "",
"delete_accounts": "",
"delete_acct_no_existing_pw": "",
"delete_and_leave": "",
"delete_and_leave_projects": "",
@@ -469,6 +484,7 @@
"deleted_by_id": "",
"deleted_by_ip": "",
"deleted_by_on": "",
"deleted_projects": "",
"deleted_user": "",
"deleting": "",
"demonstrating_git_integration": "",
@@ -688,6 +704,7 @@
"files_cannot_include_invalid_characters": "",
"files_selected": "",
"filter_projects": "",
"filter_users": "",
"find": "",
"find_and_check_citations": "",
"find_out_how_to_get_the_most_out_of_your_new_subscription": "",
@@ -945,6 +962,7 @@
"imported_from_zotero_at_date": "",
"importing": "",
"importing_and_merging_changes_in_github": "",
"inactive_projects": "",
"in_order_to_match_institutional_metadata_2": "",
"in_order_to_match_institutional_metadata_associated": "",
"include_caption": "",
@@ -1238,11 +1256,13 @@
"need_to_add_new_primary_before_remove": "",
"need_to_leave": "",
"neither_agree_nor_disagree": "",
"never": "",
"new_compile_domain_notice": "",
"new_compiles_in_this_project_will_automatically_use_the_newest_version": "",
"new_file": "",
"new_folder": "",
"new_name": "",
"new_owner": "",
"new_password": "",
"new_project": "",
"new_reference": "",
@@ -1290,6 +1310,7 @@
"no_symbols_found": "",
"no_templates_found": "",
"no_thanks_cancel_now": "",
"no_users": "",
"non_blinking_cursor": "",
"normal": "",
"normally_x_price_per_month": "",
@@ -1303,6 +1324,7 @@
"notification_personal_and_group_subscriptions": "",
"notification_project_invite_accepted_message": "",
"notification_project_invite_message": "",
"notify_users_about_account_deletion": "",
"number_of_users": "",
"numbered_list": "",
"oauth_orcid_description": "",
@@ -1360,7 +1382,9 @@
"overwrite": "",
"overwriting_the_original_folder": "",
"owned_by_x": "",
"owned_projects": "",
"owner": "",
"ownership_of_projects_will_be_transferred": "",
"page_current": "",
"page_x_of_n": "",
"pagination_navigation": "",
@@ -1420,6 +1444,8 @@
"per_month_x_annually": "",
"per_year": "",
"percent_is_the_percentage_of_the_line_width": "",
"permanently_delete_accounts": "",
"permanently_delete_projects": "",
"permanently_disables_the_preview": "",
"personal_library": "",
"pick_up_where_you_left_off": "",
@@ -1538,6 +1564,7 @@
"publisher_account": "",
"publishing": "",
"pull_github_changes_into_sharelatex": "",
"purge": "",
"push_sharelatex_changes_to_github": "",
"push_to_github_pull_to_overleaf": "",
"quoted_text": "",
@@ -1636,6 +1663,7 @@
"repository_visibility": "",
"republish": "",
"resend": "",
"resend_activation_email": "",
"resend_confirmation_code": "",
"resend_group_invite": "",
"resend_invite": "",
@@ -1648,6 +1676,8 @@
"resolve_comment_error_title": "",
"resolved_comments": "",
"restore": "",
"restore_accounts": "",
"restore_projects": "",
"restore_file": "",
"restore_file_confirmation_message": "",
"restore_file_confirmation_title": "",
@@ -1657,6 +1687,8 @@
"restore_project_to_this_version": "",
"restore_this_version": "",
"restoring": "",
"resume": "",
"resume_account": "",
"resync_completed": "",
"resync_message": "",
"resync_project_history": "",
@@ -1748,6 +1780,7 @@
"select_all": "",
"select_all_entries": "",
"select_all_projects": "",
"select_all_users": "",
"select_an_output_file": "",
"select_an_output_file_figure_modal": "",
"select_bib_file": "",
@@ -1764,6 +1797,7 @@
"select_image_from_project_files": "",
"select_project": "",
"select_projects": "",
"select_users": "",
"select_size": "",
"select_tag": "",
"select_tax_id_type": "",
@@ -1776,6 +1810,7 @@
"send": "",
"send_confirmation_code": "",
"send_message": "",
"send_notification_emails_to_users": "",
"send_request": "",
"sending": "",
"sentence_completion": "",
@@ -1788,6 +1823,8 @@
"session_expired_redirecting_to_login": "",
"sessions": "",
"set_as_main_document": "",
"set_admin": "",
"set_admin_account": "",
"set_color": "",
"set_column_width": "",
"set_up_single_sign_on": "",
@@ -1803,6 +1840,7 @@
"sharing_permissions": "",
"shortcut_to_open_advanced_reference_search": "",
"show_all_projects": "",
"show_all_users": "",
"show_breadcrumbs": "",
"show_document_preamble": "",
"show_equation_preview": "",
@@ -1816,6 +1854,7 @@
"show_outline": "",
"show_version_history": "",
"show_x_more_projects": "",
"show_x_more_users": "",
"showing_1_result": "",
"showing_1_result_of_total": "",
"showing_pdf_preview_with_inverted_colors": "",
@@ -1825,6 +1864,7 @@
"showing_x_results_of_total": "",
"sidebar": "",
"sign_up": "",
"signed_up": "",
"simple_search_mode": "",
"single_sign_on_sso": "",
"size": "",
@@ -1959,6 +1999,9 @@
"sure_you_want_to_change_plan": "",
"sure_you_want_to_delete": "",
"sure_you_want_to_leave_group": "",
"suspend": "",
"suspend_account": "",
"switch_between_dark_and_light_mode": "",
"switch_compile_mode_for_faster_draft_compilation": "",
"switch_to_editor": "",
"switch_to_pdf": "",
@@ -2045,6 +2088,7 @@
"they_will_retain_ownership_of_projects_currently_owned_by_them_and_collaborators_will_become_read_only": "",
"they_will_retain_their_existing_account_on_the_free_plan": "",
"they_wont_be_able_to_log_in_with_sso_they_will_need_to_set_password": "",
"this_action_can_be_undone_within_limited_period": "",
"this_action_cannot_be_reversed": "",
"this_action_cannot_be_undone": "",
"this_action_will_also_disable_domain_capture": "",
@@ -2166,6 +2210,7 @@
"track_changes": "",
"tracked_change_added": "",
"tracked_change_deleted": "",
"transfer_all_projects_to": "",
"transfer_management_of_your_account": "",
"transfer_management_of_your_account_to_x": "",
"transfer_management_resolve_following_issues": "",
@@ -2177,6 +2222,7 @@
"trashed": "",
"trashed_projects": "",
"trashing_projects_wont_affect_collaborators": "",
"trashing_projects_wont_affect_user_collaborators": "",
"trial_last_day": "",
"trial_remaining_days": "",
"tried_to_log_in_with_email": "",
@@ -2242,6 +2288,8 @@
"unpausing": "",
"unpublish": "",
"unpublishing": "",
"unset_admin": "",
"unset_admin_account": "",
"unsubscribe": "",
"until_then_you_can_still": "",
"untrash": "",
@@ -2288,6 +2336,17 @@
"used_latex_response_occasionally": "",
"used_latex_response_often": "",
"used_when_referring_to_the_figure_elsewhere_in_the_document": "",
"user_activity": "",
"user_categories": "",
"user_category_admin": "",
"user_category_all": "",
"user_category_deleted": "",
"user_category_inactive": "",
"user_category_ldap": "",
"user_category_local": "",
"user_category_oidc": "",
"user_category_saml": "",
"user_category_suspended": "",
"user_deletion_error": "",
"user_deletion_password_reset_tip": "",
"user_email_attribute": "",
@@ -2296,6 +2355,7 @@
"user_last_name_attribute": "",
"user_management": "",
"user_sessions": "",
"users_list": "",
"using_latex": "",
"using_premium_features": "",
"using_the_overleaf_editor": "",

View File

@@ -5,6 +5,7 @@
export default /** @type {const} */ ([
'account_balance',
'add_moderator',
'arrow_back_ios_new',
'arrow_circle_up',
'auto_delete',
@@ -28,6 +29,7 @@ export default /** @type {const} */ ([
'domain',
'edit',
'edit_square',
'edit',
'error',
'experiment',
'find_replace',
@@ -39,7 +41,7 @@ export default /** @type {const} */ ([
'help',
'image',
'info',
'info',
'info_i',
'integration_instructions',
'lightbulb',
'lightbulb_2',
@@ -53,12 +55,16 @@ export default /** @type {const} */ ([
'notifications',
'open_in_new',
'password',
'pause_circle',
'person',
'person_edit',
'picture_as_pdf',
'play_circle',
'push_pin',
'rate_review',
'remove_moderator',
'report',
'restore_from_trash',
'search',
'settings',
'shuffle',

View File

@@ -32,6 +32,7 @@ function DropdownItem(
description,
leadingIcon,
trailingIcon,
unfilled,
...props
}: DropdownItemProps,
ref: React.ForwardedRef<typeof BS5DropdownItem>
@@ -41,7 +42,7 @@ function DropdownItem(
if (typeof leadingIcon === 'string') {
leadingIconComponent = (
<MaterialIcon
className="dropdown-item-leading-icon"
className={classnames('dropdown-item-leading-icon', {unfilled})}
type={leadingIcon}
/>
)
@@ -61,7 +62,7 @@ function DropdownItem(
trailingIconComponent = (
<MaterialIcon
className="dropdown-item-trailing-icon"
className={classnames('dropdown-item-trailing-icon', {unfilled})}
type={trailingIconType}
/>
)

View File

@@ -45,7 +45,7 @@ export default function AdminMenu({
) : null}
{canDisplayProjectUrlLookup ? (
<NavDropdownLinkItem href="/admin/project">
Project URL Lookup
Manage Projects
</NavDropdownLinkItem>
) : null}
{canDisplayAdminRedirect && adminUrl ? (

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -9,3 +9,5 @@
@import 'symbol-palette';
@import 'writefull';
@import 'labs';
@import 'admin-panel/user-list';
@import 'admin-panel/user-list-ds-nav';

View File

@@ -25,6 +25,7 @@
"a_new_version_of_the_rolling_texlive_build_released": "Eine neue Version des Rolling TeX Live Build wurde veröffentlicht.",
"about": "Über uns",
"about_to_archive_projects": "Du bist dabei, die folgenden Projekte zu archivieren:",
"about_to_delete_accounts": "Du bist dabei, die Konten der folgenden Benutzer einschließlich aller ihrer Projekte zu löschen:",
"about_to_delete_cert": "Du bist dabei, das folgende Zertifikat zu löschen:",
"about_to_delete_projects": "Du bist dabei, die folgenden Projekte zu löschen:",
"about_to_delete_tag": "Du bist dabei, das folgende Stichwort zu löschen (darin enthaltene Projekte werden nicht gelöscht):",
@@ -35,8 +36,17 @@
"about_to_enable_managed_users": "Wenn du Verwaltete Benutzer aktivierst, werden alle Mitglieder deines Abonnements eingeladen, verwaltet zu werden. Dies gibt dir Administratorenrechte für ihre Konten. Du kannst dann auch neue verwaltete Mitglieder einladen.",
"about_to_leave_project": "Du bist dabei, dieses Projekt zu verlassen.",
"about_to_leave_projects": "Du bist dabei, die folgenden Projekte zu verlassen:",
"about_to_permanently_delete_accounts": "Du bist dabei, die folgenden Konten endgültig zu löschen:",
"about_to_permanently_delete_projects": "Du bist dabei, die folgenden Projekte endgültig zu löschen:",
"about_to_remove_user_preamble": "Du bist dabei, __userName__ (__userEmail__) zu entfernen. Das bedeutet:",
"about_to_resend_activation_email": "Du bist dabei, die Aktivierungs-E-Mail an die folgenden Nutzer erneut zu senden:",
"about_to_restore_accounts": "Du bist dabei, die folgenden Benutzerkonten wiederherzustellen:",
"about_to_restore_projects": "Du bist dabei, die folgenden Projekte wiederherzustellen:",
"about_to_resume_accounts": "Du bist dabei, den Zugriff für die folgenden Benutzer wieder freizugeben:",
"about_to_set_admin_accounts": "Du bist dabei, Administratorrechte für die folgenden Benutzer zu vergeben:",
"about_to_suspend_accounts": "Du bist dabei, die folgenden Konten zu sperren:",
"about_to_trash_projects": "Du bist dabei, die folgenden Projekte in den Papierkorb zu verschieben:",
"about_to_unset_admin_accounts": "Du bist dabei, Administratorrechte bei den folgenden Benutzern zu entziehen:",
"abstract": "Abstrakt",
"accept": "Akzeptieren",
"accept_all_cookies": "Alle Cookies akzeptieren",
@@ -62,6 +72,7 @@
"account_has_been_link_to_institution_account": "Dein __appName__-Konto mit der E-Mail-Adresse <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_help": "Konto und Hilfe",
"account_information": "Kontoinformationen",
"account_linking": "Kontoverknüpfung",
"account_managed_by_group_administrator": "Dein Konto wird von deinem Gruppenverwalter (__admin__) verwaltet.",
"account_managed_by_group_teamname": "Dieses __appName__-Konto wird von <0>__teamName__</0> verwaltet.",
@@ -73,6 +84,7 @@
"activate": "Aktivieren",
"activate_account": "Aktiviere dein Konto",
"activating": "Aktivierung",
"activation_link": "Aktivierungslink",
"activation_token_expired": "Dein Aktivierungs-Token ist abgelaufen, bitte fordere einen neuen an.",
"active": "Aktiv",
"add": "Hinzufügen",
@@ -163,6 +175,7 @@
"all_templates": "Alle Vorlagen",
"all_the_pros_of_our_standard_plan_plus_unlimited_collab": "Alle Vorteile unseres Standard-Abonnements, plus unbegrenzt viele Mitarbeiter pro Projekt.",
"all_these_experiments_are_available_exclusively": "All diese Experimente sind exklusiv für Mitglieder des Labs-Programms verfügbar. Wenn du dich anmeldest, kannst du auswählen, welche Experimente du ausprobieren möchtest.",
"all_users": "Alle Benutzer",
"allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "Ermöglicht die Suche nach Autor, Titel usw. Ergebnisse können direkt aus deinem Literaturverwaltungsprogramm (falls verbunden) übernommen werden.",
"already_have_an_account": "Hast du bereits ein Konto?",
"already_have_sl_account": "Hast du bereits ein __appName__-Konto?",
@@ -225,6 +238,7 @@
"back_to_editor": "Zurück zum Editor",
"back_to_log_in": "Zurück zur Anmeldung",
"back_to_subscription": "Zurück zum Abonnement",
"back_to_user_list": "Zurück zur Benutzerliste",
"back_to_your_projects": "Zurück zu deinen Projekten",
"beta": "Beta",
"beta_feature_badge": "Betafunktionsmerkmal",
@@ -393,6 +407,7 @@
"coupon_code_is_not_valid_for_selected_plan": "Der Gutscheincode ist nicht gültig für das gewählte Abonnement",
"coupons_not_included": "Dies beinhaltet nicht deine aktuellen Rabatte, die automatisch vor deiner nächsten Zahlung angewandt werden",
"create": "Erstellen",
"create_account": "Konto erstellen",
"create_a_new_password_for_your_account": "Erstelle ein neues Passwort für dein Konto",
"create_a_new_project": "Erstelle ein neues Projekt",
"create_account": "Konto erstellen",
@@ -430,6 +445,7 @@
"delete_account": "Konto löschen",
"delete_account_confirmation_label": "Ich verstehe, dass dadurch alle Projekte in meinem __appName__-Konto mit der E-Mail-Adresse <0>__userDefaultEmail__</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_accounts": "Konten löschen",
"delete_acct_no_existing_pw": "Bitte verwende das Formular zum Zurücksetzen des Passworts, um ein Passwort festzulegen, bevor du dein Konto löschst",
"delete_and_leave": "Löschen/Verlassen",
"delete_and_leave_projects": "Projekte löschen und verlassen",
@@ -438,14 +454,15 @@
"delete_certificate": "Zertifikat löschen",
"delete_comment": "Kommentar löschen",
"delete_figure": "Abbildung löschen",
"delete_projects": "Projekte löschen",
"delete_tag": "Stichwort löschen",
"delete_template": "Vorlage löschen",
"delete_token": "Token löschen",
"delete_projects": "Projekte löschen",
"delete_user": "Nutzer löschen",
"delete_your_account": "Lösche dein Konto",
"deleted_at": "Gelöscht am",
"deleted_by_on": "Gelöscht von __name__ am __date__",
"deleted_projects": "Gelöschte Projekte",
"deleting": "Löschen",
"demonstrating_git_integration": "Demonstration der Git-Integration",
"department": "Abteilung",
@@ -600,6 +617,7 @@
"files_cannot_include_invalid_characters": "Der Dateiname ist leer oder enthält ungültige Zeichen",
"files_selected": "Dateien ausgewählt.",
"filter_projects": "Projekte filtern",
"filter_users": "Benutzer filtern",
"filters": "Filter",
"find_out_more": "Finde mehr heraus",
"find_out_more_about_institution_login": "Erfahre mehr über den institutionellen Login",
@@ -776,6 +794,7 @@
"in_order_to_have_a_secure_account_make_sure_your_password": "Um dein Konto abzusichern, stelle sicher, dass dein Passwort",
"in_order_to_match_institutional_metadata_2": "Um deine institutionellen Metadaten abzugleichen, haben wir dein Konto mit <0>__email__</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.",
"inactive_projects": "Inaktive Projekte",
"include_caption": "Beschriftung anzeigen",
"include_label": "Label anzeigen",
"increased_compile_timeout": "Zeitlimit beim Kompilieren erhöhen",
@@ -1016,10 +1035,12 @@
"need_contact_group_admin_to_make_changes": "Du musst deinen Gruppenadministrator kontaktieren, wenn du bestimmte Änderungen an deinem Konto vornehmen möchtest. <0>Erfahre mehr über verwaltete Benutzer.</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_leave": "Du musst gehen?",
"never": "Nie",
"new_editor": "Neuer Editor",
"new_file": "Neue Datei",
"new_folder": "Neuer Ordner",
"new_name": "Neuer Name",
"new_owner": "Neuer Besitzer",
"new_password": "Neues Passwort",
"new_project": "Neues Projekt",
"new_snippet_project": "Ohne Titel",
@@ -1057,6 +1078,7 @@
"no_templates_found": "Keine Vorlagen gefunden.",
"no_thanks_cancel_now": "Nein, danke - Ich möchte nach wie vor jetzt stornieren",
"no_update_email": "Nein, E-Mail-Adresse aktualisieren",
"no_users": "Keine Benutzer",
"non_blinking_cursor": "Nicht blinkender Cursor",
"normal": "Normal",
"normally_x_price_per_month": "Normalerweise __price__ pro Monat",
@@ -1072,6 +1094,7 @@
"notification_personal_subscription_not_required_due_to_affiliation": "Gute Nachrichten! Deine angeschlossene Organisation __institutionName__ hat eine Partnerschaft mit Overleaf und du hast jetzt über deine Zugehörigkeit Zugriff auf die „Professionell“-Funktionen von Overleaf. Du kannst dein persönliches Abonnement kündigen, o",
"notification_project_invite_accepted_message": "Du bist <b>__projectName__</b> beigetreten",
"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",
"number_collab": "Anzahl der Mitarbeiter",
"number_of_users": "Nutzeranzahl",
@@ -1106,7 +1129,9 @@
"overleaf_learning_center": "Overleaf-Lernzentrum",
"overview": "Überblick",
"owned_by_x": "Besitzer: __x__",
"owned_projects": "Eigene Projekte",
"owner": "Besitzer",
"ownership_of_projects_will_be_transferred": "Der Besitz der folgenden Projekte wird auf einen anderen Benutzer übertragen:",
"page_current": "Seite __page__, Aktuelle Seite",
"page_not_found": "Seite nicht gefunden",
"pagination_navigation": "Seitenumbruch-Navigation",
@@ -1136,6 +1161,8 @@
"per_month": "pro Monat",
"per_user_year": "pro Nutzer / Jahr",
"per_year": "pro Jahr",
"permanently_delete_accounts": "Konten endgültig löschen",
"permanently_delete_projects": "Projekte endgültig löschen:",
"personal": "Persönlich",
"personalized_onboarding": "Personalisiertes Onboarding",
"pl": "Polnisch",
@@ -1211,6 +1238,7 @@
"publish_as_template": "Als Vorlage veröffentlichen",
"publishing": "Veröffentlichen",
"pull_github_changes_into_sharelatex": "GitHub-Änderungen nach __appName__ ziehen",
"purge": "Vernichten",
"push_sharelatex_changes_to_github": "__appName__-Änderungen an GitHub senden",
"raw_logs": "Raw Logs",
"raw_logs_description": "Raw Logs vom LaTeX-Compiler",
@@ -1278,6 +1306,7 @@
"requesting_password_reset": "Zurücksetzen des Passworts anfordern",
"required": "Erforderlich",
"resend": "Sende erneut",
"resend_activation_email": "Aktivierungs-E-Mail erneut senden",
"resend_confirmation_code": "Bestätigungscode erneut senden",
"resend_managed_user_invite": "Einladung zu Verwaltete Benutzer erneut senden",
"resending_confirmation_code": "Bestätigungscode wird erneut gesendet",
@@ -1286,9 +1315,13 @@
"resolve": "Lösen",
"resolved_comments": "Gelöste Kommentare",
"restore": "Wiederherstellen",
"restore_accounts": "Konten wiederherstellen",
"restore_projects": "Projekte wiederherzustellen:",
"restoring": "Wiederherstellen",
"restricted": "Geschützt",
"restricted_no_permission": "Entschuldigung, du hast nicht die Berechtigung, diese Seite anzuzeigen.",
"resume": "Freigeben",
"resume_account": "Konto wieder freigeben",
"return_to_login_page": "Zurück zur Login-Seite",
"reverse_x_sort_order": "Sortierreihenfolge für __x__ umkehren",
"revert_pending_plan_change": "Abonnement-Änderung rückgängig machen",
@@ -1328,8 +1361,10 @@
"security": "Sicherheit",
"see_your_current_location_in_the_project": "Zeige deinen aktuellen Standort im Projekt an",
"select_a_file": "Datei auswählen",
"select_a_new_owner_for_projects": "Neuen Besitzer für die Projekte dieses Benutzers auswählen",
"select_a_project": "Projekt auswählen",
"select_all_projects": "Alle Projekte auswählen",
"select_all_users": "Alle Benutzer auswählen",
"select_an_output_file": "Ausgabedatei auswählen",
"select_color": "Farbe __name__ auswählen",
"select_from_output_files": "aus Ausgabedateien auswählen",
@@ -1338,9 +1373,12 @@
"select_project": "__project__ auswählen",
"select_projects": "Projekte auswählen",
"select_tag": "Stichwort __tagName__ auswählen",
"select_user": "Benutzer auswählen",
"select_users": "Benutzer auswählen",
"selected": "Ausgewählt",
"selected_by_overleaf_staff": "Ausgewählt von Overleaf-Mitarbeitern",
"send": "Absenden",
"send_notification_emails_to_users": "Eine Benachrichtigung an den aktuellen und den neuen Besitzer senden",
"send_confirmation_code": "Bestätigungscode senden",
"send_test_email": "Test-Mail senden",
"sending": "Wird gesendet",
@@ -1351,6 +1389,8 @@
"session_error": "Sitzungsfehler. Bitte überprüfe, ob Cookies aktiviert sind. Wenn das Problem weiterhin besteht, versuche, deinen Cache und deine Cookies zu löschen.",
"session_expired_redirecting_to_login": "Sitzung abgelaufen. Du wirst in __seconds__ Sekunden auf die Anmeldungsseite umgeleitet",
"sessions": "Sessions",
"set_admin": "Admin setzen",
"set_admin_account": "Administratorrechte vergeben",
"set_color": "Farbe festlegen",
"set_new_password": "Neues Passwort eingeben",
"set_password": "Passwort setzen",
@@ -1361,19 +1401,23 @@
"shared_with_you": "Mit dir geteilt",
"sharelatex_beta_program": "__appName__ Beta-Programm",
"show_all_projects": "Alle Projekte anzeigen",
"show_all_users": "Alle Benutzer anzeigen",
"show_in_code": "Im Code anzeigen",
"show_in_pdf": "Im PDF anzeigen",
"show_less": "Weniger anzeigen",
"show_live_equation_previews_while_typing": "Live-Gleichungsvorschau beim Tippen anzeigen",
"show_outline": "Dateigliederung anzeigen",
"show_x_more_projects": "__x__ weitere Projekte anzeigen",
"show_x_more_users": "__x__ weitere Benutzer anzeigen",
"showing_1_result": "1 Ergebnis wird angezeigt",
"showing_1_result_of_total": "Zeige 1 Ergebnis von __total__",
"showing_x_out_of_n_projects": "Es werden __x__ von __n__ Projekten angezeigt.",
"showing_x_out_of_n_users": "Es werden __x__ von __n__ Benutzern angezeigt",
"showing_x_results": "Es werden __x__ Ergebnisse angezeigt",
"showing_x_results_of_total": "Es werden __x__ Ergebnisse von __total__ angezeigt",
"single_sign_on_sso": "Single Sign-On (SSO)",
"site_description": "Ein einfach bedienbarer Online-LaTeX-Editor. Keine Installation notwendig, Zusammenarbeit in Echtzeit, Versionskontrolle, Hunderte von LaTeX-Vorlagen und mehr",
"signed_up": "Angemeldet",
"skip_to_content": "Zum Inhalt springen",
"solutions": "Lösungen",
"something_went_wrong_canceling_your_subscription": "Beim Kündigen deines Abonnements ist etwas schief gelaufen. Bitte wende dich an den Support.",
@@ -1429,6 +1473,8 @@
"sure_you_want_to_change_plan": "Bist du sicher, dass du zum Abonnement <0>__planName__</0> wechseln möchtest?",
"sure_you_want_to_delete": "Möchtest du die folgenden Dateien wirklich löschen?",
"sure_you_want_to_leave_group": "Bist du sicher, dass du diese Gruppe verlassen möchtest?",
"suspend": "Sperren",
"suspend_account": "Konto sperren",
"sv": "Schwedisch",
"switch_compile_mode_for_faster_draft_compilation": "Kompiliermodus für schnellere Entwurfskompilierung wechseln",
"symbol_palette": "Symbolpalette",
@@ -1486,6 +1532,7 @@
"there_was_an_error_opening_your_content": "Beim Erstellen deines Projekts ist ein Fehler aufgetreten",
"these_settings_might_change_in_the_future": "Diese Einstellungen können sich in Zukunft ändern.",
"thesis": "Doktorarbeit",
"this_action_can_be_undone_within_limited_period": "Diese Aktion kann nur innerhalb eines begrenzten Zeitraums rückgängig gemacht werden.",
"this_action_cannot_be_undone": "Diese Aktion kann nicht rückgängig gemacht werden.",
"this_field_is_required": "Dieses Feld wird benötigt",
"this_grants_access_to_features_2": "Dadurch erhältst du Zugriff auf die <0>__featureType__</0> Funktionen von <0>__appName__</0>.",
@@ -1526,6 +1573,7 @@
"track_changes": "Änderungen verfolgen",
"tracked_change_added": "Hinzugefügt",
"tracked_change_deleted": "Gelöscht",
"transfer_all_projects_to": "Alle Projekte der Benutzer übertragen an:",
"trash": "Löschen",
"trash_projects": "Lösche Projekte",
"trashed_projects": "Gelöschte Projekte",
@@ -1579,6 +1627,8 @@
"unlinking": "Verknüpfung wird aufgehoben",
"unpublish": "Veröffentlichung aufheben",
"unpublishing": "Veröffentlichung aufheben",
"unset_admin": "Admin entfernen",
"unset_admin_account": "Administratorrechte entziehen",
"unsubscribe": "Abbestellen",
"unsubscribed": "Abbestellt",
"unsubscribing": "Abbestellen läuft",
@@ -1602,12 +1652,24 @@
"use_a_different_email": "Verwende eine <0>andere E-Mail-Adresse</0>.",
"use_a_different_password": "Bitte verwende ein anderes Passwort",
"use_your_own_machine": "Verwende deine eigene Maschine mit deinem eigenen Setup",
"user_activity": "Benutzeraktivität",
"user_already_added": "Nutzer bereits hinzugefügt",
"user_categories": "Benutzerkategorien",
"user_category_admin": "Administratoren",
"user_category_all": "Alle Benutzer",
"user_category_deleted": "Gelöschte Benutzer",
"user_category_inactive": "Inaktive Benutzer",
"user_category_ldap": "LDAP-Benutzer",
"user_category_local": "Lokale Benutzer",
"user_category_oidc": "OIDC-Benutzer",
"user_category_saml": "SAML-Benutzer",
"user_category_suspended": "Gesperrte Benutzer",
"user_deletion_error": "Entschuldigung, beim Löschen deines Kontos ist etwas schief gelaufen. Bitte versuche es in einer Minute erneut.",
"user_deletion_password_reset_tip": "Wenn du dich nicht mehr an dein Passwort erinnern kannst oder wenn du Single-Sign-On mit einem anderen Anbieter verwendest, um dich anzumelden (z.B. ORCID oder Google), <0>setze dein Passwort zurück</0> und versuche es erneut.",
"user_management": "Nutzerverwaltung",
"user_not_found": "Nutzer wurde nicht gefunden",
"user_wants_you_to_see_project": "__username__ möchte, dass Du __projectname__ beitreten",
"users_list": "Benutzerliste",
"validation_issue_entry_description": "Ein Validierungsproblem, das die Kompilierung dieses Projekts verhindert hat",
"vat": "MwSt.",
"vat_number": "Umsatzsteuernummer",

View File

@@ -26,6 +26,7 @@
"a_new_version_of_the_rolling_texlive_build_released": "A new version of the Rolling TeX Live build has been released.",
"about": "About",
"about_to_archive_projects": "You are about to archive the following projects:",
"about_to_delete_accounts": "You are about to delete the accounts of the following users, including all their projects:",
"about_to_delete_cert": "You are about to delete the following certificate:",
"about_to_delete_projects": "You are about to delete the following projects:",
"about_to_delete_tag": "You are about to delete the following tag (any projects in them will not be deleted):",
@@ -36,8 +37,17 @@
"about_to_enable_managed_users": "By enabling the Managed Users feature, all existing members of your group subscription will be invited to become managed. This will give you admin rights over their account. You will also have the option to invite new members to join the subscription and become managed.",
"about_to_leave_project": "You are about to leave this project.",
"about_to_leave_projects": "You are about to leave the following projects:",
"about_to_permanently_delete_accounts": "You are about to permanently delete the following accounts:",
"about_to_permanently_delete_projects": "You are about to permanently delete the following projects:",
"about_to_remove_user_preamble": "Youre about to remove __userName__ (__userEmail__). Doing this will mean:",
"about_to_resend_activation_email": "You are about to resend the activation email to the following users:",
"about_to_restore_accounts": "You are about to restore the following user accounts:",
"about_to_restore_projects": "You are about to restore the following projects:",
"about_to_resume_accounts": "You are about to resume access for the following users:",
"about_to_set_admin_accounts": "You are about to grant admin privileges to the following users:",
"about_to_suspend_accounts": "You are about to suspend the following accounts:",
"about_to_trash_projects": "You are about to trash the following projects:",
"about_to_unset_admin_accounts": "You are about to revoke admin privileges from the following users:",
"abstract": "Abstract",
"accept": "Accept",
"accept_all_cookies": "Accept all cookies",
@@ -66,6 +76,7 @@
"account_has_been_link_to_institution_account": "Your __appName__ account on <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_help": "Account and help",
"account_information": "Account information",
"account_linking": "Account linking",
"account_managed_by_group_administrator": "Your account is managed by your group administrator (__admin__)",
"account_managed_by_group_teamname": "This __appName__ account is managed by <0>__teamName__</0>.",
@@ -77,6 +88,7 @@
"activate": "Activate",
"activate_account": "Activate your account",
"activating": "Activating",
"activation_link": "Activation link",
"activation_token_expired": "Your activation token has expired, you will need to get another one sent to you.",
"active": "Active",
"add": "Add",
@@ -185,6 +197,7 @@
"all_templates": "All templates",
"all_the_pros_of_our_standard_plan_plus_unlimited_collab": "All the pros of our standard plan, plus unlimited collaborators per project.",
"all_these_experiments_are_available_exclusively": "All these experiments are available exclusively to members of the Labs program. If you sign up, you can choose which experiments you want to try.",
"all_users": "All users",
"allocate_license": "Allocate license",
"allows_to_search_by_author_title_etc_possible_to_pull_results_directly_from_your_reference_manager_if_connected": "Allows to search by author, title, etc. Possible to pull results directly from your reference manager (if connected).",
"already_have_an_account": "Already have an account?",
@@ -264,6 +277,7 @@
"back_to_my_projects": "Back to my projects",
"back_to_pdf": "Back to PDF",
"back_to_subscription": "Back to subscription",
"back_to_user_list": "Back to user list",
"back_to_your_projects": "Back to your projects",
"basic": "Basic",
"basic_ai_allowance": "Basic AI allowance",
@@ -588,6 +602,7 @@
"delete_account": "Delete account",
"delete_account_confirmation_label": "I understand this will delete all projects in my __appName__ account with email address <0>__userDefaultEmail__</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_accounts": "Delete accounts",
"delete_acct_no_existing_pw": "Please use the password reset form to set a password before deleting your account",
"delete_and_leave": "Delete / Leave",
"delete_and_leave_projects": "Delete and Leave Projects",
@@ -618,6 +633,7 @@
"deleted_by_id": "Deleted By ID",
"deleted_by_ip": "Deleted By IP",
"deleted_by_on": "Deleted by __name__ on __date__",
"deleted_projects": "Deleted projects",
"deleted_user": "Deleted user",
"deleting": "Deleting",
"demonstrating_git_integration": "Demonstrating Git integration",
@@ -903,6 +919,7 @@
"files_cannot_include_invalid_characters": "File name is empty or contains invalid characters",
"files_selected": "files selected.",
"filter_projects": "Filter projects",
"filter_users": "Filter users",
"filters": "Filters",
"find": "Find",
"find_and_check_citations": "Find and check citations",
@@ -1216,6 +1233,7 @@
"in_order_to_have_a_secure_account_make_sure_your_password": "To help keep your account secure, make sure your new password:",
"in_order_to_match_institutional_metadata_2": "In order to match your institutional metadata, weve 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>.",
"inactive_projects": "Inactive projects",
"include_caption": "Include caption",
"include_label": "Include label",
"include_results_from_your_reference_manager": "Include results from your reference manager",
@@ -1623,11 +1641,13 @@
"need_to_add_new_primary_before_remove": "Youll need to add a new primary email address before you can remove this one.",
"need_to_leave": "Need to leave?",
"neither_agree_nor_disagree": "Neither agree nor disagree",
"never": "Never",
"new_compile_domain_notice": "Something might be blocking your browser from accessing Overleafs 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_file": "New file",
"new_folder": "New folder",
"new_name": "New name",
"new_owner": "New owner",
"new_password": "New password",
"new_project": "New project",
"new_reference": "__count__ new reference",
@@ -1683,6 +1703,7 @@
"no_templates_found": "No templates found.",
"no_thanks_cancel_now": "No thanks, I still want to cancel",
"no_update_email": "No, update email",
"no_users": "No users",
"non_blinking_cursor": "Non-blinking cursor",
"non_deletable_entity": "The specified entity may not be deleted",
"normal": "Normal",
@@ -1704,6 +1725,7 @@
"notification_personal_subscription_not_required_due_to_affiliation": " Good news! Your affiliated organization __institutionName__ has an Overleaf subscription, and you now have access to Overleaf premium features through your affiliation. You can cancel your individual subscription without losing access to any features.",
"notification_project_invite_accepted_message": "Youve joined <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",
"number_collab": "Number of collaborators",
"number_collab_info": "The number of people you can invite to work on a project with you. The limit is per project, so you can invite different people to each project.",
@@ -1796,7 +1818,9 @@
"overwrite": "Overwrite",
"overwriting_the_original_folder": "Overwriting the original folder will delete it and all the files it contains.",
"owned_by_x": "owned by __x__",
"owned_projects": "User's projects",
"owner": "Owner",
"ownership_of_projects_will_be_transferred": "Ownership of the following projects will be transferred to another user:",
"page_current": "Page __page__, Current Page",
"page_not_found": "Page Not Found",
"page_x_of_n": "Page __x__ of __n__",
@@ -1873,6 +1897,8 @@
"per_year": "per year",
"percent_is_the_percentage_of_the_line_width": "% is the percentage of the line width",
"performance": "Performance",
"permanently_delete_accounts": "Permanently delete accounts",
"permanently_delete_projects": "Permanently delete projects",
"permanently_disables_the_preview": "Permanently disables the preview",
"personal": "Personal",
"personal_library": "Personal library",
@@ -2020,6 +2046,7 @@
"publisher_account": "Publisher Account",
"publishing": "Publishing",
"pull_github_changes_into_sharelatex": "Pull GitHub changes into __appName__",
"purge": "Purge",
"push_sharelatex_changes_to_github": "Push __appName__ changes to GitHub",
"push_to_github_pull_to_overleaf": "Push to GitHub, pull to __appName__",
"quoted_text": "Quoted text",
@@ -2145,6 +2172,7 @@
"requesting_password_reset": "Requesting password reset",
"required": "Required",
"resend": "Resend",
"resend_activation_email": "Resend activation email",
"resend_confirmation_code": "Resend confirmation code",
"resend_group_invite": "Resend group invite",
"resend_invite": "Resend invite",
@@ -2163,6 +2191,8 @@
"resolved_comments": "Resolved comments",
"resources": "Resources",
"restore": "Restore",
"restore_accounts": "Restore accounts",
"restore_projects": "Restore projects",
"restore_file": "Restore file",
"restore_file_confirmation_message": "Your current file will restore to the version from __date__ at __time__.",
"restore_file_confirmation_title": "Restore this version?",
@@ -2174,6 +2204,8 @@
"restoring": "Restoring",
"restricted": "Restricted",
"restricted_no_permission": "Restricted, sorry you dont have permission to load this page.",
"resume": "Resume",
"resume_account": "Resume account",
"resync_completed": "Resync completed!",
"resync_message": "Resyncing project history can take several minutes depending on the size of the project.",
"resync_project_history": "Resync Project History",
@@ -2239,6 +2271,7 @@
"search_command_replace": "Replace",
"search_in_all_projects": "Search in all projects",
"search_in_archived_projects": "Search in archived projects",
"search_in_deleted_projects": "Search in deleted projects",
"search_in_shared_projects": "Search in projects shared with you",
"search_in_trashed_projects": "Search in trashed projects",
"search_in_your_projects": "Search in your projects",
@@ -2279,6 +2312,7 @@
"select_all": "Select all",
"select_all_entries": "Select all entries",
"select_all_projects": "Select all projects",
"select_all_users": "Select all users",
"select_an_output_file": "Select an Output File",
"select_an_output_file_figure_modal": "Select an output file",
"select_bib_file": "Select .bib file",
@@ -2300,6 +2334,7 @@
"select_tax_id_type": "Select tax ID type",
"select_user": "Select user",
"select_user_role": "Select user role",
"select_users": "Select users",
"selected": "Selected",
"selected_by_overleaf_staff": "Selected by Overleaf staff",
"selected_lowercase": "selected",
@@ -2307,6 +2342,7 @@
"selected_plural": "selected",
"selection_deleted": "Selection deleted",
"send": "Send",
"send_notification_emails_to_users": "Send notification emails to the current and the new owners",
"send_confirmation_code": "Send confirmation code",
"send_message": "Send message",
"send_request": "Send request",
@@ -2326,6 +2362,8 @@
"session_expired_redirecting_to_login": "Session Expired. Redirecting to login page in __seconds__ seconds",
"sessions": "Sessions",
"set_as_main_document": "Set as main document",
"set_admin": "Set admin",
"set_admin_account": "Grant admin privileges",
"set_color": "set color",
"set_column_width": "Set column width",
"set_new_password": "Set new password",
@@ -2344,6 +2382,7 @@
"sharing_permissions": "Sharing permissions",
"shortcut_to_open_advanced_reference_search": "(<strong>__ctrlSpace__</strong> or <strong>__altSpace__</strong>)",
"show_all_projects": "Show all projects",
"show_all_users": "Show all users",
"show_breadcrumbs": "Show breadcrumbs",
"show_document_preamble": "Show document preamble",
"show_equation_preview": "Show equation preview",
@@ -2357,6 +2396,7 @@
"show_outline": "Show File outline",
"show_version_history": "Show version history",
"show_x_more_projects": "Show __x__ more projects",
"show_x_more_users": "Show __x__ more users",
"showing_1_result": "Showing 1 result",
"showing_1_result_of_total": "Showing 1 result of __total__",
"showing_pdf_preview_with_inverted_colors": "Showing PDF preview with inverted colors",
@@ -2368,6 +2408,7 @@
"sign_up": "Sign up",
"sign_up_for_free": "Sign up for free",
"sign_up_for_free_account": "Sign up for a free account and receive regular updates",
"signed_up": "Signed up",
"simple_pricing_for_individuals_and_teams": "Simple pricing for individuals and teams",
"simple_search_mode": "Simple search",
"single_sign_on_sso": "Single Sign-On (SSO)",
@@ -2534,6 +2575,8 @@
"sure_you_want_to_change_plan": "Are you sure you want to change plan to <0>__planName__</0>?",
"sure_you_want_to_delete": "Are you sure you want to permanently delete the following files?",
"sure_you_want_to_leave_group": "Are you sure you want to leave this group?",
"suspend": "Suspend",
"suspend_account": "Suspend account",
"sv": "Swedish",
"switch_compile_mode_for_faster_draft_compilation": "Switch compile mode for faster draft compilation",
"switch_plans_whenever_your_needs_change": "Switch plans whenever your needs change.",
@@ -2653,6 +2696,7 @@
"they_will_retain_ownership_of_projects_currently_owned_by_them_and_collaborators_will_become_read_only": "They will retain ownership of projects currently owned by them and any collaborators on those projects will become read-only.",
"they_will_retain_their_existing_account_on_the_free_plan": "They will retain their existing account on the __appName__ free plan.",
"they_wont_be_able_to_log_in_with_sso_they_will_need_to_set_password": "They wont be able to log in with SSO (if you have this enabled). They will need to set an __appName__ password.",
"this_action_can_be_undone_within_limited_period": "This action can be undone only within a limited period.",
"this_action_cannot_be_reversed": "This action cannot be reversed.",
"this_action_cannot_be_undone": "This action cannot be undone.",
"this_action_will_also_disable_domain_capture": "This action will also disable domain capture.",
@@ -2781,6 +2825,7 @@
"track_changes_explanation": "Make and see track changes.",
"tracked_change_added": "Added",
"tracked_change_deleted": "Deleted",
"transfer_all_projects_to": "Transfer all projects of the users to:",
"transfer_management_of_your_account": "Transfer management of your Overleaf account",
"transfer_management_of_your_account_to_x": "Transfer management of your Overleaf account to __groupName__",
"transfer_management_resolve_following_issues": "To transfer the management of your account, you need to resolve the following issues:",
@@ -2792,6 +2837,7 @@
"trashed": "Trashed",
"trashed_projects": "Trashed projects",
"trashing_projects_wont_affect_collaborators": "Trashing projects wont affect your collaborators.",
"trashing_projects_wont_affect_user_collaborators": "Trashing projects wont affect user collaborators.",
"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",
"tried_to_log_in_with_email": "Youve tried to log in with <b>__email__</b>.",
@@ -2867,6 +2913,8 @@
"unpausing": "Unpausing",
"unpublish": "Unpublish",
"unpublishing": "Unpublishing",
"unset_admin": "Unset admin",
"unset_admin_account": "Revoke admin privileges",
"unsubscribe": "Unsubscribe",
"unsubscribed": "Unsubscribed",
"unsubscribing": "Unsubscribing",
@@ -2919,10 +2967,21 @@
"used_latex_response_occasionally": "Ive used it occasionally",
"used_latex_response_often": "I use it often",
"used_when_referring_to_the_figure_elsewhere_in_the_document": "Used when referring to the figure elsewhere in the document",
"user_activity": "User activity",
"user_administration": "User administration",
"user_administration_and_usage_metrics": "User administration and usage metrics",
"user_administration_explanation": "Dashboard for adding and removing users on a subscription, and usage metrics",
"user_already_added": "User already added",
"user_categories": "User categories",
"user_category_admin": "Administrators",
"user_category_all": "All users",
"user_category_deleted": "Deleted users",
"user_category_inactive": "Inactive users",
"user_category_ldap": "LDAP users",
"user_category_local": "Local users",
"user_category_oidc": "OIDC users",
"user_category_saml": "SAML users",
"user_category_suspended": "Suspended users",
"user_deletion_error": "Sorry, something went wrong deleting your account. Please try again in a minute.",
"user_deletion_password_reset_tip": "If you cannot remember your password, or if you are using Single-Sign-On with another provider to sign in (such as ORCID or Google), please <0>reset your password</0> and try again.",
"user_email_attribute": "User email attribute",
@@ -2935,6 +2994,7 @@
"user_not_found": "User not found",
"user_sessions": "User Sessions",
"user_wants_you_to_see_project": "__username__ would like you to join __projectname__",
"users_list": "Users list",
"using_latex": "Using LaTeX",
"using_premium_features": "Using premium features",
"using_the_overleaf_editor": "Using the __appName__ Editor",

View File

@@ -17,6 +17,7 @@
"a_more_comprehensive_list_of_keyboard_shortcuts": "Более полный лист горячих клавиш находится в <0>этом шаблоне проекта __appName__</0>",
"about": "О",
"about_to_archive_projects": "Вы собираетесь архивировать следующие проекты:",
"about_to_delete_accounts": "Вы собираетесь удалить аккаунты следующих пользователей, включая все их проекты:",
"about_to_delete_cert": "Вы собираетесь удалить следующий сертификат:",
"about_to_delete_projects": "Следующие проекты будут удалены:",
"about_to_delete_template": "Следующий шаблон будет удален:",
@@ -24,6 +25,16 @@
"about_to_delete_the_following_projects": "Вы собираетесь удалить следующие проекты:",
"about_to_leave_project": "Вы собираетесь покинуть этот проект:",
"about_to_leave_projects": "Вы собираетесь покинуть следующие проекты:",
"about_to_permanently_delete_accounts": "Вы собираетесь окончательно удалить следующие аккаунты:",
"about_to_permanently_delete_projects": "Вы собираетесь окончательно удалить следующие проекты:",
"about_to_resend_activation_email": "Вы собираетесь повторно отправить письмо активации следующим пользователям:",
"about_to_restore_accounts": "Вы собираетесь восстановить следующие учетные записи:",
"about_to_restore_projects": "Вы собираетесь восстановить следующие проекты:",
"about_to_resume_accounts": "Вы собираетесь возобновить доступ к следующим учетным записям:",
"about_to_set_admin_accounts": "Вы собираетесь предоставить административные права следующим пользователям:",
"about_to_suspend_accounts": "Вы собираетесь заблокировать следующие учетные записи:",
"about_to_trash_projects": "Следующие проекты будут перемещены в корзину:",
"about_to_unset_admin_accounts": "Вы собираетесь отозвать административные права у следующих пользователей:",
"abstract": "Аннотация",
"accept": "Принять",
"accept_and_continue": "Принять и продолжить",
@@ -31,12 +42,14 @@
"accept_invitation": "Принять приглашение",
"accepting_invite_as": "Вы принимаете приглашение как",
"account": "Аккаунт",
"account_information": "Информация об аккаунте",
"account_not_linked_to_dropbox": "Ваш аккаунт не синхронизирован с Dropbox",
"account_settings": "Настройки профиля",
"actions": "Действия",
"activate": "Активировать",
"activate_account": "Активируйте Ваш аккаунт",
"activating": "Активация",
"activation_link": "Ссылка для активации",
"activation_token_expired": "Срок действия Вашего ключа истёк. Вам необходимо запросить новый ключ активации.",
"add": "Добавить",
"added": "добавлены",
@@ -45,6 +58,7 @@
"admin": "администратор",
"all_projects": "Все проекты",
"all_templates": "Все шаблоны",
"all_users": "Все пользователи",
"already_have_sl_account": "Уже есть аккаунт __appName__?",
"and": "и",
"annual": "Цена за год",
@@ -54,6 +68,7 @@
"auto_complete": "Автодополнение",
"autocomplete": "Автозавершение",
"autocomplete_references": "Автодополнение ссылок (внутри блока <code>\\cite{}</code>)",
"back_to_user_list": "Назад к списку пользователей",
"back_to_your_projects": "Назад к списку проектов",
"basic": "Базовый",
"basic_compile_timeout_on_fast_servers": "Базовый таймаут компиляции на быстрых серверах",
@@ -112,6 +127,7 @@
"country": "Страна",
"coupon_code": "код купона",
"create": "Создать",
"create_account": "Создать аккаунт",
"create_new_subscription": "Создать новую подписку",
"create_project_in_github": "Создать проект на GitHub",
"creating": "Создание",
@@ -125,10 +141,13 @@
"delete": "Удалить",
"delete_account": "Удалить аккаунт",
"delete_account_warning_message_3": "Вы собираетесь <strong>удалить все данные Вашего аккаунта</strong>, включая все Ваши проекты и настройки. Пожалуйста, введите адрес электронной почты и пароль Вашего аккаунта в форму внизу для продолжения.",
"delete_accounts": "Удалить аккаунты",
"delete_and_leave_projects": "Удалить или оставить проекты",
"delete_projects": "Удалить проекты",
"delete_template": "Удалить шаблон",
"delete_your_account": "Удалить аккаунт",
"deleted_at": "Удалено",
"deleted_projects": "Удаленные проекты",
"deleting": "Удаление",
"disconnected": "Разъединен",
"do_you_want_to_overwrite_it": "Перезаписать?",
@@ -142,7 +161,8 @@
"dropbox_sync_description": "Синхронизируйте Ваши __appName__ проекты с Вашим Dropbox. Изменения в __appName__ автоматически сохраняются в Вашем Dropbox, и наоборот.",
"edit_template": "Редактировать шаблон",
"editing": "Редактор",
"email": "Адрес электронной почты",
"email": "E-mail",
"email_address": "Адрес электронной почты",
"email_already_registered": "Этот адрес уже зарегистрирован.",
"email_link_expired": "Срок действия ссылки истёк. Пожалуйста, повторите запрос!",
"email_or_password_wrong_try_again": "Неверный адрес электронной почты или пароль. Пожалуйста, попробуйте снова",
@@ -157,6 +177,8 @@
"features": "Возможности",
"february": "Февраль",
"files_cannot_include_invalid_characters": "Файлы не могут содержать символы * и /",
"filter_projects": "Фильтровать проекты",
"filter_users": "Фильтровать пользователей",
"first_name": "Имя",
"folders": "Папки",
"footer_about_us": "О нас",
@@ -193,6 +215,7 @@
"import_to_sharelatex": "Импортировать в __appName__",
"importing": "Импорт",
"importing_and_merging_changes_in_github": "Импорт и слияние изменений в GitHub",
"inactive_projects": "Неактивные проекты",
"info": "Информация",
"institution": "Организация",
"invalid_file_name": "Неверное имя файла",
@@ -258,9 +281,11 @@
"nearly_activated": "Вы в одном шаге от активации Вашего аккаунта для __appName__!",
"need_anything_contact_us_at": "Если у Вас есть какие-либо вопросы и пожелания, пожалуйста, пишите нам по адресу",
"need_to_leave": "Удалить аккаунт?",
"never": "Никогда",
"new_file": "Новый файл",
"new_folder": "Новая папка",
"new_name": "Введите название",
"new_owner": "Новый владелец",
"new_password": "Новый пароль",
"new_project": "Создать проект",
"next_payment_of_x_collectected_on_y": "Следующий платёж в размере <0>__paymentAmmount__</0> будет списан <1>__collectionDate__</1>",
@@ -275,8 +300,10 @@
"no_search_results": "Ничего не найдено",
"no_thanks_cancel_now": "Нет, спасибо - я хочу удалить сейчас",
"no_templates_found": "Шаблоны не найдены.",
"no_users": "Нет пользователей",
"normal": "нормальный",
"not_now": "Не сейчас",
"notify_users_about_account_deletion": "Уведомить пользователей об удалении аккаунтов",
"november": "Ноябрь",
"october": "Октябрь",
"off": "Откл.",
@@ -288,7 +315,9 @@
"or": "или",
"other_logs_and_files": "Другие логи и файлы",
"over": "свыше",
"owned_projects": "Собственные проекты",
"owner": "Владелец",
"ownership_of_projects_will_be_transferred": "Владение следующими проектами будет передано другому пользователю:",
"page_not_found": "Страница не найдена",
"password": "Пароль",
"password_reset": "Сбросить пароль",
@@ -296,6 +325,8 @@
"password_reset_token_expired": "Ваш код восстановления пароля истёк. Пожалуйста, запросите восстановление пароля по почте ещё раз и перейдите по ссылке в письме.",
"pdf_viewer": "Просмотрщик PDF",
"pending": "В ожидании",
"permanently_delete_accounts": "Окончательно удалить аккаунты",
"permanently_delete_projects": "Окончательно удалить проекты",
"personal": "Личный",
"pl": "Польский",
"planned_maintenance": "Плановые работы",
@@ -317,6 +348,7 @@
"problem_with_subscription_contact_us": "Возникли проблемы с Вашей подпиской. Пожалуйста, свяжитесь с нами, чтобы узнать подробности.",
"processing": "обработка",
"professional": "Профессионал",
"project_categories_tags": "Категории и теги проектов",
"project_last_published_at": "В последний раз проект был опубликован",
"project_name": "Название проекта",
"project_not_linked_to_github": "Этот проект не связан ни с одним проектом на GitHub. Вы можете создать для него проект на GitHub:",
@@ -324,12 +356,14 @@
"project_too_large_please_reduce": "В этом проекте слишком много текста. Пожалуйста, попробуйте уменьшить количество.",
"project_url": "URL проекта",
"projects": "Проекты",
"projects_list": "Список проектов",
"pt": "Португальский",
"public": "Открытый",
"publish": "Опубликовать",
"publish_as_template": "Создать шаблон",
"publishing": "Публикация",
"pull_github_changes_into_sharelatex": "Скачать изменения с GitHub в __appName__",
"purge": "Уничтожить",
"push_sharelatex_changes_to_github": "Загрузить изменения из __appName__ на GitHub",
"recent_commits_in_github": "Последние коммиты на GitHub",
"recompile": "Компилировать",
@@ -352,13 +386,18 @@
"republish": "Переопубликовать",
"request_sent_thank_you": "Спасибо, Ваш запрос отправлен!",
"required": "обязательно",
"resend": "Отправить еще раз",
"resend": "Отправить повторно",
"resend_activation_email": "Повторно отправить письмо активации",
"reset_password": "Сбросить пароль",
"reset_your_password": "Сбросить пароль",
"restore": "Восстановить",
"restore_accounts": "Восстановить учетные записи",
"restore_projects": "Восстановить проекты",
"restoring": "Восстановление",
"restricted": "Доступ ограничен",
"restricted_no_permission": "Извините, у Вас недостаточно прав для просмотра данной страницы.",
"resume": "Возобновить",
"resume_account": "Возобновить доступ к учетной записи",
"return_to_login_page": "Вернуться на страницу входа",
"revoke_invite": "Отозвать приглашение",
"ro": "Румынский",
@@ -366,23 +405,41 @@
"ru": "Русский",
"saving": "Сохранение",
"saving_notification_with_seconds": "Сохранение __docname__... (__seconds__ секунд с последнего сохранения)",
"search": "Поиск",
"search_bib_files": "Поиск по автору, названию, году",
"search_projects": "Поиск по проектам",
"search_references": "Поиск .bib файлов в проекте",
"security": "Безопасность",
"select_a_new_owner_for_projects": "Выберите нового владельца для проектов этого пользователя",
"select_all_projects": "Выбрать все проекты",
"select_all_users": "Выбрать всех пользователей",
"select_github_repository": "Выберите проект на GitHub для импорта в __appName__",
"select_project": "Выбрать __project__",
"select_projects": "Выбрать проекты",
"select_user": "Выбрать пользователя",
"select_users": "Выбрать пользователей",
"send_notification_emails_to_users": "Отправить уведовление текущему и новому владельцам",
"september": "Сентябрь",
"server_error": "Ошибка сервера",
"services": "Сервисы",
"session_created_at": "Сессия создана",
"session_expired_redirecting_to_login": "Срок сессии истёк. Перенаправление на страницу входа через __seconds__ секунд(ы)",
"sessions": "Сессии",
"set_admin": "Сделать админом",
"set_admin_account": "Предоставить права администратора",
"set_new_password": "Введите новый пароль",
"set_password": "Установить пароль",
"settings": "Настройки",
"share": "Открыть доступ",
"share_project": "Открыть доступ к проекту",
"shared_with_you": "Доступные мне",
"show_all_projects": "Показать все проекты",
"show_all_users": "Показать всех пользователей",
"show_x_more_projects": "Показать ещё __x__ проектов",
"show_x_more_users": "Показать ещё __x__ пользователей",
"showing_x_out_of_n_projects": "Показано __x__ из __n__ проектов",
"showing_x_out_of_n_users": "Показано __x__ из __n__ пользователей",
"signed_up": "Зарегистрирован",
"site_description": "Простой в использовании онлайн редактор LaTeX. Не требует установки, поддерживает совместную работу в реальном времени, контроль версий, сотни шаблонов LaTeX и многое другое.",
"somthing_went_wrong_compiling": "К сожалению, что-то пошло не так и мы не смогли скомпИлировать Ваш проект. Попробуйте еще раз через пару минут.",
"source": "Исходный код",
@@ -399,6 +456,8 @@
"sure_you_want_to_change_plan": "Вы уверены, что хотите сменить тарифный план на <0>__planName__</0>?",
"sure_you_want_to_delete": "Вы уверены, что хотите перманентно удалить следующие файлы?",
"sure_you_want_to_leave_group": "Вы уверены, что хотите покинуть группу?",
"suspend": "Заблокировать",
"suspend_account": "Заблокировать учетную запись",
"sv": "Шведский",
"sync": "Синхронизация",
"sync_project_to_github_explanation": "Все изменения, сделанные Вами в __appName__ будут интегрированы (commit и merge) со всеми обновлениями на GitHub.",
@@ -418,6 +477,8 @@
"thanks_settings_updated": "Спасибо, изменения сохранены",
"theme": "Тема",
"thesis": "Диссертация",
"this_action_can_be_undone_within_limited_period": "Эта операция может быть отменена только в течение ограниченного периода.",
"this_action_cannot_be_undone": "Эту операцию нельзя отменить.",
"this_is_your_template": "Это шаблон из Вашего проекта",
"this_project_is_public": "Это открытый проект. Он может быть изменен любым человеком, знающим адрес (URL)",
"this_project_is_public_read_only": "Этот проект открыт для всех, у кого есть ссылка (но без возможности редактирования)",
@@ -431,6 +492,11 @@
"too_recently_compiled": "Этот проект был скомпилирован совсем недавно, поэтому компиляция была пропущена.",
"total_words": "Количество слов",
"tr": "Турецкий",
"transfer_all_projects_to": "Передать все проекты пользователей новому владельцу:",
"trash_projects": "Переместить проекты в корзину",
"trashed_projects": "Проекты в корзине",
"trashing_projects_wont_affect_collaborators": "Перемещение проекта в корзину не повлияет на других разработчиков проекта.",
"trashing_projects_wont_affect_user_collaborators": "Перемещение проекта в корзину не повлияет на других разработчиков проекта.",
"try_now": "Попробуйте",
"try_recompile_project": "Попробуйте скомпилировать проект заново.",
"uk": "Украинский",
@@ -442,6 +508,8 @@
"unlink_github_warning": "Все проекты, которые Вы синхронизировали с GitHub, будут отсоединены и больше не будут синхронизироваться с GitHub. Вы уверены, что хотите отсоединить Ваш GitHub аккаунт?",
"unpublish": "Отменить публикацию",
"unpublishing": "Отмена публикации",
"unset_admin": "Убрать админина",
"unset_admin_account": "Отозвать права администратора",
"unsubscribe": "Отменить подписку",
"unsubscribed": "Не подписан",
"unsubscribing": "Отмена подписки",
@@ -456,7 +524,19 @@
"upload_file": "Загрузить файл",
"upload_project": "Загрузить проект",
"upload_zipped_project": "Загрузить архив проекта (*.zip)",
"user_activity": "Активность пользователя",
"user_categories": "Категории пользователей",
"user_category_admin": "Администраторы",
"user_category_all": "Все пользователи",
"user_category_deleted": "Удалённые пользователи",
"user_category_inactive": "Неактивные пользователи",
"user_category_ldap": "Пользователи LDAP",
"user_category_local": "Локальные пользователи",
"user_category_oidc": "Пользователи OIDC",
"user_category_saml": "Пользователи SAML",
"user_category_suspended": "Заблокированные пользователи",
"user_wants_you_to_see_project": "__username__ приглашает вас к просмотру проекта __projectname__",
"users_list": "Список пользователей",
"vat_number": "Номер плательщика НДС",
"view_all": "Показать все",
"view_in_template_gallery": "Посмотреть в галерее шаблонов",

View File

@@ -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
)
},
}

View File

@@ -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),
}

View 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),
}

View File

@@ -0,0 +1 @@
{ "extends": "../../../../tsconfig.backend.json" }

View 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')}…

View File

@@ -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

View File

@@ -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

View File

@@ -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 />
))

View File

@@ -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 />
))

View File

@@ -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 />)
}

View File

@@ -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 />)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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} />
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -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'

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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>
)
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 } })
}

View File

@@ -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)
}

View File

@@ -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]'
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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