diff --git a/packages/client/hmi-client/src/components/home/tera-project-menu.vue b/packages/client/hmi-client/src/components/home/tera-project-menu.vue index efd5ac532a..b7994977ce 100644 --- a/packages/client/hmi-client/src/components/home/tera-project-menu.vue +++ b/packages/client/hmi-client/src/components/home/tera-project-menu.vue @@ -12,10 +12,11 @@ import { isEmpty } from 'lodash'; import Button from 'primevue/button'; import Menu from 'primevue/menu'; import { computed, ref } from 'vue'; -import { exportProjectAsFile } from '@/services/project'; +import { exportProjectAsFile, setSample } from '@/services/project'; import { AcceptedExtensions } from '@/types/common'; import { MenuItem } from 'primevue/menuitem'; import useAuthStore from '@/stores/auth'; +import { useToastService } from '@/services/toast'; const props = defineProps<{ project: Project | null }>(); @@ -81,6 +82,21 @@ const downloadMenuItem = { } }; +const makeSampleMenuItem = { + label: 'Make a sample', + icon: 'pi pi-star', + command: () => { + setSample(props.project?.id).then((response) => { + if (response) { + useToastService().success(undefined, 'Project set as sample'); + useProjects().refresh(); + } else { + useToastService().error(undefined, 'Error setting project as sample'); + } + }); + } +}; + const projectMenuItems = computed(() => { // Basic access to a public and reader project const items: MenuItem[] = [copyMenuItem, downloadMenuItem]; @@ -95,6 +111,11 @@ const projectMenuItems = computed(() => { items.push(removeMenuItem); } + // Admin only + if (useAuthStore().isAdmin && props.project?.sampleProject === false) { + items.push(makeSampleMenuItem); + } + return items; }); diff --git a/packages/client/hmi-client/src/page/Home.vue b/packages/client/hmi-client/src/page/Home.vue index e10441a627..70327535e9 100644 --- a/packages/client/hmi-client/src/page/Home.vue +++ b/packages/client/hmi-client/src/page/Home.vue @@ -234,12 +234,14 @@ const searchedAndFilterProjects = computed(() => { tabProjects = tabProjects.filter( ({ userPermission, publicProject }) => // I can edit the project, or I can view a non-public project - ['creator', 'writer'].includes(userPermission ?? '') || (userPermission === 'reader' && !publicProject) + ['creator', 'writer'].includes(userPermission ?? '') || (userPermission === 'reader' && publicProject === false) ); } else if (activeTabIndex.value === 1) { - tabProjects = tabProjects.filter(({ publicProject }) => publicProject === true); + tabProjects = tabProjects.filter( + ({ publicProject, sampleProject }) => publicProject === true && sampleProject === false + ); } else if (activeTabIndex.value === 2) { - tabProjects = [] as Project[]; // TODO - Sample projects + tabProjects = tabProjects.filter(({ sampleProject }) => sampleProject === true); } // If they are no search we can return the filtered and sorted projects diff --git a/packages/client/hmi-client/src/services/project.ts b/packages/client/hmi-client/src/services/project.ts index cd2d336f86..480d6d77d3 100644 --- a/packages/client/hmi-client/src/services/project.ts +++ b/packages/client/hmi-client/src/services/project.ts @@ -153,6 +153,16 @@ async function setAccessibility(projectId: Project['id'], isPublic: boolean): Pr } } +async function setSample(projectId: Project['id']): Promise { + try { + const response = await API.post(`projects/set-sample/${projectId}`); + return response?.status === 200; + } catch (error) { + console.error(`The project was not made a sample project, ${error}`); + return false; + } +} + async function getPermissions(projectId: Project['id']): Promise { try { const { status, data } = await API.get(`projects/${projectId}/permissions`); @@ -322,6 +332,7 @@ export { removePermissions, setAccessibility, setPermissions, + setSample, update, updatePermissions, exportProjectAsFile, diff --git a/packages/client/hmi-client/src/types/Types.ts b/packages/client/hmi-client/src/types/Types.ts index bf0b99b93e..111fc6cadf 100644 --- a/packages/client/hmi-client/src/types/Types.ts +++ b/packages/client/hmi-client/src/types/Types.ts @@ -294,6 +294,7 @@ export interface Project extends TerariumAsset { overviewContent?: any; projectAssets: ProjectAsset[]; metadata?: { [index: string]: string }; + sampleProject?: boolean; publicProject?: boolean; userPermission?: string; } diff --git a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/controller/dataservice/ProjectController.java b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/controller/dataservice/ProjectController.java index 87c059a10c..813e29deb8 100644 --- a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/controller/dataservice/ProjectController.java +++ b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/controller/dataservice/ProjectController.java @@ -1175,6 +1175,75 @@ public ResponseEntity makeProjectPublic( } } + @Operation(summary = "Set a project as a sample project by ID") + @ApiResponses( + value = { + @ApiResponse( + responseCode = "200", + description = "Project has been made a sample project", + content = { + @Content( + mediaType = "application/json", + schema = @io.swagger.v3.oas.annotations.media.Schema(implementation = UUID.class) + ) + } + ), + @ApiResponse( + responseCode = "403", + description = "The current user does not have privileges to modify this project.", + content = @Content + ), + @ApiResponse(responseCode = "500", description = "An error occurred verifying permissions", content = @Content) + } + ) + @PostMapping("/set-sample/{id}") + @Secured(Roles.USER) + public ResponseEntity makeProjectSample(@PathVariable("id") final UUID id) { + try { + // Only an admin can set a project as a sample project + projectService.checkPermissionCanAdministrate(currentUserService.get().getId(), id); + + final Optional project = projectService.getProject(id); + if (project.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, messages.get("projects.not-found")); + } + + // Update the project and make it public as well as a sample project + project.get().setSampleProject(true).setPublicAsset(true); + projectAssetService.togglePublicForAssets(terariumAssetServices, id, true, Schema.Permission.WRITE); + projectService.updateProject(project.get()); + + /* Permissions */ + + // Getting the project + final RebacProject rebacProject = new RebacProject(id, reBACService); + + // Delete all previous user relationships attached to the project, + final List contributors = projectPermissionsService.getContributors(rebacProject); + for (final Contributor contributor : contributors) { + if (contributor.isUser()) { + final RebacUser rebacUser = new RebacUser(contributor.getUserId(), reBACService); + rebacUser.removeAllRelationships(rebacProject); + } + } + + // Add the Admin group to administrate the project + final RebacGroup adminGroup = new RebacGroup(ReBACService.ASKEM_ADMIN_GROUP_ID, reBACService); + adminGroup.removeAllRelationsExceptOne(rebacProject, Schema.Relationship.ADMIN); + + // Add the public group to read the project + final RebacGroup publicGroup = new RebacGroup(ReBACService.PUBLIC_GROUP_ID, reBACService); + publicGroup.removeAllRelationsExceptOne(rebacProject, Schema.Relationship.READER); + + return ResponseEntity.ok().build(); + } catch (final ResponseStatusException rethrow) { + throw rethrow; + } catch (final Exception e) { + log.error("Unexpected error, failed to set as a sample project ", e); + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, messages.get("rebac.service-unavailable")); + } + } + @PostMapping("/{id}/permissions/user/{user-id}/{relationship}") @Secured(Roles.USER) @Operation(summary = "Sets a user's permissions for a project") diff --git a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/models/dataservice/project/Contributor.java b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/models/dataservice/project/Contributor.java index b965937c1d..d7326525fb 100644 --- a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/models/dataservice/project/Contributor.java +++ b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/models/dataservice/project/Contributor.java @@ -26,4 +26,14 @@ public Schema.Relationship getPermission() { public String getUserId() { return userId; } + + /** Is the Contributor a Group? */ + public Boolean isGroup() { + return userId == null; + } + + /** Is the Contributor a User? */ + public Boolean isUser() { + return userId != null; + } } diff --git a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/models/dataservice/project/Project.java b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/models/dataservice/project/Project.java index 8adf0d7537..03207ddc8a 100644 --- a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/models/dataservice/project/Project.java +++ b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/models/dataservice/project/Project.java @@ -70,6 +70,9 @@ public class Project extends TerariumAsset { @Schema(accessMode = Schema.AccessMode.READ_ONLY, defaultValue = "{}") private Map metadata; + @TSOptional + private Boolean sampleProject = false; + /** Information for the front-end to display/filter the project accordingly. */ @TSOptional @Transient @@ -99,6 +102,9 @@ public static Project mergeProjectFields(final Project existingProject, final Pr if (project.getThumbnail() != null) { existingProject.setThumbnail(project.getThumbnail()); } + if (project.getSampleProject() != null) { + existingProject.setSampleProject(project.getSampleProject()); + } return existingProject; } diff --git a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/models/dataservice/project/ProjectAndAssetAggregate.java b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/models/dataservice/project/ProjectAndAssetAggregate.java index 582bbb5710..36845e1eef 100644 --- a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/models/dataservice/project/ProjectAndAssetAggregate.java +++ b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/models/dataservice/project/ProjectAndAssetAggregate.java @@ -33,4 +33,6 @@ public interface ProjectAndAssetAggregate { Integer getAssetCount(); String getAssetType(); + + Boolean getSampleProject(); } diff --git a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/repository/data/ProjectRepository.java b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/repository/data/ProjectRepository.java index 84fc20c785..9c594602c3 100644 --- a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/repository/data/ProjectRepository.java +++ b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/repository/data/ProjectRepository.java @@ -29,6 +29,7 @@ public interface ProjectRepository extends PSCrudRepository, JpaS p.name as name, p.overviewContent as overviewContent, p.publicAsset as publicAsset, + p.sampleProject as sampleProject, p.temporary as temporary, p.thumbnail as thumbnail, p.userId as userId, @@ -66,6 +67,7 @@ p.id in (:ids) p.name as name, p.overviewContent as overviewContent, p.publicAsset as publicAsset, + p.sampleProject as sampleProject, p.temporary as temporary, p.thumbnail as thumbnail, p.userId as userId, diff --git a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/service/data/ProjectService.java b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/service/data/ProjectService.java index 8e23b12d6f..95b4c5232b 100644 --- a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/service/data/ProjectService.java +++ b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/service/data/ProjectService.java @@ -66,6 +66,7 @@ public List getActiveProjects() { project.setThumbnail(aggregate.getThumbnail()); project.setUserId(aggregate.getUserId()); project.setMetadata(new HashMap<>()); + project.setSampleProject(aggregate.getSampleProject()); addAssetCount(project, aggregate.getAssetType(), aggregate.getAssetCount()); projectMap.put(project.getId(), project); } @@ -101,6 +102,7 @@ public List getActiveProjects(final List ids) { project.setThumbnail(aggregate.getThumbnail()); project.setUserId(aggregate.getUserId()); project.setMetadata(new HashMap<>()); + project.setSampleProject(aggregate.getSampleProject()); addAssetCount(project, aggregate.getAssetType(), aggregate.getAssetCount()); projectMap.put(project.getId(), project); } diff --git a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/utils/rebac/ReBACService.java b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/utils/rebac/ReBACService.java index 4c17fea3a1..1fefe5b005 100644 --- a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/utils/rebac/ReBACService.java +++ b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/utils/rebac/ReBACService.java @@ -491,11 +491,15 @@ public void removeRelationship( final SchemaObject who, final SchemaObject what, final Schema.Relationship relationship - ) throws Exception, RelationshipAlreadyExistsException { + ) throws Exception { userCache.invalidate(who.id); invalidatePermissionCache(who, what); final ReBACFunctions rebac = new ReBACFunctions(channel, spiceDbBearerToken); - CURRENT_ZED_TOKEN = rebac.removeRelationship(who, relationship, what); + try { + CURRENT_ZED_TOKEN = rebac.removeRelationship(who, relationship, what); + } catch (RelationshipAlreadyExistsException ignore) { + // NB: This is a no-op as the relationship is already removed + } } private Consistency getCurrentConsistency() { diff --git a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/utils/rebac/askem/RebacGroup.java b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/utils/rebac/askem/RebacGroup.java index ae24b9039d..dbb103234b 100644 --- a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/utils/rebac/askem/RebacGroup.java +++ b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/utils/rebac/askem/RebacGroup.java @@ -26,6 +26,10 @@ public boolean canAdministrate(RebacObject rebacObject) throws Exception { return reBACService.can(getSchemaObject(), Schema.Permission.ADMINISTRATE, rebacObject.getSchemaObject()); } + public void createReaderRelationship(RebacObject rebacObject) throws Exception, RelationshipAlreadyExistsException { + reBACService.createRelationship(getSchemaObject(), rebacObject.getSchemaObject(), Schema.Relationship.READER); + } + public void createWriterRelationship(RebacObject rebacObject) throws Exception, RelationshipAlreadyExistsException { reBACService.createRelationship(getSchemaObject(), rebacObject.getSchemaObject(), Schema.Relationship.WRITER); } @@ -34,6 +38,48 @@ public void createCreatorRelationship(RebacObject rebacObject) throws Exception, reBACService.createRelationship(getSchemaObject(), rebacObject.getSchemaObject(), Schema.Relationship.CREATOR); } + public void createAdminRelationship(RebacObject rebacObject) throws Exception, RelationshipAlreadyExistsException { + reBACService.createRelationship(getSchemaObject(), rebacObject.getSchemaObject(), Schema.Relationship.ADMIN); + } + + /** Remove all relationships between this group and the given object. */ + public void removeAllRelationships(final RebacObject rebacObject) throws Exception { + final Schema.Relationship[] relationships = { + Schema.Relationship.READER, + Schema.Relationship.WRITER, + Schema.Relationship.CREATOR, + Schema.Relationship.ADMIN + }; + for (Schema.Relationship relationship : relationships) { + reBACService.removeRelationship(getSchemaObject(), rebacObject.getSchemaObject(), relationship); + } + } + + /** + * Remove all relationships between this group and the given object. + * Except the provided relationship, if it doesn't exist, it will be added + */ + public void removeAllRelationsExceptOne( + final RebacObject rebacObject, + final Schema.Relationship relationshipToIgnore + ) throws Exception { + final Schema.Relationship[] relationships = { + Schema.Relationship.READER, + Schema.Relationship.WRITER, + Schema.Relationship.CREATOR, + Schema.Relationship.ADMIN + }; + for (Schema.Relationship relationship : relationships) { + if (relationship == relationshipToIgnore) { + try { + reBACService.createRelationship(getSchemaObject(), rebacObject.getSchemaObject(), relationship); + } catch (RelationshipAlreadyExistsException ignore) {} + } else { + reBACService.removeRelationship(getSchemaObject(), rebacObject.getSchemaObject(), relationship); + } + } + } + public void setPermissionRelationships(RebacObject who, String relationship) throws Exception, RelationshipAlreadyExistsException { Schema.Relationship relationshipEnum = Schema.Relationship.valueOf(relationship.toUpperCase()); diff --git a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/utils/rebac/askem/RebacUser.java b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/utils/rebac/askem/RebacUser.java index 639645166c..f50021f203 100644 --- a/packages/server/src/main/java/software/uncharted/terarium/hmiserver/utils/rebac/askem/RebacUser.java +++ b/packages/server/src/main/java/software/uncharted/terarium/hmiserver/utils/rebac/askem/RebacUser.java @@ -63,6 +63,34 @@ public void createCreatorRelationship(final RebacObject rebacObject) reBACService.createRelationship(getSchemaObject(), rebacObject.getSchemaObject(), Schema.Relationship.CREATOR); } + public void createWriterRelationship(final RebacObject rebacObject) + throws Exception, RelationshipAlreadyExistsException { + reBACService.createRelationship(getSchemaObject(), rebacObject.getSchemaObject(), Schema.Relationship.WRITER); + } + + public void createReaderRelationship(final RebacObject rebacObject) + throws Exception, RelationshipAlreadyExistsException { + reBACService.createRelationship(getSchemaObject(), rebacObject.getSchemaObject(), Schema.Relationship.READER); + } + + public void removeCreatorRelationship(final RebacObject rebacObject) throws Exception { + reBACService.removeRelationship(getSchemaObject(), rebacObject.getSchemaObject(), Schema.Relationship.CREATOR); + } + + public void removeWriterRelationship(final RebacObject rebacObject) throws Exception { + reBACService.removeRelationship(getSchemaObject(), rebacObject.getSchemaObject(), Schema.Relationship.WRITER); + } + + public void removeReaderRelationship(final RebacObject rebacObject) throws Exception { + reBACService.removeRelationship(getSchemaObject(), rebacObject.getSchemaObject(), Schema.Relationship.READER); + } + + public void removeAllRelationships(final RebacObject rebacObject) throws Exception { + reBACService.removeRelationship(getSchemaObject(), rebacObject.getSchemaObject(), Schema.Relationship.CREATOR); + reBACService.removeRelationship(getSchemaObject(), rebacObject.getSchemaObject(), Schema.Relationship.WRITER); + reBACService.removeRelationship(getSchemaObject(), rebacObject.getSchemaObject(), Schema.Relationship.READER); + } + public PermissionGroup createGroup(final String name) throws Exception, RelationshipAlreadyExistsException { final PermissionGroup group = reBACService.createGroup(name); reBACService.createRelationship(