diff --git a/backend/src/main/java/org/cryptomator/hub/api/AuthorityDto.java b/backend/src/main/java/org/cryptomator/hub/api/AuthorityDto.java index cfe468b25..e09d16e0d 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/AuthorityDto.java +++ b/backend/src/main/java/org/cryptomator/hub/api/AuthorityDto.java @@ -5,6 +5,8 @@ import org.cryptomator.hub.entities.Group; import org.cryptomator.hub.entities.User; +import jakarta.inject.Inject; + abstract sealed class AuthorityDto permits UserDto, GroupDto, MemberDto { public enum Type { @@ -30,10 +32,13 @@ protected AuthorityDto(String id, Type type, String name, String pictureUrl) { this.pictureUrl = pictureUrl; } + @Inject + static User.Repository userRepo; + static AuthorityDto fromEntity(Authority a) { return switch (a) { case User u -> UserDto.justPublicInfo(u); - case Group g -> GroupDto.fromEntity(g); + case Group g -> new GroupDto(g.getId(), g.getName(), (int) userRepo.getEffectiveGroupUsers(g.getId()).count()); default -> throw new IllegalStateException("authority is not of type user or group"); }; } diff --git a/backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java b/backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java index f33b5c8bd..2189ac3b9 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java @@ -9,6 +9,8 @@ import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import org.cryptomator.hub.entities.Authority; +import org.cryptomator.hub.entities.Group; +import org.cryptomator.hub.entities.User; import org.eclipse.microprofile.openapi.annotations.Operation; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.jboss.resteasy.reactive.NoCache; @@ -21,6 +23,9 @@ public class AuthorityResource { @Inject Authority.Repository authorityRepo; + + @Inject + User.Repository userRepo; @GET @Path("/search") @@ -29,7 +34,10 @@ public class AuthorityResource { @NoCache @Operation(summary = "search authority by name") public List search(@QueryParam("query") @NotBlank String query) { - return authorityRepo.byName(query).map(AuthorityDto::fromEntity).toList(); + List authorities = authorityRepo.byName(query).toList(); + return authorities.stream() + .map(this::convertToDto) + .toList(); } @GET @@ -40,7 +48,20 @@ public List search(@QueryParam("query") @NotBlank String query) { @Operation(summary = "lists all authorities matching the given ids", description = "lists for each id in the list its corresponding authority. Ignores all id's where an authority cannot be found") @APIResponse(responseCode = "200") public List getSome(@QueryParam("ids") List authorityIds) { - return authorityRepo.findAllInList(authorityIds).map(AuthorityDto::fromEntity).toList(); + return authorityRepo.findAllInList(authorityIds) + .map(this::convertToDto) + .toList(); + } + + private AuthorityDto convertToDto(Authority a) { + if (a instanceof User u) { + return UserDto.justPublicInfo(u); + } else if (a instanceof Group g) { + int memberCount = (int) userRepo.getEffectiveGroupUsers(g.getId()).count(); + return new GroupDto(g.getId(), g.getName(), memberCount); + } else { + throw new IllegalStateException("authority is not of type user or group"); + } } -} \ No newline at end of file +} diff --git a/backend/src/main/java/org/cryptomator/hub/api/GroupDto.java b/backend/src/main/java/org/cryptomator/hub/api/GroupDto.java index 2f1620ab9..4e2c896b9 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/GroupDto.java +++ b/backend/src/main/java/org/cryptomator/hub/api/GroupDto.java @@ -5,12 +5,19 @@ public final class GroupDto extends AuthorityDto { - GroupDto(@JsonProperty("id") String id, @JsonProperty("name") String name) { - super(id, Type.GROUP, name, null); - } + private final int memberCount; - public static GroupDto fromEntity(Group group) { - return new GroupDto(group.getId(), group.getName()); - } + GroupDto(@JsonProperty("id") String id, @JsonProperty("name") String name, @JsonProperty("memberCount") int memberCount) { + super(id, Type.GROUP, name, null); + this.memberCount = memberCount; + } + @JsonProperty("memberCount") + public int getMemberCount() { + return memberCount; + } + + public static GroupDto fromEntity(Group group, int memberCount) { + return new GroupDto(group.getId(), group.getName(), memberCount); + } } diff --git a/backend/src/main/java/org/cryptomator/hub/api/GroupsResource.java b/backend/src/main/java/org/cryptomator/hub/api/GroupsResource.java index 78db054a5..ccaa32e7c 100644 --- a/backend/src/main/java/org/cryptomator/hub/api/GroupsResource.java +++ b/backend/src/main/java/org/cryptomator/hub/api/GroupsResource.java @@ -2,6 +2,7 @@ import jakarta.annotation.security.RolesAllowed; import jakarta.inject.Inject; +import jakarta.ws.rs.core.Response; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; @@ -13,6 +14,8 @@ import org.eclipse.microprofile.openapi.annotations.Operation; import java.util.List; +import java.util.HashMap; +import java.util.Map; @Path("/groups") public class GroupsResource { @@ -28,7 +31,8 @@ public class GroupsResource { @Produces(MediaType.APPLICATION_JSON) @Operation(summary = "list all groups") public List getAll() { - return groupRepo.findAll().stream().map(GroupDto::fromEntity).toList(); + List groups = groupRepo.findAll().list(); + return groups.stream().map(group -> GroupDto.fromEntity(group, groupRepo.countMembers(group.getId()))).toList(); } @GET diff --git a/backend/src/main/java/org/cryptomator/hub/entities/Group.java b/backend/src/main/java/org/cryptomator/hub/entities/Group.java index 388b87f27..87dc88b48 100644 --- a/backend/src/main/java/org/cryptomator/hub/entities/Group.java +++ b/backend/src/main/java/org/cryptomator/hub/entities/Group.java @@ -2,13 +2,8 @@ import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; import jakarta.enterprise.context.ApplicationScoped; -import jakarta.persistence.CascadeType; -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Entity; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.JoinTable; -import jakarta.persistence.ManyToMany; -import jakarta.persistence.Table; +import jakarta.inject.Inject; +import jakarta.persistence.*; import java.util.HashSet; import java.util.Set; @@ -25,6 +20,9 @@ public class Group extends Authority { ) private Set members = new HashSet<>(); + @Inject + transient Repository groupRepo; + public Set getMembers() { return members; } @@ -33,7 +31,17 @@ public void setMembers(Set members) { this.members = members; } + public int getMemberCount() { + return groupRepo.countMembers(this.getId()); + } + @ApplicationScoped public static class Repository implements PanacheRepositoryBase { + public int countMembers(String groupId) { + return getEntityManager() + .createQuery("SELECT SIZE(g.members) FROM Group g WHERE g.id = :groupId", Integer.class) + .setParameter("groupId", groupId) + .getSingleResult(); + } } } diff --git a/frontend/src/common/backend.ts b/frontend/src/common/backend.ts index a1be6a8f3..2bd62a37c 100644 --- a/frontend/src/common/backend.ts +++ b/frontend/src/common/backend.ts @@ -84,6 +84,15 @@ export type GroupDto = { id: string; name: string; pictureUrl?: string; + memberCount: number; +} + +class GroupService { + public async getMemberCount(groupId: string): Promise { + return axiosAuth.get(`/groups/${groupId}/effective-members`) + .then(response => response.data.length) + .catch(() => 0); + } } export type AuthorityDto = UserDto | GroupDto; @@ -385,7 +394,8 @@ const services = { billing: new BillingService(), version: new VersionService(), license: new LicenseService(), - settings: new SettingsService() + settings: new SettingsService(), + groups: new GroupService() }; export default services; diff --git a/frontend/src/components/SearchInputGroup.vue b/frontend/src/components/SearchInputGroup.vue index b8ede1749..5984a1a44 100644 --- a/frontend/src/components/SearchInputGroup.vue +++ b/frontend/src/components/SearchInputGroup.vue @@ -8,15 +8,29 @@ - - - + +
+ {{ selectedItem.name }} + + {{ selectedItem.memberCount }} {{ t('vaultDetails.sharedWith.members') }} + +
+
{{ item.name }} + + {{ item.memberCount }} {{ t('vaultDetails.sharedWith.members') }} +
@@ -38,6 +52,7 @@ import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions } from '@headl import { UsersIcon, XCircleIcon } from '@heroicons/vue/24/solid'; import { computed, nextTick, ref, shallowRef, watch } from 'vue'; import { debounce } from '../common/util'; +import { useI18n } from 'vue-i18n'; export type Item = { id: string; @@ -45,6 +60,8 @@ export type Item = { pictureUrl?: string; } +const { t } = useI18n({ useScope: 'global' }); + const props = defineProps<{ actionTitle: string onSearch: (query: string) => Promise diff --git a/frontend/src/components/VaultDetails.vue b/frontend/src/components/VaultDetails.vue index 42bc58201..c6c0c88de 100644 --- a/frontend/src/components/VaultDetails.vue +++ b/frontend/src/components/VaultDetails.vue @@ -46,7 +46,13 @@
-

{{ member.name }}

+
+

{{ member.name }}

+ + {{ groupMemberCounts.get(member.id) }} {{ t('vaultDetails.sharedWith.members') }} + +
{{ t('vaultDetails.sharedWith.badge.owner') }}
@@ -298,10 +304,24 @@ async function fetchData() { async function fetchOwnerData() { try { - (await backend.vaults.getMembers(props.vaultId)).forEach(member => members.value.set(member.id, member)); + const fetchedMembers = await backend.vaults.getMembers(props.vaultId); + for (const member of fetchedMembers) { + if (member.type === "GROUP") { + try { + const count = await backend.groups.getMemberCount(member.id); + members.value.set(member.id, { ...member, memberCount: count }); + } catch (error) { + members.value.set(member.id, { ...member, memberCount: 0 }); + } + } else { + members.value.set(member.id, member); + } + } + await refreshTrusts(); usersRequiringAccessGrant.value = await backend.vaults.getUsersRequiringAccessGrant(props.vaultId); vaultRecoveryRequired.value = false; + const vaultKeyJwe = await backend.vaults.accessToken(props.vaultId, true); vaultKeys.value = await loadVaultKeys(vaultKeyJwe); } catch (error) { @@ -317,6 +337,16 @@ async function fetchOwnerData() { } } +const groupMemberCounts = computed(() => { + const counts = new Map(); + members.value.forEach((member) => { + if (member.type === 'GROUP' && 'memberCount' in member) { + counts.set(member.id, member.memberCount); + } + }); + return counts; +}); + async function loadVaultKeys(vaultKeyJwe: string): Promise { const userKeys = await userdata.decryptUserKeysWithBrowser(); return VaultKeys.decryptWithUserKey(vaultKeyJwe, userKeys.ecdhKeyPair.privateKey); @@ -465,10 +495,26 @@ function refreshVault(updatedVault: VaultDto) { emit('vaultUpdated', updatedVault); } -async function searchAuthority(query: string): Promise { - return (await backend.authorities.search(query)) - .filter(authority => !members.value.has(authority.id)) - .sort((a, b) => a.name.localeCompare(b.name)); +const searchResults = ref>([]); + +async function searchAuthority(query: string): Promise<(AuthorityDto & { memberCount?: number })[]> { + const results = await backend.authorities.search(query); + const filtered = results.filter(authority => !members.value.has(authority.id)); + + const enhanced = await Promise.all( + filtered.map(async authority => { + if (authority.type === "GROUP") { + try { + const count = await backend.groups.getMemberCount(authority.id); + return { ...authority, memberCount: count }; + } catch (error) { + return { ...authority, memberCount: 0 }; + } + } + return authority; + }) + ); + return enhanced.sort((a, b) => a.name.localeCompare(b.name)); } async function updateMemberRole(member: MemberDto, role: VaultRole) { diff --git a/frontend/src/i18n/en-US.json b/frontend/src/i18n/en-US.json index 5070daa25..d6383522e 100644 --- a/frontend/src/i18n/en-US.json +++ b/frontend/src/i18n/en-US.json @@ -257,6 +257,7 @@ "vaultDetails.sharedWith.title": "Shared with", "vaultDetails.sharedWith.badge.owner": "Owner", "vaultDetails.sharedWith.grantOwnership": "Grant Ownership", + "vaultDetails.sharedWith.members": "Members", "vaultDetails.sharedWith.revokeOwnership": "Revoke Ownership", "vaultDetails.actions.title": "Actions", "vaultDetails.actions.updatePermissions": "Update Permissions",