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 @@
-
-
+ @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 || [];
+ }
}