diff --git a/src/app/core/components/nav-menu/nav-menu.component.ts b/src/app/core/components/nav-menu/nav-menu.component.ts index e167f5f43..efbb43336 100644 --- a/src/app/core/components/nav-menu/nav-menu.component.ts +++ b/src/app/core/components/nav-menu/nav-menu.component.ts @@ -1,4 +1,4 @@ -import { select } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; @@ -7,7 +7,7 @@ import { PanelMenuModule } from 'primeng/panelmenu'; import { filter, map } from 'rxjs'; -import { Component, computed, inject, output } from '@angular/core'; +import { Component, computed, effect, inject, output } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router'; @@ -18,10 +18,10 @@ import { RouteContext } from '@osf/core/models'; import { AuthService } from '@osf/core/services'; import { UserSelectors } from '@osf/core/store/user'; import { IconComponent } from '@osf/shared/components'; -import { CurrentResourceType, ReviewPermissions } from '@osf/shared/enums'; +import { CurrentResourceType, ResourceType, ReviewPermissions } from '@osf/shared/enums'; import { getViewOnlyParam } from '@osf/shared/helpers'; import { WrapFnPipe } from '@osf/shared/pipes'; -import { CurrentResourceSelectors } from '@osf/shared/stores'; +import { CurrentResourceSelectors, GetResourceDetails } from '@osf/shared/stores'; @Component({ selector: 'osf-nav-menu', @@ -38,8 +38,38 @@ export class NavMenuComponent { private readonly isAuthenticated = select(UserSelectors.isAuthenticated); private readonly currentResource = select(CurrentResourceSelectors.getCurrentResource); + private readonly currentUserPermissions = select(CurrentResourceSelectors.getCurrentUserPermissions); + private readonly isResourceDetailsLoading = select(CurrentResourceSelectors.isResourceDetailsLoading); private readonly provider = select(ProviderSelectors.getCurrentProvider); + readonly actions = createDispatchMap({ getResourceDetails: GetResourceDetails }); + + readonly resourceType = computed(() => { + const type = this.currentResource()?.type; + + switch (type) { + case CurrentResourceType.Projects: + return ResourceType.Project; + case CurrentResourceType.Registrations: + return ResourceType.Registration; + case CurrentResourceType.Preprints: + return ResourceType.Preprint; + default: + return ResourceType.Project; + } + }); + + constructor() { + effect(() => { + const resourceId = this.currentResourceId(); + const resourceType = this.resourceType(); + + if (resourceId && resourceType) { + this.actions.getResourceDetails(resourceId, resourceType); + } + }); + } + readonly mainMenuItems = computed(() => { const isAuthenticated = this.isAuthenticated(); const filtered = filterMenuItems(MENU_ITEMS, isAuthenticated); @@ -65,7 +95,8 @@ export class NavMenuComponent { isCollections: this.isCollectionsRoute() || false, currentUrl: this.router.url, isViewOnly: !!getViewOnlyParam(this.router), - permissions: this.currentResource()?.permissions, + permissions: this.currentUserPermissions(), + isResourceDetailsLoading: this.isResourceDetailsLoading(), }; const items = updateMenuItems(filtered, routeContext); diff --git a/src/app/core/constants/nav-items.constant.ts b/src/app/core/constants/nav-items.constant.ts index b5b1d2fc4..586341e3a 100644 --- a/src/app/core/constants/nav-items.constant.ts +++ b/src/app/core/constants/nav-items.constant.ts @@ -14,6 +14,7 @@ export const VIEW_ONLY_PROJECT_MENU_ITEMS: string[] = [ 'project-files', 'project-wiki', 'project-analytics', + 'project-links', ]; export const VIEW_ONLY_REGISTRY_MENU_ITEMS: string[] = [ @@ -22,6 +23,7 @@ export const VIEW_ONLY_REGISTRY_MENU_ITEMS: string[] = [ 'registration-wiki', 'registration-analytics', 'registration-components', + 'registration-recent-activity', ]; export const PROJECT_MENU_ITEMS: MenuItem[] = [ @@ -437,3 +439,15 @@ export const MENU_ITEMS: MenuItem[] = [ styleClass: 'my-5', }, ]; + +export const PROJECT_MENU_PERMISSIONS: Record< + string, + { + requiresWrite?: boolean; + requiresPermissions?: boolean; + } +> = { + 'project-addons': { requiresWrite: true }, + 'project-contributors': { requiresPermissions: true }, + 'project-settings': { requiresPermissions: true }, +}; diff --git a/src/app/core/helpers/nav-menu.helper.ts b/src/app/core/helpers/nav-menu.helper.ts index 034c32c4a..49607ae4a 100644 --- a/src/app/core/helpers/nav-menu.helper.ts +++ b/src/app/core/helpers/nav-menu.helper.ts @@ -1,17 +1,39 @@ import { MenuItem } from 'primeng/api'; +import { UserPermissions } from '@osf/shared/enums'; import { getViewOnlyParamFromUrl } from '@osf/shared/helpers'; import { AUTHENTICATED_MENU_ITEMS, PREPRINT_MENU_ITEMS, PROJECT_MENU_ITEMS, + PROJECT_MENU_PERMISSIONS, REGISTRATION_MENU_ITEMS, VIEW_ONLY_PROJECT_MENU_ITEMS, VIEW_ONLY_REGISTRY_MENU_ITEMS, } from '../constants'; import { RouteContext } from '../models'; +function shouldShowMenuItem(menuItemId: string, permissions: string[] | undefined): boolean { + const permissionConfig = PROJECT_MENU_PERMISSIONS[menuItemId]; + + if (!permissionConfig) { + return true; + } + + if (permissionConfig.requiresPermissions && (!permissions || !permissions.length)) { + return false; + } + + if (permissionConfig.requiresWrite) { + const hasWritePermission = + permissions?.includes(UserPermissions.Write) || permissions?.includes(UserPermissions.Admin); + return hasWritePermission || false; + } + + return true; +} + export function filterMenuItems(items: MenuItem[], isAuthenticated: boolean): MenuItem[] { return items.map((item) => { const isAuthenticatedItem = AUTHENTICATED_MENU_ITEMS.includes(item.id || ''); @@ -101,13 +123,18 @@ function updateProjectMenuItem(item: MenuItem, ctx: RouteContext): MenuItem { }; } - return menuItem; + const isVisible = shouldShowMenuItem(menuItem.id || '', ctx.permissions); + + return { + ...menuItem, + visible: isVisible, + }; }); return { ...subItem, visible: true, - expanded: true, + expanded: !ctx.isResourceDetailsLoading, items: menuItems.map((menuItem) => ({ ...menuItem, routerLink: [ctx.resourceId as string, menuItem.routerLink], diff --git a/src/app/core/models/route-context.model.ts b/src/app/core/models/route-context.model.ts index 456173ee2..395a5ef37 100644 --- a/src/app/core/models/route-context.model.ts +++ b/src/app/core/models/route-context.model.ts @@ -1,5 +1,3 @@ -import { UserPermissions } from '@osf/shared/enums'; - export interface RouteContext { resourceId: string | undefined; providerId?: string; @@ -13,5 +11,6 @@ export interface RouteContext { isCollections: boolean; currentUrl?: string; isViewOnly?: boolean; - permissions?: UserPermissions[]; + permissions?: string[]; + isResourceDetailsLoading?: boolean; } diff --git a/src/app/features/project/linked-services/linked-services.component.html b/src/app/features/project/linked-services/linked-services.component.html index a36358f21..833c79190 100644 --- a/src/app/features/project/linked-services/linked-services.component.html +++ b/src/app/features/project/linked-services/linked-services.component.html @@ -41,13 +41,15 @@

{{ 'project.linkedServices.noLinkedServices' | translate }}

-

- {{ 'project.linkedServices.redirectMessage' | translate }} - - {{ 'project.linkedServices.addonsLink' | translate }} - - {{ 'project.linkedServices.redirectMessageSuffix' | translate }} -

+ @if (canManageAddons()) { +

+ {{ 'project.linkedServices.redirectMessage' | translate }} + + {{ 'project.linkedServices.addonsLink' | translate }} + + {{ 'project.linkedServices.redirectMessageSuffix' | translate }} +

+ } } diff --git a/src/app/features/project/linked-services/linked-services.component.ts b/src/app/features/project/linked-services/linked-services.component.ts index ea280a770..ff1e5834f 100644 --- a/src/app/features/project/linked-services/linked-services.component.ts +++ b/src/app/features/project/linked-services/linked-services.component.ts @@ -12,6 +12,7 @@ import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components' import { AddonServiceNames } from '@shared/enums'; import { convertCamelCaseToNormal } from '@shared/helpers'; import { AddonsSelectors, GetAddonsResourceReference, GetConfiguredLinkAddons } from '@shared/stores'; +import { CurrentResourceSelectors } from '@shared/stores/current-resource'; @Component({ selector: 'osf-linked-services', @@ -29,6 +30,8 @@ export class LinkedServicesComponent implements OnInit { isResourceReferenceLoading = select(AddonsSelectors.getAddonsResourceReferenceLoading); isConfiguredLinkAddonsLoading = select(AddonsSelectors.getConfiguredLinkAddonsLoading); isCurrentUserLoading = select(UserSelectors.getCurrentUserLoading); + hasWriteAccess = select(CurrentResourceSelectors.hasWriteAccess); + hasAdminAccess = select(CurrentResourceSelectors.hasAdminAccess); isLoading = computed(() => { return this.isConfiguredLinkAddonsLoading() || this.isResourceReferenceLoading() || this.isCurrentUserLoading(); @@ -45,6 +48,10 @@ export class LinkedServicesComponent implements OnInit { })); }); + canManageAddons = computed(() => { + return this.hasWriteAccess() || this.hasAdminAccess(); + }); + actions = createDispatchMap({ getConfiguredLinkAddons: GetConfiguredLinkAddons, getAddonsResourceReference: GetAddonsResourceReference, diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.html b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html index 676352d56..55736a974 100644 --- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.html +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html @@ -21,18 +21,19 @@

{{ linkedResource.title }}

- -
- - -
+ @if (canEdit()) { +
+ + +
+ }
diff --git a/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.html b/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.html index f67364529..5c702e0fe 100644 --- a/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.html +++ b/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.html @@ -23,7 +23,7 @@
} - @if (isCollectionsRoute() || hasViewOnly()) { + @if (isCollectionsRoute() || hasViewOnly() || !canEdit()) { @if (isPublic()) {
diff --git a/src/app/features/project/overview/project-overview.component.html b/src/app/features/project/overview/project-overview.component.html index 238841f18..0800aad51 100644 --- a/src/app/features/project/overview/project-overview.component.html +++ b/src/app/features/project/overview/project-overview.component.html @@ -17,7 +17,7 @@ [isCollectionsRoute]="isCollectionsRoute()" [currentResource]="currentResource()" [projectDescription]="project.description" - [canEdit]="isAdmin()" + [canEdit]="hasAdminAccess()" />
@@ -47,7 +47,7 @@ }
- @if (canWrite()) { + @if (hasWriteAccess()) { } @@ -57,8 +57,11 @@ [areComponentsLoading]="areComponentsLoading()" /> - - + @if (!hasViewOnly()) { + + + } +
@@ -67,7 +70,8 @@ [currentResource]="resourceOverview()" (customCitationUpdated)="onCustomCitationUpdated($event)" [isCollectionsRoute]="isCollectionsRoute()" - [canEdit]="canWrite()" + [canEdit]="hasAdminAccess()" + [showEditButton]="hasWriteAccess()" /> diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 368c9a877..53d447925 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -31,7 +31,7 @@ import { CollectionsModerationSelectors, GetSubmissionsReviewActions, } from '@osf/features/moderation/store/collections-moderation'; -import { Mode, ResourceType, UserPermissions } from '@osf/shared/enums'; +import { Mode, ResourceType } from '@osf/shared/enums'; import { hasViewOnlyParam, IS_XSMALL } from '@osf/shared/helpers'; import { MapProjectOverview } from '@osf/shared/mappers'; import { MetaTagsService, ToastService } from '@osf/shared/services'; @@ -129,6 +129,8 @@ export class ProjectOverviewComponent implements OnInit { areSubjectsLoading = select(SubjectsSelectors.areSelectedSubjectsLoading); currentProject = select(ProjectOverviewSelectors.getProject); isAnonymous = select(ProjectOverviewSelectors.isProjectAnonymous); + hasWriteAccess = select(ProjectOverviewSelectors.hasWriteAccess); + hasAdminAccess = select(ProjectOverviewSelectors.hasAdminAccess); private readonly actions = createDispatchMap({ getProject: GetProjectById, @@ -175,14 +177,6 @@ export class ProjectOverviewComponent implements OnInit { userPermissions = computed(() => this.currentProject()?.currentUserPermissions || []); hasViewOnly = computed(() => hasViewOnlyParam(this.router)); - isAdmin = computed(() => { - return this.userPermissions().includes(UserPermissions.Admin); - }); - - canWrite = computed(() => { - return this.userPermissions().includes(UserPermissions.Write); - }); - resourceOverview = computed(() => { const project = this.currentProject(); const subjects = this.subjects(); diff --git a/src/app/features/project/overview/store/project-overview.selectors.ts b/src/app/features/project/overview/store/project-overview.selectors.ts index f8e8e52e6..7b85f5c94 100644 --- a/src/app/features/project/overview/store/project-overview.selectors.ts +++ b/src/app/features/project/overview/store/project-overview.selectors.ts @@ -1,5 +1,7 @@ import { Selector } from '@ngxs/store'; +import { UserPermissions } from '@osf/shared/enums'; + import { ProjectOverviewStateModel } from './project-overview.model'; import { ProjectOverviewState } from './project-overview.state'; @@ -53,4 +55,19 @@ export class ProjectOverviewSelectors { static getDuplicatedProject(state: ProjectOverviewStateModel) { return state.duplicatedProject; } + + @Selector([ProjectOverviewState]) + static hasWriteAccess(state: ProjectOverviewStateModel): boolean { + return state.project.data?.currentUserPermissions.includes(UserPermissions.Write) || false; + } + + @Selector([ProjectOverviewState]) + static hasAdminAccess(state: ProjectOverviewStateModel): boolean { + return state.project.data?.currentUserPermissions.includes(UserPermissions.Admin) || false; + } + + @Selector([ProjectOverviewState]) + static hasNoPermissions(state: ProjectOverviewStateModel): boolean { + return !state.project.data?.currentUserPermissions.length; + } } diff --git a/src/app/features/project/registrations/registrations.component.html b/src/app/features/project/registrations/registrations.component.html index a9bd9b0ad..34300b4eb 100644 --- a/src/app/features/project/registrations/registrations.component.html +++ b/src/app/features/project/registrations/registrations.component.html @@ -1,5 +1,5 @@ params['id'])) ?? of(undefined)); registrations = select(RegistrationsSelectors.getRegistrations); diff --git a/src/app/features/project/wiki/wiki.component.html b/src/app/features/project/wiki/wiki.component.html index 99de7c58a..4fe159c43 100644 --- a/src/app/features/project/wiki/wiki.component.html +++ b/src/app/features/project/wiki/wiki.component.html @@ -5,7 +5,7 @@ [variant]="wikiModes().view ? undefined : 'outlined'" (onClick)="toggleMode(WikiModes.View)" /> - @if (!hasViewOnly()) { + @if (hasWriteAccess()) { @@ -46,7 +46,7 @@ (selectVersion)="onSelectVersion($event)" > } - @if (!hasViewOnly() && wikiModes().edit) { + @if (hasWriteAccess() && wikiModes().edit) { hasViewOnlyParam(this.router)); + hasWriteAccess = select(CurrentResourceSelectors.hasWriteAccess); + hasAdminAccess = select(CurrentResourceSelectors.hasAdminAccess); + actions = createDispatchMap({ getWikiModes: GetWikiModes, toggleMode: ToggleMode, @@ -108,7 +112,7 @@ export class WikiComponent { if (!this.wikiIdFromQueryParams) { this.navigateToWiki(this.wikiList()?.[0]?.id || ''); } - if (!this.wikiList()?.length) { + if (!this.wikiList()?.length && this.hasWriteAccess()) { this.actions.createWiki(ResourceType.Project, this.projectId(), this.homeWikiName); } }) diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.html b/src/app/features/registry/pages/registry-overview/registry-overview.component.html index d0ec326a6..12cf3c214 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.html +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.html @@ -135,6 +135,7 @@

{{ section.title }}

[currentResource]="resourceOverview()" (customCitationUpdated)="onCustomCitationUpdated($event)" [canEdit]="hasWriteAccess()" + [showEditButton]="hasWriteAccess()" /> diff --git a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html index 0c8dfc404..799fd09be 100644 --- a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html +++ b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html @@ -21,7 +21,6 @@
{{ citation.title }} } } - @if (canEdit()) { - -

{{ 'project.overview.metadata.getMoreCitations' | translate }}

- - - {{ selectedOption.label }} - - - @if (styledCitation()) { -

{{ styledCitation()?.citation }}

- } - - @if (!hasViewOnly) { - - } + +

{{ 'project.overview.metadata.getMoreCitations' | translate }}

+ + + {{ selectedOption.label }} + + + @if (styledCitation()) { +

{{ styledCitation()?.citation }}

+ } + + @if (!hasViewOnly || canEdit()) { + } } } diff --git a/src/app/shared/components/resource-metadata/resource-metadata.component.html b/src/app/shared/components/resource-metadata/resource-metadata.component.html index e8a4488d6..c9f8f8b7c 100644 --- a/src/app/shared/components/resource-metadata/resource-metadata.component.html +++ b/src/app/shared/components/resource-metadata/resource-metadata.component.html @@ -5,7 +5,7 @@ - @if (!viewOnly() && list().length) { + @if (canEdit() && list().length) { @if (!isHomeWikiSelected()) { {{ item.label | translate }} - - @if (!viewOnly()) { + @if (canEdit()) { (); readonly isLoading = input(false); - readonly viewOnly = input(false); + readonly canEdit = input(false); readonly deleteWiki = output(); readonly createWiki = output(); diff --git a/src/app/shared/mappers/components/components.mapper.ts b/src/app/shared/mappers/components/components.mapper.ts index 0cd83baf1..762e19d95 100644 --- a/src/app/shared/mappers/components/components.mapper.ts +++ b/src/app/shared/mappers/components/components.mapper.ts @@ -9,14 +9,14 @@ export class ComponentsMapper { description: response.attributes.description, public: response.attributes.public, contributors: response.embeds.bibliographic_contributors.data.map((contributor) => ({ - id: contributor.embeds.users.data.id, - familyName: contributor.embeds.users.data.attributes.family_name, - fullName: contributor.embeds.users.data.attributes.full_name, - givenName: contributor.embeds.users.data.attributes.given_name, - middleName: contributor.embeds.users.data.attributes.middle_name, - type: contributor.embeds.users.data.type, + id: contributor.embeds.users?.data?.id, + familyName: contributor.embeds.users?.data?.attributes?.family_name, + fullName: contributor.embeds.users?.data?.attributes?.full_name, + givenName: contributor.embeds.users?.data?.attributes?.given_name, + middleName: contributor.embeds.users?.data?.attributes?.middle_name, + type: contributor.embeds.users?.data?.type, })), - currentUserPermissions: response.attributes.current_user_permissions || [], + currentUserPermissions: response.attributes?.current_user_permissions || [], }; } } diff --git a/src/app/shared/stores/current-resource/current-resource.selectors.ts b/src/app/shared/stores/current-resource/current-resource.selectors.ts index bb6ccae03..1bf13ad6f 100644 --- a/src/app/shared/stores/current-resource/current-resource.selectors.ts +++ b/src/app/shared/stores/current-resource/current-resource.selectors.ts @@ -1,6 +1,7 @@ import { Selector } from '@ngxs/store'; import { BaseNodeModel, CurrentResource, NodeShortInfoModel } from '@osf/shared/models'; +import { UserPermissions } from '@shared/enums'; import { CurrentResourceStateModel } from './current-resource.model'; import { CurrentResourceState } from './current-resource.state'; @@ -21,6 +22,21 @@ export class CurrentResourceSelectors { return state.resourceChildren.data; } + @Selector([CurrentResourceState]) + static hasWriteAccess(state: CurrentResourceStateModel): boolean { + return state.resourceDetails.data?.currentUserPermissions?.includes(UserPermissions.Write) || false; + } + + @Selector([CurrentResourceState]) + static hasAdminAccess(state: CurrentResourceStateModel): boolean { + return state.resourceDetails.data?.currentUserPermissions?.includes(UserPermissions.Admin) || false; + } + + @Selector([CurrentResourceState]) + static hasNoPermissions(state: CurrentResourceStateModel): boolean { + return !state.resourceDetails.data?.currentUserPermissions?.length; + } + @Selector([CurrentResourceState]) static isResourceDetailsLoading(state: CurrentResourceStateModel): boolean { return state.resourceDetails.isLoading; @@ -30,4 +46,9 @@ export class CurrentResourceSelectors { static isResourceWithChildrenLoading(state: CurrentResourceStateModel): boolean { return state.resourceChildren.isLoading; } + + @Selector([CurrentResourceState]) + static getCurrentUserPermissions(currentResourceState: CurrentResourceStateModel): string[] { + return currentResourceState.resourceDetails.data.currentUserPermissions || []; + } }