diff --git a/src/main/java/fr/inra/oresing/model/LocalDateTimeRange.java b/src/main/java/fr/inra/oresing/model/LocalDateTimeRange.java index 0057bc0ecadb17808253df9a7c4063b248beced7..1c6fb15c0c05b494de3f3df2b91636ad38895926 100644 --- a/src/main/java/fr/inra/oresing/model/LocalDateTimeRange.java +++ b/src/main/java/fr/inra/oresing/model/LocalDateTimeRange.java @@ -23,6 +23,7 @@ import java.time.format.DateTimeFormatter; @Value public class LocalDateTimeRange { + public static final String EMPTY = "empty"; Range<LocalDateTime> range; private static final DateTimeFormatter SQL_TIMESTAMP_DATE_TIME_FORMATTER = @@ -99,6 +100,9 @@ public class LocalDateTimeRange { } public static LocalDateTimeRange parseSql(String sqlExpression) { + if(EMPTY.equals(sqlExpression)){ + return null; + } String[] split = StringUtils.split(sqlExpression, ","); String lowerBoundString = split[0]; String upperBoundString = split[1]; diff --git a/src/main/java/fr/inra/oresing/persistence/SqlService.java b/src/main/java/fr/inra/oresing/persistence/SqlService.java index f2feb61a70f964ad1f39894a1bdf39611d855c28..315df43a6e1bfe781b05d93a6135749cddddf457 100644 --- a/src/main/java/fr/inra/oresing/persistence/SqlService.java +++ b/src/main/java/fr/inra/oresing/persistence/SqlService.java @@ -24,6 +24,7 @@ public class SqlService { private NamedParameterJdbcTemplate namedParameterJdbcTemplate; public void createSchema(SqlSchema schema, OreSiRole owner) { + execute("DROP SCHEMA IF EXISTS " + schema.getSqlIdentifier()+" CASCADE "); execute("CREATE SCHEMA " + schema.getSqlIdentifier() + " AUTHORIZATION " + owner.getSqlIdentifier()); } diff --git a/src/main/java/fr/inra/oresing/rest/AuthorizationResources.java b/src/main/java/fr/inra/oresing/rest/AuthorizationResources.java index 2ed18d77ff577b59cceec05a79ee4c75aaadc0dc..61aa69081d0d2b81ea1953428d5c0698dc0c5cda 100644 --- a/src/main/java/fr/inra/oresing/rest/AuthorizationResources.java +++ b/src/main/java/fr/inra/oresing/rest/AuthorizationResources.java @@ -44,6 +44,12 @@ public class AuthorizationResources { return ResponseEntity.ok(getAuthorizationResults); } + @GetMapping(value = "/applications/{nameOrId}/dataType/{dataType}/grantable", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity<GetGrantableResult> getGrantable(@PathVariable("nameOrId") String applicationNameOrId, @PathVariable("dataType") String dataType) { + GetGrantableResult getGrantableResult = authorizationService.getGrantable(applicationNameOrId, dataType); + return ResponseEntity.ok(getGrantableResult); + } + @DeleteMapping(value = "/applications/{nameOrId}/dataType/{dataType}/authorization/{authorizationId}", produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity<?> revokeAuthorization(@PathVariable("nameOrId") String applicationNameOrId, @PathVariable("authorizationId") UUID authorizationId) { authorizationService.revoke(new AuthorizationRequest(applicationNameOrId, authorizationId)); diff --git a/src/main/java/fr/inra/oresing/rest/AuthorizationService.java b/src/main/java/fr/inra/oresing/rest/AuthorizationService.java index 8cf658a5676278fc26d1f3b1e83539b2f629638a..8f2a4192245cf97c6f2b03f96bc00a6d35322826 100644 --- a/src/main/java/fr/inra/oresing/rest/AuthorizationService.java +++ b/src/main/java/fr/inra/oresing/rest/AuthorizationService.java @@ -1,16 +1,12 @@ package fr.inra.oresing.rest; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; -import com.google.common.collect.Range; -import fr.inra.oresing.model.Application; -import fr.inra.oresing.model.Configuration; -import fr.inra.oresing.model.OreSiAuthorization; -import fr.inra.oresing.persistence.AuthenticationService; -import fr.inra.oresing.persistence.AuthorizationRepository; -import fr.inra.oresing.persistence.OreSiRepository; -import fr.inra.oresing.persistence.SqlPolicy; -import fr.inra.oresing.persistence.SqlSchema; -import fr.inra.oresing.persistence.SqlService; +import com.google.common.collect.ImmutableSortedSet; +import fr.inra.oresing.checker.CheckerFactory; +import fr.inra.oresing.checker.ReferenceLineChecker; +import fr.inra.oresing.model.*; +import fr.inra.oresing.persistence.*; import fr.inra.oresing.persistence.roles.OreSiRightOnApplicationRole; import fr.inra.oresing.persistence.roles.OreSiUserRole; import lombok.extern.slf4j.Slf4j; @@ -20,10 +16,7 @@ import org.springframework.transaction.annotation.Transactional; import org.testcontainers.shaded.com.google.common.base.Preconditions; import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.LinkedHashSet; -import java.util.Set; -import java.util.UUID; +import java.util.*; import java.util.stream.Collectors; @Slf4j @@ -40,6 +33,15 @@ public class AuthorizationService { @Autowired private OreSiRepository repository; + @Autowired + private UserRepository userRepository; + + @Autowired + private OreSiService oreSiService; + + @Autowired + private CheckerFactory checkerFactory; + public UUID addAuthorization(CreateAuthorizationRequest authorization) { OreSiUserRole userRole = authenticationService.getUserRole(authorization.getUserId()); @@ -83,17 +85,20 @@ public class AuthorizationService { usingExpressionElements.add("dataType = '" + dataType + "'"); usingExpressionElements.add("dataGroup = '" + authorization.getDataGroup() + "'"); - String timeScopeSqlExpression = authorization.getTimeScope().toSqlExpression(); - usingExpressionElements.add("timeScope <@ '" + timeScopeSqlExpression + "'"); - - authorization.getAuthorizedScopes().entrySet().stream() - .map(authorizationEntry -> { - String authorizationScope = authorizationEntry.getKey(); - String authorizedScope = authorizationEntry.getValue(); - String usingElement = "jsonb_extract_path_text(requiredAuthorizations, '" + authorizationScope + "')::ltree <@ '" + authorizedScope + "'::ltree"; - return usingElement; - }) - .forEach(usingExpressionElements::add); + Optional.ofNullable(authorization.getTimeScope()) + .map(tc ->tc.toSqlExpression()) + .ifPresent(timeScopeSqlExpression -> { + usingExpressionElements.add("timeScope <@ '" + timeScopeSqlExpression + "'"); + + authorization.getAuthorizedScopes().entrySet().stream() + .map(authorizationEntry -> { + String authorizationScope = authorizationEntry.getKey(); + String authorizedScope = authorizationEntry.getValue(); + String usingElement = "jsonb_extract_path_text(requiredAuthorizations, '" + authorizationScope + "')::ltree <@ '" + authorizedScope + "'::ltree"; + return usingElement; + }) + .forEach(usingExpressionElements::add); + }); String usingExpression = usingExpressionElements.stream() .map(statement -> "(" + statement + ")") @@ -143,28 +148,83 @@ public class AuthorizationService { } private GetAuthorizationResult toGetAuthorizationResult(OreSiAuthorization oreSiAuthorization) { - Range<LocalDateTime> timeScopeRange = oreSiAuthorization.getTimeScope().getRange(); - LocalDate fromDay; - if (timeScopeRange.hasLowerBound()) { - fromDay = timeScopeRange.lowerEndpoint().toLocalDate(); - } else { - fromDay = null; - } - LocalDate toDay; - if (timeScopeRange.hasUpperBound()) { - toDay = timeScopeRange.upperEndpoint().toLocalDate(); - } else { - toDay = null; - } + LocalDate[] dateInterval = new LocalDate[]{null, null}; + Optional.ofNullable(oreSiAuthorization.getTimeScope()) + .map(ts -> ts.getRange()) + .ifPresent(timeScopeRange -> { + if (timeScopeRange.hasLowerBound()) { + dateInterval[0] = timeScopeRange.lowerEndpoint().toLocalDate(); + } else { + dateInterval[0] = null; + } + if (timeScopeRange.hasUpperBound()) { + dateInterval[1] = timeScopeRange.upperEndpoint().toLocalDate(); + } else { + dateInterval[1] = null; + } + }); return new GetAuthorizationResult( - oreSiAuthorization.getId(), - oreSiAuthorization.getOreSiUser(), - oreSiAuthorization.getApplication(), - oreSiAuthorization.getDataType(), - oreSiAuthorization.getDataGroup(), - oreSiAuthorization.getAuthorizedScopes(), - fromDay, - toDay + oreSiAuthorization.getId(), + oreSiAuthorization.getOreSiUser(), + oreSiAuthorization.getApplication(), + oreSiAuthorization.getDataType(), + oreSiAuthorization.getDataGroup(), + oreSiAuthorization.getAuthorizedScopes(), + dateInterval[0], + dateInterval[1] ); } + + public GetGrantableResult getGrantable(String applicationNameOrId, String dataType) { + Application application = repository.application().findApplication(applicationNameOrId); + Configuration configuration = application.getConfiguration(); + Preconditions.checkArgument(configuration.getDataTypes().containsKey(dataType)); + ImmutableSortedSet<GetGrantableResult.User> users = getGrantableUsers(); + ImmutableSortedSet<GetGrantableResult.DataGroup> dataGroups = getDataGroups(application, dataType); + ImmutableSortedSet<GetGrantableResult.AuthorizationScope> authorizationScopes = getAuthorizationScopes(application, dataType); + return new GetGrantableResult(users, dataGroups, authorizationScopes); + } + + private ImmutableSortedSet<GetGrantableResult.DataGroup> getDataGroups(Application application, String dataType) { + ImmutableSortedSet<GetGrantableResult.DataGroup> dataGroups = application.getConfiguration().getDataTypes().get(dataType).getAuthorization().getDataGroups().entrySet().stream() + .map(dataGroupEntry -> new GetGrantableResult.DataGroup(dataGroupEntry.getKey(), dataGroupEntry.getValue().getLabel())) + .collect(ImmutableSortedSet.toImmutableSortedSet(Comparator.comparing(GetGrantableResult.DataGroup::getId))); + return dataGroups; + } + + private ImmutableSortedSet<GetGrantableResult.User> getGrantableUsers() { + List<OreSiUser> allUsers = userRepository.findAll(); + ImmutableSortedSet<GetGrantableResult.User> users = allUsers.stream() + .map(oreSiUserEntity -> new GetGrantableResult.User(oreSiUserEntity.getId(), oreSiUserEntity.getLogin())) + .collect(ImmutableSortedSet.toImmutableSortedSet(Comparator.comparing(GetGrantableResult.User::getId))); + return users; + } + + private ImmutableSortedSet<GetGrantableResult.AuthorizationScope> getAuthorizationScopes(Application application, String dataType) { + ImmutableMap<VariableComponentKey, ReferenceLineChecker> referenceLineCheckers = checkerFactory.getReferenceLineCheckers(application, dataType); + Configuration.AuthorizationDescription authorizationDescription = application.getConfiguration().getDataTypes().get(dataType).getAuthorization(); + ImmutableSortedSet<GetGrantableResult.AuthorizationScope> authorizationScopes = authorizationDescription.getAuthorizationScopes().entrySet().stream() + .map(authorizationScopeEntry -> { + String variable = authorizationScopeEntry.getValue().getVariable(); + String component = authorizationScopeEntry.getValue().getComponent(); + VariableComponentKey variableComponentKey = new VariableComponentKey(variable, component); + ReferenceLineChecker referenceLineChecker = referenceLineCheckers.get(variableComponentKey); + String lowestLevelReference = referenceLineChecker.getRefType(); + HierarchicalReferenceAsTree hierarchicalReferenceAsTree = oreSiService.getHierarchicalReferenceAsTree(application, lowestLevelReference); + ImmutableSortedSet<GetGrantableResult.AuthorizationScope.Option> rootOptions = hierarchicalReferenceAsTree.getRoots().stream() + .map(rootReferenceValue -> toOption(hierarchicalReferenceAsTree, rootReferenceValue)) + .collect(ImmutableSortedSet.toImmutableSortedSet(Comparator.comparing(GetGrantableResult.AuthorizationScope.Option::getId))); + String authorizationScopeId = authorizationScopeEntry.getKey(); + return new GetGrantableResult.AuthorizationScope(authorizationScopeId, authorizationScopeId, rootOptions); + }) + .collect(ImmutableSortedSet.toImmutableSortedSet(Comparator.comparing(GetGrantableResult.AuthorizationScope::getId))); + return authorizationScopes; + } + + private GetGrantableResult.AuthorizationScope.Option toOption(HierarchicalReferenceAsTree tree, ReferenceValue referenceValue) { + ImmutableSortedSet<GetGrantableResult.AuthorizationScope.Option> options = tree.getChildren(referenceValue).stream() + .map(child -> toOption(tree, child)) + .collect(ImmutableSortedSet.toImmutableSortedSet(Comparator.comparing(GetGrantableResult.AuthorizationScope.Option::getId))); + return new GetGrantableResult.AuthorizationScope.Option(referenceValue.getHierarchicalKey(), referenceValue.getHierarchicalKey(), options); + } } diff --git a/src/main/java/fr/inra/oresing/rest/GetGrantableResult.java b/src/main/java/fr/inra/oresing/rest/GetGrantableResult.java new file mode 100644 index 0000000000000000000000000000000000000000..70eae042d015302b783195104d9f75e582455cca --- /dev/null +++ b/src/main/java/fr/inra/oresing/rest/GetGrantableResult.java @@ -0,0 +1,40 @@ +package fr.inra.oresing.rest; + +import lombok.Value; + +import java.util.Set; +import java.util.UUID; + +@Value +public class GetGrantableResult { + + Set<User> users; + Set<DataGroup> dataGroups; + Set<AuthorizationScope> authorizationScopes; + + @Value + public static class User { + UUID id; + String label; + } + + @Value + public static class DataGroup { + String id; + String label; + } + + @Value + public static class AuthorizationScope { + String id; + String label; + Set<Option> options; + + @Value + public static class Option { + String id; + String label; + Set<Option> children; + } + } +} diff --git a/src/main/java/fr/inra/oresing/rest/HierarchicalReferenceAsTree.java b/src/main/java/fr/inra/oresing/rest/HierarchicalReferenceAsTree.java new file mode 100644 index 0000000000000000000000000000000000000000..14d88fdf38b3a9f6d3549c0832c4ea07f7ff025f --- /dev/null +++ b/src/main/java/fr/inra/oresing/rest/HierarchicalReferenceAsTree.java @@ -0,0 +1,18 @@ +package fr.inra.oresing.rest; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSetMultimap; +import fr.inra.oresing.model.ReferenceValue; +import lombok.Value; + +@Value +public class HierarchicalReferenceAsTree { + + ImmutableSetMultimap<ReferenceValue, ReferenceValue> tree; + + ImmutableSet<ReferenceValue> roots; + + public ImmutableSet<ReferenceValue> getChildren(ReferenceValue referenceValue) { + return getTree().get(referenceValue); + } +} diff --git a/src/main/java/fr/inra/oresing/rest/OreSiService.java b/src/main/java/fr/inra/oresing/rest/OreSiService.java index 7f06aa49dd565c47b5d991b3cb9b718e1390023d..3f3b13693e0211e1163ced9cd4b45b72c1ac4889 100644 --- a/src/main/java/fr/inra/oresing/rest/OreSiService.java +++ b/src/main/java/fr/inra/oresing/rest/OreSiService.java @@ -4,15 +4,22 @@ import com.google.common.base.Charsets; import com.google.common.base.Preconditions; import com.google.common.base.Predicate; import com.google.common.base.Splitter; +import com.google.common.collect.BiMap; +import com.google.common.collect.HashBiMap; +import com.google.common.collect.HashMultimap; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMultiset; import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSetMultimap; +import com.google.common.collect.ImmutableSortedSet; import com.google.common.collect.Iterables; import com.google.common.collect.Iterators; import com.google.common.collect.Maps; import com.google.common.collect.MoreCollectors; +import com.google.common.collect.Ordering; +import com.google.common.collect.SetMultimap; +import com.google.common.collect.Sets; import com.google.common.primitives.Ints; import fr.inra.oresing.OreSiTechnicalException; import fr.inra.oresing.checker.CheckerFactory; @@ -385,6 +392,36 @@ public class OreSiService { Splitter.on(LTREE_SEPARATOR).split(compositeKey).forEach(this::checkNaturalKeySyntax); } + HierarchicalReferenceAsTree getHierarchicalReferenceAsTree(Application application, String lowestLevelReference) { + ReferenceValueRepository referenceValueRepository = repo.getRepository(application).referenceValue(); + Configuration.CompositeReferenceDescription compositeReferenceDescription = application.getConfiguration().getCompositeReferencesUsing(lowestLevelReference).orElseThrow(); + BiMap<String, ReferenceValue> indexedByHierarchicalKeyReferenceValues = HashBiMap.create(); + Map<ReferenceValue, String> parentHierarchicalKeys = new LinkedHashMap<>(); + ImmutableList<String> referenceTypes = compositeReferenceDescription.getComponents().stream() + .map(Configuration.CompositeReferenceComponentDescription::getReference) + .collect(ImmutableList.toImmutableList()); + ImmutableSortedSet<String> sortedReferenceTypes = ImmutableSortedSet.copyOf(Ordering.explicit(referenceTypes), referenceTypes); + ImmutableSortedSet<String> includedReferences = sortedReferenceTypes.headSet(lowestLevelReference, true); + compositeReferenceDescription.getComponents().stream() + .filter(compositeReferenceComponentDescription -> includedReferences.contains(compositeReferenceComponentDescription.getReference())) + .forEach(compositeReferenceComponentDescription -> { + String reference = compositeReferenceComponentDescription.getReference(); + String parentKeyColumn = compositeReferenceComponentDescription.getParentKeyColumn(); + referenceValueRepository.findAllByReferenceType(reference).forEach(referenceValue -> { + indexedByHierarchicalKeyReferenceValues.put(referenceValue.getHierarchicalKey(), referenceValue); + if (parentKeyColumn != null) { + String parentHierarchicalKey = referenceValue.getRefValues().get(parentKeyColumn); + parentHierarchicalKeys.put(referenceValue, parentHierarchicalKey); + } + }); + }); + Map<ReferenceValue, ReferenceValue> childToParents = Maps.transformValues(parentHierarchicalKeys, indexedByHierarchicalKeyReferenceValues::get); + SetMultimap<ReferenceValue, ReferenceValue> tree = HashMultimap.create(); + childToParents.forEach((child, parent) -> tree.put(parent, child)); + ImmutableSet<ReferenceValue> roots = Sets.difference(indexedByHierarchicalKeyReferenceValues.values(), parentHierarchicalKeys.keySet()).immutableCopy(); + return new HierarchicalReferenceAsTree(ImmutableSetMultimap.copyOf(tree), roots); + } + @Value private static class RowWithData { int lineNumber; diff --git a/src/test/java/fr/inra/oresing/rest/AuthorizationResourcesTest.java b/src/test/java/fr/inra/oresing/rest/AuthorizationResourcesTest.java index 8f32ccaa13868cc690b43511237debfb42c7be45..e26a48b0bba14985133ca4da061e53dbd733bac0 100644 --- a/src/test/java/fr/inra/oresing/rest/AuthorizationResourcesTest.java +++ b/src/test/java/fr/inra/oresing/rest/AuthorizationResourcesTest.java @@ -78,6 +78,14 @@ public class AuthorizationResourcesTest { .andExpect(status().is4xxClientError()); } + { + String response = mockMvc.perform(get("/api/v1/applications/acbb/dataType/biomasse_production_teneur/grantable") + .cookie(authCookie) + ).andReturn().getResponse().getContentAsString(); + Assert.assertTrue(response.contains("lusignan")); + Assert.assertTrue(response.contains("laqueuille.laqueuille__1")); + } + { String json = "{\"userId\":\"" + readerUserId + "\",\"applicationNameOrId\":\"acbb\",\"dataType\":\"biomasse_production_teneur\",\"dataGroup\":\"all\",\"authorizedScopes\":{\"localization\":\"theix.theix__22\"},\"fromDay\":[2010,1,1],\"toDay\":[2010,6,1]}"; diff --git a/ui2/src/components/common/CollapsibleTree.vue b/ui2/src/components/common/CollapsibleTree.vue index c372b3648742dd88babe570a0eac8bc6c20f3775..7f60add94cef608a69b5ec53825d905a0218e0fd 100644 --- a/ui2/src/components/common/CollapsibleTree.vue +++ b/ui2/src/components/common/CollapsibleTree.vue @@ -1,24 +1,36 @@ <template> <div> <div - :class="`CollapsibleTree-header ${children && children.length !== 0 ? 'clickable' : ''} ${ - children && children.length !== 0 && displayChildren ? '' : 'mb-1' - }`" + :class="`CollapsibleTree-header ${ + option.children && option.children.length !== 0 ? 'clickable' : '' + } ${option.children && option.children.length !== 0 && displayChildren ? '' : 'mb-1'}`" :style="`background-color:rgba(240, 245, 245, ${1 - level / 2})`" @click="displayChildren = !displayChildren" > <div class="CollapsibleTree-header-infos"> - <FontAwesomeIcon - v-if="children && children.length !== 0" - :icon="displayChildren ? 'caret-down' : 'caret-right'" - class="clickable mr-3" - /> - <div - class="link" - :style="`transform:translate(${level * 50}px);`" - @click="(event) => onClickLabelCb(event, label)" - > - {{ label }} + <div class="CollapsibleTree-header-infos" :style="`transform:translate(${level * 50}px);`"> + <FontAwesomeIcon + v-if="option.children && option.children.length !== 0" + :icon="displayChildren ? 'caret-down' : 'caret-right'" + class="clickable mr-3" + /> + + <b-radio + v-if="withRadios" + v-model="innerOptionChecked" + :name="radioName" + @click.native="stopPropagation" + :native-value="option.id" + > + {{ option.label }} + </b-radio> + <div + v-else + :class="onClickLabelCb ? 'link' : ''" + @click="(event) => onClickLabelCb && onClickLabelCb(event, option.label)" + > + {{ option.label }} + </div> </div> </div> <div class="CollapsibleTree-buttons"> @@ -27,7 +39,7 @@ v-model="refFile" class="file-label" accept=".csv" - @input="() => onUploadCb(label, refFile)" + @input="() => onUploadCb(option.label, refFile)" > <span class="file-name" v-if="refFile"> {{ refFile.name }} @@ -41,7 +53,7 @@ <b-button :icon-left="button.iconName" size="is-small" - @click="button.clickCb(label)" + @click="button.clickCb(option.label)" class="ml-1" :type="button.type" > @@ -50,38 +62,50 @@ </div> </div> </div> - <div v-if="displayChildren"> - <CollapsibleTree - v-for="child in children" - :key="child.id" - :label="child.label" - :children="child.children" - :level="level + 1" - :onClickLabelCb="onClickLabelCb" - :onUploadCb="onUploadCb" - :buttons="buttons" - /> - </div> + <CollapsibleTree + v-for="child in option.children" + :key="child.id" + :option="child" + :level="level + 1" + :onClickLabelCb="onClickLabelCb" + :onUploadCb="onUploadCb" + :buttons="buttons" + :class="displayChildren ? '' : 'hide'" + :withRadios="withRadios" + :radioName="radioName" + @optionChecked="onInnerOptionChecked" + /> </div> </template> <script> -import { Component, Prop, Vue } from "vue-property-decorator"; +import { Component, Prop, Vue, Watch } from "vue-property-decorator"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; @Component({ components: { FontAwesomeIcon }, }) export default class CollapsibleTree extends Vue { - @Prop() label; - @Prop() children; - @Prop() level; + @Prop() option; + @Prop({ default: 0 }) level; @Prop() onClickLabelCb; @Prop() onUploadCb; @Prop() buttons; + @Prop({ default: false }) withRadios; + @Prop() radioName; displayChildren = false; refFile = null; + innerOptionChecked = null; + + @Watch("innerOptionChecked") + onInnerOptionChecked(value) { + this.$emit("optionChecked", value); + } + + stopPropagation(event) { + event.stopPropagation(); + } } </script> diff --git a/ui2/src/components/datatype/DataTypeDetailsPanel.vue b/ui2/src/components/datatype/DataTypeDetailsPanel.vue new file mode 100644 index 0000000000000000000000000000000000000000..bad14a483393e6ac149b3232f6a9337771d75804 --- /dev/null +++ b/ui2/src/components/datatype/DataTypeDetailsPanel.vue @@ -0,0 +1,47 @@ +<template> + <SidePanel + :open="open" + :leftAlign="leftAlign" + :title="dataType && dataType.label" + :closeCb="closeCb" + > + <div class="Panel-buttons"> + <b-button type="is-primary" icon-left="key" @click="consultAuthorization">{{ + $t("dataTypesManagement.consult-authorization") + }}</b-button> + </div> + </SidePanel> +</template> + +<script> +import { Component, Prop, Vue } from "vue-property-decorator"; +import SidePanel from "../common/SidePanel.vue"; + +@Component({ + components: { SidePanel }, +}) +export default class DataTypeDetailsPanel extends Vue { + @Prop({ default: false }) leftAlign; + @Prop({ default: false }) open; + @Prop() dataType; + @Prop() closeCb; + @Prop() applicationName; + + consultAuthorization() { + this.$router.push( + `/applications/${this.applicationName}/dataTypes/${this.dataType.id}/authorizations` + ); + } +} +</script> + +<style lang="scss" scoped> +.Panel-buttons { + display: flex; + flex-direction: column; + + .button { + margin-bottom: 0.5rem; + } +} +</style> diff --git a/ui2/src/components/login/Register.vue b/ui2/src/components/login/Register.vue new file mode 100644 index 0000000000000000000000000000000000000000..a5cbb0495495741d871e6ea064501837294796d4 --- /dev/null +++ b/ui2/src/components/login/Register.vue @@ -0,0 +1,127 @@ +<template> + <ValidationObserver ref="observer" v-slot="{ handleSubmit }"> + <section> + <ValidationProvider rules="required" name="login" v-slot="{ errors, valid }" vid="login"> + <b-field + class="input-field" + :type="{ + 'is-danger': errors && errors.length > 0, + 'is-success': valid, + }" + :message="errors[0]" + > + <template slot="label"> + {{ $t("login.login") }} + <span class="mandatory"> + {{ $t("validation.obligatoire") }} + </span> + </template> + <b-input v-model="login" :placeholder="$t('login.login-placeholder')"> </b-input> + </b-field> + </ValidationProvider> + + <ValidationProvider + rules="required" + name="password" + v-slot="{ errors, valid }" + vid="password" + > + <b-field + class="input-field" + :type="{ + 'is-danger': errors && errors.length > 0, + 'is-success': valid, + }" + :message="errors[0]" + > + <template slot="label"> + {{ $t("login.pwd") }} + <span class="mandatory"> + {{ $t("validation.obligatoire") }} + </span> + </template> + <b-input + type="password" + v-model="password" + :placeholder="$t('login.pwd-placeholder')" + :password-reveal="true" + > + </b-input> + </b-field> + </ValidationProvider> + + <ValidationProvider + rules="required|confirmed:password" + name="confirm_password" + v-slot="{ errors, valid }" + vid="confirm_password" + > + <b-field + class="input-field" + :type="{ + 'is-danger': errors && errors.length > 0, + 'is-success': valid, + }" + :message="errors[0]" + > + <template slot="label"> + {{ $t("login.confirm-pwd") }} + <span class="mandatory"> + {{ $t("validation.obligatoire") }} + </span> + </template> + <b-input + type="password" + v-model="confirmedPwd" + :placeholder="$t('login.pwd-placeholder')" + :password-reveal="true" + @keyup.native.enter="handleSubmit(register)" + > + </b-input> + </b-field> + </ValidationProvider> + </section> + + <div class="buttons"> + <b-button type="is-primary" @click="handleSubmit(register)" icon-left="user-plus"> + {{ $t("login.register") }} + </b-button> + </div> + </ValidationObserver> +</template> + +<script> +import { Component, Vue } from "vue-property-decorator"; +import { ValidationObserver, ValidationProvider } from "vee-validate"; +import { LoginService } from "@/services/rest/LoginService"; +import { AlertService } from "@/services/AlertService"; + +@Component({ + components: { ValidationObserver, ValidationProvider }, +}) +export default class Register extends Vue { + loginService = LoginService.INSTANCE; + alertService = AlertService.INSTANCE; + + login = ""; + password = ""; + confirmedPwd = ""; + + async register() { + try { + await this.loginService.register(this.login, this.password); + this.alertService.toastSuccess(this.$t("alert.registered-user")); + this.resetVariables(); + this.$emit("userRegistered"); + } catch (error) { + this.alertService.toastServerError(error); + } + } + + resetVariables() { + this.login = ""; + this.password = ""; + this.confirmedPwd = ""; + } +} +</script> diff --git a/ui2/src/components/login/Signin.vue b/ui2/src/components/login/Signin.vue index 87a70f24931c8ea4f2b93d8f8785ca09bc2e2e9a..93f284d200f0f985ba9aac7499e8cdfffbe3e11f 100644 --- a/ui2/src/components/login/Signin.vue +++ b/ui2/src/components/login/Signin.vue @@ -45,6 +45,7 @@ v-model="password" :placeholder="$t('login.pwd-placeholder')" :password-reveal="true" + @keyup.native.enter="handleSubmit(signIn)" > </b-input> </b-field> @@ -52,7 +53,7 @@ </section> <div class="buttons"> - <b-button type="is-primary" @click="handleSubmit(signIn)" icon-right="plus"> + <b-button type="is-primary" @click="handleSubmit(signIn)" icon-left="sign-in-alt"> {{ $t("login.signin") }} </b-button> <router-link :to="{ path: '/' }"> diff --git a/ui2/src/locales/en.json b/ui2/src/locales/en.json index a135564113b3879887c6369def470c1ce3363c55..ad1f74668efe4c34bc2773fd0ce8fa011db27764 100644 --- a/ui2/src/locales/en.json +++ b/ui2/src/locales/en.json @@ -5,23 +5,27 @@ "references-page": "{applicationName} references", "references-data": "{refName} data", "application-creation": "Application creation", - "data-types-page": "{applicationName} data types" + "data-types-page": "{applicationName} data types", + "data-type-authorizations": "{dataType} authorizations", + "data-type-new-authorization": "New authorization for {dataType}" }, "login": { "signin": "Sign in", - "signup": "Sign up", "login": "Login", "login-placeholder": "Ex: michel", "pwd": "Password", "pwd-placeholder": "Ex: xxxx", - "pwd-forgotten": "Forgotten password ? " + "pwd-forgotten": "Forgotten password ? ", + "register": "Register", + "confirm-pwd": "Confirmer le mot de passe" }, "validation": { "obligatoire": "Mandatory", "facultatif": "Optional", "invalid-required": "Please fill the field", "invalid-application-name": "The name must only includes lowercase letters.", - "invalid-application-name-length": "The name's length should be between 4 and 20 characters." + "invalid-application-name-length": "The name's length should be between 4 and 20 characters.", + "invalid-confirmed": "Fields don't match." }, "alert": { "cancel": "Cancel", @@ -34,7 +38,10 @@ "delete": "Delete", "reference-csv-upload-error": "An error occured while uploading the csv file", "reference-updated": "Reference updated", - "data-updated": "Data type updated" + "data-updated": "Data type updated", + "registered-user": "User registered", + "revoke-authorization": "Authorization revoked", + "create-authorization": "Authorization created" }, "message": { "app-config-error": "Error in yaml file", @@ -103,6 +110,29 @@ "data": "Data" }, "dataTypesManagement": { - "data-types": "Data types" + "data-types": "Data types", + "consult-authorization": "Consult authorizations" + }, + "dataTypeAuthorizations": { + "add-auhtorization": "Add an authorization", + "sub-menu-data-type-authorizations": "{dataType} authorizations", + "sub-menu-new-authorization": "new authorization", + "users": "Users", + "users-placeholder": "Chose users to authorize", + "data-groups": "Data groups", + "data-groups-placeholder": "Chose data groups to authorize", + "authorization-scopes": "Authorization scopes", + "period": "Authorization period", + "from-date": "From date : ", + "to-date": "To date : ", + "from-date-to-date": "From date to date : ", + "always": "Always", + "to": "to", + "create": "Create authorization", + "user": "User", + "data-group": "Data group", + "data-type": "Data type", + "actions": "Actions", + "revoke": "Revoke" } } diff --git a/ui2/src/locales/fr.json b/ui2/src/locales/fr.json index 4d090ad0b0616aa930bc0717586536e08e8e8355..6d3bd70130bb126a5fdec93b51f5ed928fa879e3 100644 --- a/ui2/src/locales/fr.json +++ b/ui2/src/locales/fr.json @@ -5,23 +5,27 @@ "references-page": "Référentiels de {applicationName}", "references-data": "Données de {refName}", "application-creation": "Créer une application", - "data-types-page": "Type de données de {applicationName}" + "data-types-page": "Type de données de {applicationName}", + "data-type-authorizations": "Autorisations de {dataType}", + "data-type-new-authorization": "Nouvelle autorisation pour {dataType}" }, "login": { "signin": "Se connecter", - "signup": "Créer un compte", "login": "Identifiant", "login-placeholder": "Ex: michel", "pwd": "Mot de passe", "pwd-placeholder": "Ex: xxxx", - "pwd-forgotten": "Mot de passe oublié ?" + "pwd-forgotten": "Mot de passe oublié ?", + "register": "Créer un compte", + "confirm-pwd": "Confirmer le mot de passe" }, "validation": { "obligatoire": "Obligatoire", "facultatif": "Facultatif", "invalid-required": "Merci de remplir le champ", - "invalid-application-name": "Le nom ne doit comporter que des lettresminuscules .", - "invalid-application-name-length": "Le nom doit être compris en 4 et 20 caractères." + "invalid-application-name": "Le nom ne doit comporter que des lettres minuscules .", + "invalid-application-name-length": "Le nom doit être compris en 4 et 20 caractères.", + "invalid-confirmed": "Les deux champs ne correspondent pas." }, "alert": { "cancel": "Annuler", @@ -34,7 +38,10 @@ "delete": "Supprimer", "reference-csv-upload-error": "Une erreur s'est produite au téléversement du fichier csv", "reference-updated": "Référentiel mis à jour", - "data-updated": "Type de donnée mis à jour" + "data-updated": "Type de donnée mis à jour", + "registered-user": "Compte utilisateur créé", + "revoke-authorization": "Autorisation révoquée", + "create-authorization": "Autorisation créée" }, "message": { "app-config-error": "Erreur dans le fichier yaml", @@ -103,6 +110,29 @@ "data": "Données" }, "dataTypesManagement": { - "data-types": "Type de données" + "data-types": "Type de données", + "consult-authorization": "Consulter les autorisations" + }, + "dataTypeAuthorizations": { + "add-auhtorization": "Ajouter une autorisation", + "sub-menu-data-type-authorizations": "autorisations de {dataType}", + "sub-menu-new-authorization": "nouvelle autorisation", + "users": "Utilisateurs", + "users-placeholder": "Choisir les utilisateurs à autoriser", + "data-groups": "Groupes de données", + "data-groups-placeholder": "Choisir les données à autoriser", + "authorization-scopes": "Périmètres d'autorisation", + "period": "Période d'autorisation", + "from-date": "À partir du", + "to-date": "Jusqu'au", + "from-date-to-date": "De date à date", + "always": "Toujours", + "to": "à ", + "create": "Créer l'autorisation", + "user": "Utilisateur", + "data-group": "Groupe de données", + "data-type": "Type de donnée", + "actions": "Actions", + "revoke": "Révoquer" } } diff --git a/ui2/src/main.js b/ui2/src/main.js index 7936b7271481b4b02125c9223e7d06e0e6e8fec1..d5ac2dbba1ef50abf9b1f1f464c5d265adec53cb 100644 --- a/ui2/src/main.js +++ b/ui2/src/main.js @@ -28,6 +28,14 @@ import { faVial, faCaretRight, faArrowLeft, + faSignInAlt, + faUserPlus, + faUserAstronaut, + faKey, + faChevronUp, + faChevronDown, + faCalendarDay, + faPaperPlane, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; library.add( @@ -53,7 +61,15 @@ library.add( faDownload, faVial, faCaretRight, - faArrowLeft + faArrowLeft, + faSignInAlt, + faUserPlus, + faUserAstronaut, + faKey, + faChevronUp, + faChevronDown, + faCalendarDay, + faPaperPlane ); Vue.component("vue-fontawesome", FontAwesomeIcon); @@ -77,7 +93,7 @@ export const i18n = new VueI18n({ // Validation import "vee-validate"; -import { required } from "vee-validate/dist/rules"; +import { confirmed, required } from "vee-validate/dist/rules"; import { extend } from "vee-validate"; // Ici on surcharge les messages d'erreur de vee-validate. // Pour plus de règles : https://logaretm.github.io/vee-validate/guide/rules.html @@ -87,6 +103,11 @@ extend("required", { message: i18n.t("validation.invalid-required"), }); +extend("confirmed", { + ...confirmed, + message: i18n.t("validation.invalid-confirmed").toString(), +}); + extend("validApplicationName", { message: i18n.t("validation.invalid-application-name"), validate: (value) => { @@ -101,6 +122,14 @@ extend("validApplicationNameLength", { }, }); +// extend("dateIsAfter", { +// message: i18n.t("validation.date-not-after").toString(), +// validate: (value, { min }: Record<string, any>) => { +// return isAfter(value, new Date(min)) +// }, +// params: ["min"], +// }) + // Buefy Vue.use(Buefy, { defaultIconComponent: "vue-fontawesome", diff --git a/ui2/src/model/DataTypeAuthorization.js b/ui2/src/model/DataTypeAuthorization.js new file mode 100644 index 0000000000000000000000000000000000000000..7b2f08f11abe1dbda6d177b9bddccfee32786147 --- /dev/null +++ b/ui2/src/model/DataTypeAuthorization.js @@ -0,0 +1,9 @@ +export class DataTypeAuthorization { + userId; + applicationNameOrId; + dataType; + dataGroup; + authorizedScopes; + fromDay; + toDay; +} diff --git a/ui2/src/router/index.js b/ui2/src/router/index.js index 14cca11457f1a79ba4057c3165a9c342dfed190b..b3ea63b1b93fc586a1fdf9b0fec606d3054b37a2 100644 --- a/ui2/src/router/index.js +++ b/ui2/src/router/index.js @@ -7,6 +7,8 @@ import ReferencesManagementView from "@/views/references/ReferencesManagementVie import ReferenceTable from "@/views/references/ReferenceTableView.vue"; import DataTypeTableView from "@/views/datatype/DataTypeTableView.vue"; import DataTypesManagementView from "@/views/datatype/DataTypesManagementView.vue"; +import DataTypeAuthorizationsView from "@/views/authorizations/DataTypeAuthorizationsView.vue"; +import DataTypeAuthorizationInfoView from "@/views/authorizations/DataTypeAuthorizationInfoView.vue"; Vue.use(VueRouter); @@ -51,6 +53,16 @@ const routes = [ component: DataTypeTableView, props: true, }, + { + path: "/applications/:applicationName/dataTypes/:dataTypeId/authorizations", + component: DataTypeAuthorizationsView, + props: true, + }, + { + path: "/applications/:applicationName/dataTypes/:dataTypeId/authorizations/:authorizationId", + component: DataTypeAuthorizationInfoView, + props: true, + }, ]; const router = new VueRouter({ diff --git a/ui2/src/services/Fetcher.js b/ui2/src/services/Fetcher.js index a5f89e63fc0fc779762dcd252631598d151527d9..35934f54fdeb9c5cc22812cb0f7c8099a9766744 100644 --- a/ui2/src/services/Fetcher.js +++ b/ui2/src/services/Fetcher.js @@ -7,17 +7,24 @@ export const LOCAL_STORAGE_LANG = "lang"; export const LOCAL_STORAGE_AUTHENTICATED_USER = "authenticatedUser"; export class Fetcher { - async post(url, data) { - const formData = this.convertToFormData(data); + async post(url, data, withFormData = true) { + let body = JSON.stringify(data); + if (withFormData) { + body = this.convertToFormData(data); + } + const headers = withFormData + ? { "Accept-Language": this.getUserPrefLocale() } + : { + "Accept-Language": this.getUserPrefLocale(), + "Content-Type": "application/json;charset=UTF-8;multipart/form-data", + }; const response = await fetch(`${config.API_URL}${url}`, { method: "POST", mode: "cors", credentials: "include", - body: formData, - headers: { - "Accept-Language": this.getUserPrefLocale(), - }, + body: body, + headers: headers, }); return this._handleResponse(response); diff --git a/ui2/src/services/rest/AuthorizationService.js b/ui2/src/services/rest/AuthorizationService.js new file mode 100644 index 0000000000000000000000000000000000000000..c369b7110837cad403de7f997b98225851a8e69c --- /dev/null +++ b/ui2/src/services/rest/AuthorizationService.js @@ -0,0 +1,31 @@ +import { Fetcher } from "../Fetcher"; + +export class AuthorizationService extends Fetcher { + static INSTANCE = new AuthorizationService(); + + constructor() { + super(); + } + + async getDataAuthorizations(applicationName, dataTypeId) { + return this.get(`applications/${applicationName}/dataType/${dataTypeId}/authorization`); + } + + async getAuthorizationGrantableInfos(applicationName, dataTypeId) { + return this.get(`applications/${applicationName}/dataType/${dataTypeId}/grantable`); + } + + async createAuthorization(applicationName, dataTypeId, authorizationModel) { + return this.post( + `applications/${applicationName}/dataType/${dataTypeId}/authorization`, + authorizationModel, + false + ); + } + + async revokeAuthorization(applicationName, dataTypeId, authorizationId) { + return this.delete( + `applications/${applicationName}/dataType/${dataTypeId}/authorization/${authorizationId}` + ); + } +} diff --git a/ui2/src/services/rest/LoginService.js b/ui2/src/services/rest/LoginService.js index 6ab7d2575ad29225a29dd38943bfda38b289db20..045efddf755fac1ea73c3815ed99c4c1f5bfa347 100644 --- a/ui2/src/services/rest/LoginService.js +++ b/ui2/src/services/rest/LoginService.js @@ -30,6 +30,13 @@ export class LoginService extends Fetcher { return Promise.resolve(response); } + async register(login, pwd) { + return this.post("users", { + login: login, + password: pwd, + }); + } + async logout() { await this.delete("logout"); this.notifyCrendentialsLost(); diff --git a/ui2/src/style/_common.scss b/ui2/src/style/_common.scss index 04baf8edb161acee274059879406507a78225e15..f4caa1b3c751e6bc117876221321e5041ea71ebd 100644 --- a/ui2/src/style/_common.scss +++ b/ui2/src/style/_common.scss @@ -31,6 +31,10 @@ a { } } +.hide { + display: none; +} + // Input style .input-field { @@ -70,3 +74,13 @@ a { .message .media { align-items: center; } + +.pagination-link.is-current { + background-color: $dark; + border-color: $dark; +} + +a.dropdown-item { + display: flex; + align-items: center; +} diff --git a/ui2/src/views/LoginView.vue b/ui2/src/views/LoginView.vue index 9530262eb91e667148e5ce3e19360cf51d6ee15d..d1cadcf51b8de8a2566a3258a1683c4ce79e1ec2 100644 --- a/ui2/src/views/LoginView.vue +++ b/ui2/src/views/LoginView.vue @@ -2,24 +2,34 @@ <PageView class="LoginView" :hasMenu="false"> <h1 class="title main-title">{{ $t("titles.login-page") }}</h1> <div class="card LoginView-card"> - <b-tabs type="is-boxed"> - <b-tab-item :label="$t('login.signin')"> + <b-tabs v-model="selectedTab" type="is-boxed" :animated="false"> + <b-tab-item :label="$t('login.signin')" icon="sign-in-alt"> <SignIn /> </b-tab-item> + <b-tab-item :label="$t('login.register')" icon="user-plus"> + <Register @userRegistered="changeTabToSignIn" /> + </b-tab-item> </b-tabs> </div> </PageView> </template> <script> +import Register from "@/components/login/Register.vue"; import SignIn from "@/components/login/Signin.vue"; import { Component, Vue } from "vue-property-decorator"; import PageView from "./common/PageView.vue"; @Component({ - components: { PageView, SignIn }, + components: { PageView, SignIn, Register }, }) -export default class LoginView extends Vue {} +export default class LoginView extends Vue { + selectedTab = 0; + + changeTabToSignIn() { + this.selectedTab = 0; + } +} </script> <style lang="scss"> diff --git a/ui2/src/views/application/ApplicationCreationView.vue b/ui2/src/views/application/ApplicationCreationView.vue index 56f125e514f3730973be1ccc9052bf4a954dde0f..de85979b8f05853a79688a8f0ff9961650a28466 100644 --- a/ui2/src/views/application/ApplicationCreationView.vue +++ b/ui2/src/views/application/ApplicationCreationView.vue @@ -56,10 +56,10 @@ </b-field> </ValidationProvider> <div class="buttons"> - <b-button type="is-light" @click="handleSubmit(testApplication)" icon-right="vial"> + <b-button type="is-light" @click="handleSubmit(testApplication)" icon-left="vial"> {{ $t("applications.test") }} </b-button> - <b-button type="is-primary" @click="handleSubmit(createApplication)" icon-right="plus"> + <b-button type="is-primary" @click="handleSubmit(createApplication)" icon-left="plus"> {{ $t("applications.create") }} </b-button> </div> @@ -108,6 +108,7 @@ export default class ApplicationCreationView extends Vue { try { await this.applicationService.createApplication(this.applicationConfig); this.alertService.toastSuccess(this.$t("alert.application-creation-success")); + this.$router.push("/applications"); } catch (error) { this.checkMessageErrors(error); } diff --git a/ui2/src/views/application/ApplicationsView.vue b/ui2/src/views/application/ApplicationsView.vue index 6abee73086c81555e6fe0be76737d25af71cb501..287a243799c97f919eb9a370a261abe31f603aea 100644 --- a/ui2/src/views/application/ApplicationsView.vue +++ b/ui2/src/views/application/ApplicationsView.vue @@ -2,7 +2,7 @@ <PageView> <h1 class="title main-title">{{ $t("titles.applications-page") }}</h1> <div class="buttons" v-if="canCreateApplication"> - <b-button type="is-primary" @click="createApplication" icon-right="plus"> + <b-button type="is-primary" @click="createApplication" icon-left="plus"> {{ $t("applications.create") }} </b-button> </div> @@ -52,7 +52,8 @@ export default class ApplicationsView extends Vue { applicationService = ApplicationService.INSTANCE; applications = []; - canCreateApplication = LoginService.INSTANCE.getAuthenticatedUser().authorizedForApplicationCreation; + canCreateApplication = LoginService.INSTANCE.getAuthenticatedUser() + .authorizedForApplicationCreation; created() { this.init(); diff --git a/ui2/src/views/authorizations/DataTypeAuthorizationInfoView.vue b/ui2/src/views/authorizations/DataTypeAuthorizationInfoView.vue new file mode 100644 index 0000000000000000000000000000000000000000..b7af2cfd9c7b181676d61225e5fa8b3dce70d09a --- /dev/null +++ b/ui2/src/views/authorizations/DataTypeAuthorizationInfoView.vue @@ -0,0 +1,428 @@ +<template> + <PageView class="with-submenu"> + <SubMenu :root="application.title" :paths="subMenuPaths" /> + + <h1 class="title main-title"> + <span v-if="authorizationId === 'new'">{{ + $t("titles.data-type-new-authorization", { dataType: dataTypeId }) + }}</span> + </h1> + + <ValidationObserver ref="observer" v-slot="{ handleSubmit }"> + <b-field + :label="$t('dataTypeAuthorizations.period')" + class="DataTypeAuthorizationInfoView-periods-container mb-4" + > + <b-radio + name="dataTypeAuthorization-period" + v-model="period" + :native-value="periods.FROM_DATE" + class="DataTypeAuthorizationInfoView-radio-field mb-2" + > + <span class="DataTypeAuthorizationInfoView-radio-label"> + {{ periods.FROM_DATE }} + </span> + <ValidationProvider + :rules="period === periods.FROM_DATE ? 'required' : ''" + name="period_fromDate" + v-slot="{ errors, valid }" + vid="period_fromDate" + > + <b-field + :type="{ + 'is-danger': errors && errors.length > 0, + 'is-success': valid && period === periods.FROM_DATE, + }" + :message="errors[0]" + > + <b-datepicker + v-model="startDate" + show-week-number + :locale="chosenLocale" + icon="calendar-day" + trap-focus + :disabled="period !== periods.FROM_DATE" + > + </b-datepicker> + </b-field> + </ValidationProvider> + </b-radio> + + <b-radio + name="dataTypeAuthorization-period" + v-model="period" + :native-value="periods.TO_DATE" + class="DataTypeAuthorizationInfoView-radio-field mb-2" + > + <span class="DataTypeAuthorizationInfoView-radio-label"> + {{ periods.TO_DATE }} + </span> + <ValidationProvider + :rules="period === periods.TO_DATE ? 'required' : ''" + name="period_toDate" + v-slot="{ errors, valid }" + vid="period_toDate" + > + <b-field + :type="{ + 'is-danger': errors && errors.length > 0, + 'is-success': valid && period === periods.TO_DATE, + }" + :message="errors[0]" + > + <b-datepicker + v-model="endDate" + show-week-number + :locale="chosenLocale" + icon="calendar-day" + trap-focus + :disabled="period !== periods.TO_DATE" + > + </b-datepicker> + </b-field> + </ValidationProvider> + </b-radio> + + <b-radio + name="dataTypeAuthorization-period" + v-model="period" + :native-value="periods.FROM_DATE_TO_DATE" + class="DataTypeAuthorizationInfoView-radio-field mb-2" + > + <span class="DataTypeAuthorizationInfoView-radio-label"> + {{ periods.FROM_DATE_TO_DATE }} + </span> + <ValidationProvider + :rules="period === periods.FROM_DATE_TO_DATE ? 'required' : ''" + name="period_fromDateToDate_1" + v-slot="{ errors, valid }" + vid="period_fromDateToDate_1" + > + <b-field + class="mr-4" + :type="{ + 'is-danger': errors && errors.length > 0, + 'is-success': valid && period === periods.FROM_DATE_TO_DATE, + }" + :message="errors[0]" + > + <b-datepicker + v-model="startDate" + show-week-number + :locale="chosenLocale" + icon="calendar-day" + trap-focus + :disabled="period !== periods.FROM_DATE_TO_DATE" + > + </b-datepicker> + </b-field> + </ValidationProvider> + <span class="mr-4">{{ $t("dataTypeAuthorizations.to") }}</span> + <ValidationProvider + :rules="period === periods.FROM_DATE_TO_DATE ? 'required' : ''" + name="period_fromDateToDate_2" + v-slot="{ errors, valid }" + vid="period_fromDateToDate_2" + > + <b-field + :type="{ + 'is-danger': errors && errors.length > 0, + 'is-success': valid && period === periods.FROM_DATE_TO_DATE, + }" + :message="errors[0]" + > + <b-datepicker + v-model="endDate" + show-week-number + :locale="chosenLocale" + icon="calendar-day" + trap-focus + :disabled="period !== periods.FROM_DATE_TO_DATE" + > + </b-datepicker> + </b-field> + </ValidationProvider> + </b-radio> + + <b-radio + class="DataTypeAuthorizationInfoView-radio-field" + name="dataTypeAuthorization-period" + v-model="period" + :native-value="periods.ALWAYS" + > + <span class="DataTypeAuthorizationInfoView-radio-label"> + {{ periods.ALWAYS }}</span + ></b-radio + > + </b-field> + + <ValidationProvider rules="required" name="users" v-slot="{ errors, valid }" vid="users"> + <b-field + :label="$t('dataTypeAuthorizations.users')" + class="mb-4" + :type="{ + 'is-danger': errors && errors.length > 0, + 'is-success': valid, + }" + :message="errors[0]" + > + <b-select + :placeholder="$t('dataTypeAuthorizations.users-placeholder')" + v-model="userToAuthorize" + expanded + > + <option v-for="user in users" :value="user.id" :key="user.id"> + {{ user.label }} + </option> + </b-select> + </b-field> + </ValidationProvider> + + <ValidationProvider + rules="required" + name="dataGroups" + v-slot="{ errors, valid }" + vid="dataGroups" + > + <b-field + :label="$t('dataTypeAuthorizations.data-groups')" + class="mb-4" + :type="{ + 'is-danger': errors && errors.length > 0, + 'is-success': valid, + }" + :message="errors[0]" + > + <b-select + :placeholder="$t('dataTypeAuthorizations.data-groups-placeholder')" + v-model="dataGroupToAuthorize" + expanded + > + <option v-for="dataGroup in dataGroups" :value="dataGroup.id" :key="dataGroup.id"> + {{ dataGroup.label }} + </option> + </b-select> + </b-field> + </ValidationProvider> + + <ValidationProvider rules="required" name="scopes" v-slot="{ errors, valid }" vid="scopes"> + <b-field + :label="$t('dataTypeAuthorizations.authorization-scopes')" + class="mb-4" + :type="{ + 'is-danger': errors && errors.length > 0, + 'is-success': valid, + }" + :message="errors[0]" + > + <b-collapse + class="card" + animation="slide" + v-for="(scope, index) of authorizationScopes" + :key="scope.id" + :open="openCollapse == index" + @open="openCollapse = index" + > + <template #trigger="props"> + <div class="card-header" role="button"> + <p class="card-header-title"> + {{ scope.label }} + </p> + <a class="card-header-icon"> + <b-icon :icon="props.open ? 'chevron-down' : 'chevron-up'"> </b-icon> + </a> + </div> + </template> + <div class="card-content"> + <div class="content"> + <CollapsibleTree + v-for="option in scope.options" + :key="option.id" + :option="option" + :withRadios="true" + :radioName="`dataTypeAuthorizations_${applicationName}_${dataTypeId}`" + @optionChecked="(value) => (scopesToAuthorize[scope.id] = value)" + /> + </div> + </div> + </b-collapse> + </b-field> + </ValidationProvider> + + <div class="buttons"> + <b-button type="is-primary" @click="handleSubmit(createAuthorization)" icon-left="plus"> + {{ $t("dataTypeAuthorizations.create") }} + </b-button> + </div> + </ValidationObserver> + </PageView> +</template> + +<script> +import CollapsibleTree from "@/components/common/CollapsibleTree.vue"; +import SubMenu, { SubMenuPath } from "@/components/common/SubMenu.vue"; +import { DataTypeAuthorization } from "@/model/DataTypeAuthorization"; +import { AlertService } from "@/services/AlertService"; +import { ApplicationService } from "@/services/rest/ApplicationService"; +import { AuthorizationService } from "@/services/rest/AuthorizationService"; +import { UserPreferencesService } from "@/services/UserPreferencesService"; +import { ValidationObserver, ValidationProvider } from "vee-validate"; +import { Component, Prop, Vue, Watch } from "vue-property-decorator"; +import PageView from "../common/PageView.vue"; + +@Component({ + components: { PageView, SubMenu, CollapsibleTree, ValidationObserver, ValidationProvider }, +}) +export default class DataTypeAuthorizationInfoView extends Vue { + @Prop() dataTypeId; + @Prop() applicationName; + @Prop() authorizationId; + + authorizationService = AuthorizationService.INSTANCE; + alertService = AlertService.INSTANCE; + applicationService = ApplicationService.INSTANCE; + userPreferencesService = UserPreferencesService.INSTANCE; + + periods = { + FROM_DATE: this.$t("dataTypeAuthorizations.from-date"), + TO_DATE: this.$t("dataTypeAuthorizations.to-date"), + FROM_DATE_TO_DATE: this.$t("dataTypeAuthorizations.from-date-to-date"), + ALWAYS: this.$t("dataTypeAuthorizations.always"), + }; + + authorizations = []; + application = {}; + users = []; + dataGroups = []; + authorizationScopes = []; + userToAuthorize = null; + dataGroupToAuthorize = null; + openCollapse = null; + scopesToAuthorize = {}; + period = this.periods.FROM_DATE; + startDate = null; + endDate = null; + + created() { + this.init(); + this.chosenLocale = this.userPreferencesService.getUserPrefLocale(); + this.subMenuPaths = [ + new SubMenuPath( + this.$t("dataTypesManagement.data-types").toLowerCase(), + () => this.$router.push(`/applications/${this.applicationName}/dataTypes`), + () => this.$router.push("/applications") + ), + new SubMenuPath( + this.$t(`dataTypeAuthorizations.sub-menu-data-type-authorizations`, { + dataType: this.dataTypeId, + }), + () => { + this.$router.push( + `/applications/${this.applicationName}/dataTypes/${this.dataTypeId}/authorizations` + ); + }, + () => this.$router.push(`/applications/${this.applicationName}/dataTypes`) + ), + new SubMenuPath( + this.$t(`dataTypeAuthorizations.sub-menu-new-authorization`), + () => {}, + () => { + this.$router.push( + `/applications/${this.applicationName}/dataTypes/${this.dataTypeId}/authorizations` + ); + } + ), + ]; + } + + async init() { + try { + this.application = await this.applicationService.getApplication(this.applicationName); + const grantableInfos = await this.authorizationService.getAuthorizationGrantableInfos( + this.applicationName, + this.dataTypeId + ); + ({ + authorizationScopes: this.authorizationScopes, + dataGroups: this.dataGroups, + users: this.users, + } = grantableInfos); + // this.authorizationScopes[0].options[0].children[0].children.push({ + // children: [], + // id: "toto", + // label: "toto", + // }); + } catch (error) { + this.alertService.toastServerError(error); + } + } + + @Watch("period") + onPeriodChanged() { + this.endDate = null; + this.startDate = null; + } + + async createAuthorization() { + const dataTypeAuthorization = new DataTypeAuthorization(); + dataTypeAuthorization.userId = this.userToAuthorize; + dataTypeAuthorization.applicationNameOrId = this.applicationName; + dataTypeAuthorization.dataType = this.dataTypeId; + dataTypeAuthorization.dataGroup = this.dataGroupToAuthorize; + dataTypeAuthorization.authorizedScopes = this.scopesToAuthorize; + let fromDay = null; + if (this.startDate) { + fromDay = [ + this.startDate.getFullYear(), + this.startDate.getMonth() + 1, + this.startDate.getDate(), + ]; + } + dataTypeAuthorization.fromDay = fromDay; + let toDay = null; + if (this.endDate) { + toDay = [this.endDate.getFullYear(), this.endDate.getMonth() + 1, this.endDate.getDate()]; + } + dataTypeAuthorization.toDay = toDay; + + try { + await this.authorizationService.createAuthorization( + this.applicationName, + this.dataTypeId, + dataTypeAuthorization + ); + this.alertService.toastSuccess(this.$t("alert.create-authorization")); + this.$router.push( + `/applications/${this.applicationName}/dataTypes/${this.dataTypeId}/authorizations` + ); + } catch (error) { + this.alertService.toastServerError(error); + } + } +} +</script> + +<style lang="scss"> +.DataTypeAuthorizationInfoView-periods-container { + .field-body .field.has-addons { + display: flex; + flex-direction: column; + } +} + +.DataTypeAuthorizationInfoView-radio-field { + height: 40px; + + &.b-radio { + .control-label { + display: flex; + align-items: center; + width: 100%; + } + } +} + +.DataTypeAuthorizationInfoView-radio-label { + width: 200px; +} +</style> diff --git a/ui2/src/views/authorizations/DataTypeAuthorizationsView.vue b/ui2/src/views/authorizations/DataTypeAuthorizationsView.vue new file mode 100644 index 0000000000000000000000000000000000000000..df8a1f11d854a84be619f5ecbabf88d7345e3560 --- /dev/null +++ b/ui2/src/views/authorizations/DataTypeAuthorizationsView.vue @@ -0,0 +1,149 @@ +<template> + <PageView class="with-submenu"> + <SubMenu :root="application.title" :paths="subMenuPaths" /> + <h1 class="title main-title"> + {{ $t("titles.data-type-authorizations", { dataType: dataTypeId }) }} + </h1> + <div class="buttons"> + <b-button type="is-primary" @click="addAuthorization" icon-left="plus"> + {{ $t("dataTypeAuthorizations.add-auhtorization") }} + </b-button> + </div> + + <b-table + :data="authorizations" + :striped="true" + :isFocusable="true" + :isHoverable="true" + :sticky-header="true" + :paginated="true" + :per-page="15" + height="100%" + > + <b-table-column + b-table-column + field="user" + :label="$t('dataTypeAuthorizations.user')" + sortable + v-slot="props" + > + {{ props.row.user }} + </b-table-column> + + <b-table-column + b-table-column + field="dataGroup" + :label="$t('dataTypeAuthorizations.data-group')" + sortable + v-slot="props" + > + {{ props.row.dataGroup }} + </b-table-column> + <b-table-column + v-for="scope in scopes" + :key="scope" + b-table-column + :label="scope" + sortable + v-slot="props" + > + {{ props.row.authorizedScopes[scope] }} + </b-table-column> + <b-table-column b-table-column :label="$t('dataTypeAuthorizations.actions')" v-slot="props"> + <b-button + type="is-danger" + size="is-small" + @click="revoke(props.row.id)" + icon-left="trash-alt" + > + {{ $t("dataTypeAuthorizations.revoke") }} + </b-button> + </b-table-column> + </b-table> + </PageView> +</template> + +<script> +import SubMenu, { SubMenuPath } from "@/components/common/SubMenu.vue"; +import { AlertService } from "@/services/AlertService"; +import { ApplicationService } from "@/services/rest/ApplicationService"; +import { AuthorizationService } from "@/services/rest/AuthorizationService"; +import { Component, Prop, Vue } from "vue-property-decorator"; +import PageView from "../common/PageView.vue"; + +@Component({ + components: { PageView, SubMenu }, +}) +export default class DataTypeAuthorizationsView extends Vue { + @Prop() dataTypeId; + @Prop() applicationName; + + authorizationService = AuthorizationService.INSTANCE; + alertService = AlertService.INSTANCE; + applicationService = ApplicationService.INSTANCE; + + authorizations = []; + application = {}; + scopes = []; + + created() { + this.init(); + this.subMenuPaths = [ + new SubMenuPath( + this.$t("dataTypesManagement.data-types").toLowerCase(), + () => this.$router.push(`/applications/${this.applicationName}/dataTypes`), + () => this.$router.push("/applications") + ), + new SubMenuPath( + this.$t(`dataTypeAuthorizations.sub-menu-data-type-authorizations`, { + dataType: this.dataTypeId, + }), + () => { + this.$router.push( + `/applications/${this.applicationName}/dataTypes/${this.dataTypeId}/authorizations` + ); + }, + () => this.$router.push(`/applications/${this.applicationName}/dataTypes`) + ), + ]; + } + + async init() { + try { + this.application = await this.applicationService.getApplication(this.applicationName); + this.authorizations = await this.authorizationService.getDataAuthorizations( + this.applicationName, + this.dataTypeId + ); + if (this.authorizations && this.authorizations.length !== 0) { + this.scopes = Object.keys(this.authorizations[0].authorizedScopes); + } + } catch (error) { + this.alertService.toastServerError(error); + } + } + + addAuthorization() { + this.$router.push( + `/applications/${this.applicationName}/dataTypes/${this.dataTypeId}/authorizations/new` + ); + } + + async revoke(id) { + try { + await this.authorizationService.revokeAuthorization( + this.applicationName, + this.dataTypeId, + id + ); + this.alertService.toastSuccess(this.$t("alert.revoke-authorization")); + this.authorizations.splice( + this.authorizations.findIndex((a) => a.id === id), + 1 + ); + } catch (error) { + this.alertService.toastServerError(error); + } + } +} +</script> diff --git a/ui2/src/views/common/MenuView.vue b/ui2/src/views/common/MenuView.vue index b98c505e9e209d507046fd7afe6b20808669c246..b5d862810131b98cb2fc4b001bdc2d5e7bef5d63 100644 --- a/ui2/src/views/common/MenuView.vue +++ b/ui2/src/views/common/MenuView.vue @@ -2,19 +2,17 @@ <div class="menu-view-container"> <b-navbar class="menu-view" v-if="open"> <template #start> + <b-navbar-item href="https://www.inrae.fr/"> + <img class="logo_blanc" src="@/assets/logo-inrae_blanc.svg" /> + <img class="logo_vert" src="@/assets/Logo-INRAE.svg" /> + </b-navbar-item> + <img class="logo_rep" src="@/assets/Rep-FR-logo.svg" /> <b-navbar-item tag="router-link" :to="{ path: '/applications' }"> {{ $t("menu.applications") }} </b-navbar-item> </template> <template #end> - <b-navbar-item tag="div"> - <div class="buttons"> - <b-button type="is-info" @click="logout" icon-right="sign-out-alt">{{ - $t("menu.logout") - }}</b-button> - </div> - </b-navbar-item> <b-navbar-item tag="div"> <b-field> <b-select @@ -28,13 +26,26 @@ </b-select> </b-field> </b-navbar-item> - <b-navbar-item href="https://www.inrae.fr/"> - <img class="logo_blanc" src="@/assets/logo-inrae_blanc.svg" /> - <img class="logo_vert" src="@/assets/Logo-INRAE.svg" /> + + <b-navbar-item tag="div" class="MenuView-user"> + <b-dropdown position="is-bottom-left" append-to-body aria-role="menu"> + <template #trigger> + <a class="navbar-item" role="button"> + <b-icon icon="user-astronaut" class="mr-1" /> + <span>{{ currentUser.login }}</span> + <b-icon icon="caret-down" class="ml-2" /> + </a> + </template> + + <b-dropdown-item @click="logout()" aria-role="menuitem"> + <b-icon icon="sign-out-alt" /> + {{ $t("menu.logout") }} + </b-dropdown-item> + </b-dropdown> </b-navbar-item> - <img class="logo_rep" src="@/assets/Rep-FR-logo.svg" /> </template> </b-navbar> + <FontAwesomeIcon @click="open = !open" :icon="open ? 'caret-up' : 'caret-down'" @@ -62,9 +73,11 @@ export default class MenuView extends Vue { locales = Locales; chosenLocale = ""; open = false; + currentUser = null; created() { this.chosenLocale = this.userPreferencesService.getUserPrefLocale(); + this.currentUser = this.loginService.getAuthenticatedUser(); } logout() { @@ -127,6 +140,15 @@ export default class MenuView extends Vue { margin: 0; } } + + .MenuView-user.navbar-item { + .navbar-item { + color: white; + &:hover { + color: $primary; + } + } + } } .menu-view-container { diff --git a/ui2/src/views/datatype/DataTypesManagementView.vue b/ui2/src/views/datatype/DataTypesManagementView.vue index 3001b559b690a5b5d02fb9d5ac6ee4739a0a2f2d..c0a8ede31ddb1a5804fb4deaa8e51b7174118396 100644 --- a/ui2/src/views/datatype/DataTypesManagementView.vue +++ b/ui2/src/views/datatype/DataTypesManagementView.vue @@ -8,12 +8,19 @@ <CollapsibleTree v-for="data in dataTypes" :key="data.id" - :label="data.label" + :option="data" :level="0" :onClickLabelCb="(event, label) => openDataTypeCb(event, label)" :onUploadCb="(label, file) => uploadDataTypeCsv(label, file)" :buttons="buttons" /> + <DataTypeDetailsPanel + :leftAlign="false" + :open="openPanel" + :dataType="chosenDataType" + :closeCb="(newVal) => (openPanel = newVal)" + :applicationName="applicationName" + /> </div> <div v-if="errorsMessages.length"> <div v-for="msg in errorsMessages" v-bind:key="msg"> @@ -43,9 +50,10 @@ import { AlertService } from "@/services/AlertService"; import { DataService } from "@/services/rest/DataService"; import { HttpStatusCodes } from "@/utils/HttpUtils"; import { ErrorsService } from "@/services/ErrorsService"; +import DataTypeDetailsPanel from "@/components/datatype/DataTypeDetailsPanel.vue"; @Component({ - components: { CollapsibleTree, PageView, SubMenu }, + components: { CollapsibleTree, PageView, SubMenu, DataTypeDetailsPanel }, }) export default class DataTypesManagementView extends Vue { @Prop() applicationName; @@ -70,6 +78,8 @@ export default class DataTypesManagementView extends Vue { ]; dataTypes = []; errorsMessages = []; + openPanel = false; + chosenDataType = null; created() { this.subMenuPaths = [ @@ -104,8 +114,9 @@ export default class DataTypesManagementView extends Vue { openDataTypeCb(event, label) { event.stopPropagation(); - - console.log("OPEN", label); + this.openPanel = + this.chosenDataType && this.chosenDataType.label === label ? !this.openPanel : true; + this.chosenDataType = this.dataTypes.find((dt) => dt.label === label); } async uploadDataTypeCsv(label, file) { diff --git a/ui2/src/views/references/ReferencesManagementView.vue b/ui2/src/views/references/ReferencesManagementView.vue index 5ca7120131aa64447dcfc396ae9c9ca6cf7cdcbb..09000adb78641a1beaa9ba1f69b8cf8b8c9e27f8 100644 --- a/ui2/src/views/references/ReferencesManagementView.vue +++ b/ui2/src/views/references/ReferencesManagementView.vue @@ -8,8 +8,7 @@ <CollapsibleTree v-for="ref in references" :key="ref.id" - :label="ref.label" - :children="ref.children" + :option="ref" :level="0" :onClickLabelCb="(event, label) => openRefDetails(event, label)" :onUploadCb="(label, refFile) => uploadReferenceCsv(label, refFile)"