Skip to content

Conversation

@iammajid
Copy link
Contributor

Adds a complete user and group management interface for administrators. The user management includes list, detail, create and edit views with full CRUD operations. Users can be created, edited and deleted through the admin UI. The user detail page displays associated devices, legacy devices, groups and vault memberships. Group membership can be managed directly from the user detail view by adding or removing groups. The group management includes list and detail views with member management functionality.
Bildschirmfoto 2025-12-15 um 07 52 48
Bildschirmfoto 2025-12-15 um 07 52 33
Bildschirmfoto 2025-12-15 um 07 52 59
Bildschirmfoto 2025-12-15 um 07 53 10

iammajid and others added 30 commits April 11, 2025 15:49
implement pagination for user list in GroupDetail
new components for group and user detail sections
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
frontend/src/common/backend.ts (1)

114-116: Property name mismatch with backend API persists.

The backend's MemberDto.java serializes this field as "role" (via @JsonProperty("role") as shown in the relevant code snippet), but the frontend type uses vaultRole. This will cause vaultRole to be undefined at runtime.

🧹 Nitpick comments (3)
frontend/src/components/authority/UserEditCreate.vue (1)

108-111: Consider using type="button" explicitly for role removal chips.

The role removal buttons inside the ListboxButton work correctly with @click.stop, but adding explicit type="button" would be more defensive against any future form submission behavior.

🔎 Suggested improvement
-                        <button v-for="role in selectedRoles" :key="role" class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20" @click.stop="removeRole(role)">
+                        <button v-for="role in selectedRoles" :key="role" type="button" class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20" @click.stop="removeRole(role)">
frontend/src/common/backend.ts (2)

97-101: Consider removing redundant devices property from UserDtoWithDetails.

UserDto already has devices: DeviceDto[] (line 83). Redeclaring it in UserDtoWithDetails is redundant. If the intent is to ensure devices are always present (not optional), the base UserDto already makes it non-optional.

🔎 Suggested simplification
 export type UserDtoWithDetails = UserDto & {
   groups: GroupDto[];
-  devices: DeviceDto[];
   legacyDevices: DeviceDto[];
 }

135-137: Remove outdated TODO comment - VaultDtoWithRole is in use.

The TODO comment // TODO: unused?? is incorrect. VaultDtoWithRole is used in:

  • UserDto.accessibleVaults (line 84)
  • GroupDtoWithDetails.vaults (line 141)
🔎 Proposed fix
-export type VaultDtoWithRole = VaultDto & { // TODO: unused??
+export type VaultDtoWithRole = VaultDto & {
   role: VaultRole;
 }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bf6c7e2 and e5baf6e.

📒 Files selected for processing (4)
  • frontend/src/common/backend.ts (6 hunks)
  • frontend/src/components/authority/GroupList.vue (1 hunks)
  • frontend/src/components/authority/UserEditCreate.vue (1 hunks)
  • frontend/src/components/authority/UserList.vue (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/src/components/authority/UserList.vue
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2024-07-27T13:14:40.963Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 282
File: frontend/src/common/userdata.ts:82-88
Timestamp: 2024-07-27T13:14:40.963Z
Learning: The error handling suggestion for `createBrowserKeys` in `frontend/src/common/userdata.ts` was discussed and resolved in PR #282, and should not be repeated in future reviews.

Applied to files:

  • frontend/src/components/authority/UserEditCreate.vue
  • frontend/src/common/backend.ts
📚 Learning: 2025-12-18T08:32:28.253Z
Learnt from: iammajid
Repo: cryptomator/hub PR: 376
File: frontend/src/components/authority/GroupInfo.vue:5-5
Timestamp: 2025-12-18T08:32:28.253Z
Learning: In the frontend codebase, ensure that authority DTOs for users and groups always include a pictureUrl by having AuthorityService.fillInMissingPicture() run at the service layer (as in backend.ts). As a result, components rendering users or groups (e.g., GroupInfo.vue and related components) can safely rely on pictureUrl being present and should not implement their own fallback logic for missing images. When reviewing, verify that the service layer populates pictureUrl for all user/group DTOs and that UI components do not duplicate fallback handling.

Applied to files:

  • frontend/src/components/authority/UserEditCreate.vue
  • frontend/src/common/backend.ts
  • frontend/src/components/authority/GroupList.vue
🧬 Code graph analysis (1)
frontend/src/common/backend.ts (3)
backend/src/main/java/org/cryptomator/hub/api/GroupDto.java (1)
  • GroupDto (10-46)
backend/src/main/java/org/cryptomator/hub/api/AuthorityDto.java (1)
  • AuthorityDto (8-45)
backend/src/main/java/org/cryptomator/hub/api/MemberDto.java (1)
  • MemberDto (8-35)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run Tests
🔇 Additional comments (10)
frontend/src/components/authority/UserEditCreate.vue (3)

1-30: LGTM! Template structure and profile picture handling are well-implemented.

The loading state, breadcrumb navigation for edit/create modes, and profile picture preview with Jdenticon fallback are correctly structured. The conditional rendering chain (v-if/v-else-if/v-else) for image display handles all cases appropriately.


279-280: Verify that isEditMode remains correct after user creation.

userId is declared with let and reassigned on line 473 after creating a user. Since isEditMode is a computed property checking !!userId, it would become true after assignment. However, since the component navigates away immediately after (line 482), this shouldn't cause issues in practice.


484-498: LGTM! Error handling appropriately distinguishes conflict types.

The 409 conflict handling correctly differentiates between email and username conflicts, displaying field-specific error messages. The fallback to submitError for other errors is appropriate.

frontend/src/components/authority/GroupList.vue (3)

17-51: LGTM! Mobile card layout is well-structured.

The mobile layout correctly displays group information with a responsive card design, action menu, and member/vault counts. The click handler on the card navigates to the group detail page while the menu button stops propagation appropriately.


114-142: LGTM! Empty state and pagination logic is correct.

When sortedGroups.length === 0, showPagination is false (since 0 is not > pageSize), so pagination won't display. The empty states are correctly chained with v-if/v-else-if and mutually exclusive from the content sections which check paginatedGroups.length > 0.


202-211: LGTM! Error state handling is properly implemented.

The onFetchError ref is now correctly cleared at the start of fetchData() (line 203), ensuring that a successful retry clears any previous error state. This addresses the previously flagged issue.

frontend/src/common/backend.ts (4)

66-67: LGTM! RealmRole type correctly defined.

The RealmRole type aligns with the Keycloak realm roles used in the backend for admin and vault creation permissions.


302-353: LGTM! GroupService implementation follows established patterns.

The new GroupService class:

  • Properly calls AuthorityService.fillInMissingPicture() for all group and member DTOs, consistent with the learned pattern that UI components rely on pictureUrl being present.
  • Uses rethrowAndConvertIfExpected for proper error handling.
  • Returns Promise<void> for delete operations with .then(() => {}) pattern.

Based on learnings, the service layer correctly populates pictureUrl for all DTOs.


369-393: LGTM! UserService updates correctly implemented.

The new and updated methods:

  • removeUser now correctly returns Promise<void> (past issue addressed).
  • All methods apply fillInMissingPicture() consistently.
  • Error handling follows established patterns.

524-526: LGTM! GroupService properly registered in services export.

The new GroupService is correctly instantiated and exposed via the services object, making it accessible as backend.groups.*.

<div v-if="paginatedGroups.length > 0" class="-my-2 overflow-x-auto sm:-mx-6 lg:-mx-8 hidden md:block">
<div class="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div class="shadow-sm overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200" aria-describedby="groupListTitle">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing element for aria-describedby reference.

The table has aria-describedby="groupListTitle" but there's no element with id="groupListTitle" in the template. Either add the referenced element or remove the attribute.

🔎 Proposed fix - Option A: Add the referenced element
       <h2 class="text-2xl font-bold leading-9 text-gray-900 sm:text-3xl sm:truncate mb-4">
         {{ t('groups.title') }}
       </h2>
+      <p id="groupListTitle" class="sr-only">{{ t('groupList.tableDescription') }}</p>
🔎 Proposed fix - Option B: Remove the invalid reference
-          <table class="min-w-full divide-y divide-gray-200" aria-describedby="groupListTitle">
+          <table class="min-w-full divide-y divide-gray-200">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<table class="min-w-full divide-y divide-gray-200" aria-describedby="groupListTitle">
<table class="min-w-full divide-y divide-gray-200">
🤖 Prompt for AI Agents
In frontend/src/components/authority/GroupList.vue around line 57, the table
includes aria-describedby="groupListTitle" but there is no element with
id="groupListTitle"; either add a corresponding element (for example a <caption>
or visually-hidden heading with id="groupListTitle" placed immediately before or
inside the table that conveys the table's title/description) and ensure it is
accessible (visible or screen-reader-only), or remove the aria-describedby
attribute if no separate descriptive element is needed.

<div class="mt-1 md:mt-0 md:col-span-2 lg:col-span-1">
<div class="relative">
<input id="passwordConfirm" v-model="passwordConfirm" :type="passwordInputType" :class="[errors.passwordConfirm ? 'border-red-300 focus:border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-primary focus:border-primary', 'block w-full max-w-md shadow-sm sm:text-sm rounded-md pr-10']"/>
<button type="button" class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 focus:outline-none" :aria-label="passwordInputType === 'password' ? t('userEditCreate.showPassword') : t('userEditCreate.hidePassword')" @click="togglePasswordVisibility">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent i18n key namespace for password visibility aria-label.

Line 158 uses t('common.showPassword') / t('common.hidePassword'), but line 189 uses t('userEditCreate.showPassword') / t('userEditCreate.hidePassword'). These should be consistent.

🔎 Proposed fix
-                  <button type="button" class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 focus:outline-none" :aria-label="passwordInputType === 'password' ? t('userEditCreate.showPassword') : t('userEditCreate.hidePassword')" @click="togglePasswordVisibility">
+                  <button type="button" class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 focus:outline-none" :aria-label="passwordInputType === 'password' ? t('common.showPassword') : t('common.hidePassword')" @click="togglePasswordVisibility">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button type="button" class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 focus:outline-none" :aria-label="passwordInputType === 'password' ? t('userEditCreate.showPassword') : t('userEditCreate.hidePassword')" @click="togglePasswordVisibility">
<button type="button" class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 focus:outline-none" :aria-label="passwordInputType === 'password' ? t('common.showPassword') : t('common.hidePassword')" @click="togglePasswordVisibility">
🤖 Prompt for AI Agents
In frontend/src/components/authority/UserEditCreate.vue around line 189, the
aria-label for the password visibility toggle uses the userEditCreate namespace
while other occurrences use the common namespace; change the keys to
t('common.showPassword') and t('common.hidePassword') to match line 158 and keep
i18n usage consistent across the component (update both the true and false
branches of the conditional and scan for any other mismatched uses in this file
to align them to the common namespace).

Comment on lines +444 to +451
initialUserData.value = {
firstName: firstName.value,
lastName: lastName.value,
username: username.value,
email: email.value,
roles: [...selectedRoles.value],
previewUrl: pictureUrl.value
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Optimistic state update may cause UX confusion on API failure.

initialUserData is updated before the API call succeeds. If the API call fails, userDataHasUnsavedChanges will return false (since initial matches current), hiding the "unsaved changes" warning even though data wasn't actually saved. Consider moving this update after the successful API response.

🔎 Proposed fix
   processing.value = true;

   firstName.value = firstName.value.trim();
   lastName.value = lastName.value.trim();
   username.value = username.value.trim();
   email.value = email.value.trim();
   pictureUrl.value = pictureUrl.value.trim();

-  initialUserData.value = {
-    firstName: firstName.value,
-    lastName: lastName.value,
-    username: username.value,
-    email: email.value,
-    roles: [...selectedRoles.value],
-    previewUrl: pictureUrl.value
-  };
-
   try {
     if (isEditMode.value) {
       await backend.users.updateUser(userId, {
         // ...
       });
     } else {
       const createdUser = await backend.users.createUser({
         // ...
       });

       userId = createdUser.id;
     }

+    initialUserData.value = {
+      firstName: firstName.value,
+      lastName: lastName.value,
+      username: username.value,
+      email: email.value,
+      roles: [...selectedRoles.value],
+      previewUrl: pictureUrl.value
+    };
+
     userSaved.value = true;
     processing.value = false;

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In frontend/src/components/authority/UserEditCreate.vue around lines 444 to 451,
initialUserData is being set optimistically before the save API call completes
which can clear the "unsaved changes" state on API failure; move the assignment
of initialUserData into the success branch of the API response (after confirming
the server saved changes) so initialUserData is only updated on success, and
leave it unchanged (or revert UI state) on error to preserve the unsaved-changes
detection and show appropriate error handling.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
frontend/src/components/authority/GroupMemberList.vue (1)

32-32: Missing alt attribute on member avatar image.

The avatar image lacks an alt attribute, which is required for accessibility compliance. Screen readers need meaningful text to convey the image content to users.

🔎 Proposed fix
-              <img :src="member.pictureUrl" class="w-8 h-8 rounded-full object-cover border border-gray-300" />
+              <img :src="member.pictureUrl" :alt="member.name || member.email || t('common.member')" class="w-8 h-8 rounded-full object-cover border border-gray-300" />
🧹 Nitpick comments (5)
frontend/src/components/authority/UserInfo.vue (1)

5-5: Add alt attribute for accessibility.

The avatar image is missing an alt attribute, which is important for screen readers and accessibility compliance.

Proposed fix
-        <img :src="user.pictureUrl" class="w-48 h-48 rounded-full object-cover border border-gray-300 mb-4" />
+        <img :src="user.pictureUrl" :alt="user.name" class="w-48 h-48 rounded-full object-cover border border-gray-300 mb-4" />
frontend/src/components/authority/UserDetail.vue (2)

22-22: Localize screen reader text.

The "Open options menu" string is hardcoded in English. Use i18n for consistency and accessibility across locales.

Proposed fix
-            <span class="sr-only">Open options menu</span>
+            <span class="sr-only">{{ t('common.openOptionsMenu') }}</span>

Add the corresponding translation key to your i18n files.


128-139: Consider showing user-facing feedback on fetch failure.

When backend.users.getUser fails, the error is only logged to the console. The user sees the default empty state with no indication that something went wrong. Consider displaying an error message or redirecting gracefully.

Proposed approach
 const loading = ref<boolean>(true);
+const fetchError = ref<Error | null>(null);

 onMounted(async () => {
   try {
     user.value = await backend.users.getUser(props.id);

     // Load roles
     user.value.realmRoles = (user.value.realmRoles || []).filter((r: string) => r === 'admin' || r === 'create-vaults');
   } catch (error) {
     console.error('Failed to fetch user:', error);
+    fetchError.value = error instanceof Error ? error : new Error('Unknown error');
   } finally {
     loading.value = false;
   }
 });

Then render an error state in the template when fetchError is set.

frontend/src/components/authority/UserDeleteDialog.vue (1)

28-28: Add alt attribute for accessibility.

The user avatar image is missing an alt attribute for screen readers.

Proposed fix
-                        <img :src="user.pictureUrl" class="w-8 h-8 rounded-full border" />
+                        <img :src="user.pictureUrl" :alt="user.name" class="w-8 h-8 rounded-full border" />
frontend/src/components/authority/GroupMemberList.vue (1)

30-30: Simplify the key to use only member.id.

The current key concatenates member.id + member.name, but since member.id is unique, the concatenation is unnecessary. Additionally, member.name might be undefined, which would result in a key like "idundefined".

🔎 Proposed fix
-          <tr v-for="member in paginatedMembers" :key="member.id + member.name">
+          <tr v-for="member in paginatedMembers" :key="member.id">
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e5baf6e and 45c8009.

📒 Files selected for processing (8)
  • frontend/src/components/authority/GroupDeleteDialog.vue (1 hunks)
  • frontend/src/components/authority/GroupDetail.vue (1 hunks)
  • frontend/src/components/authority/GroupMemberList.vue (1 hunks)
  • frontend/src/components/authority/GroupMemberRemoveDialog.vue (1 hunks)
  • frontend/src/components/authority/UserDeleteDialog.vue (1 hunks)
  • frontend/src/components/authority/UserDetail.vue (1 hunks)
  • frontend/src/components/authority/UserGroupsList.vue (1 hunks)
  • frontend/src/components/authority/UserInfo.vue (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • frontend/src/components/authority/GroupMemberRemoveDialog.vue
  • frontend/src/components/authority/GroupDeleteDialog.vue
  • frontend/src/components/authority/UserGroupsList.vue
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-12-18T08:32:28.253Z
Learnt from: iammajid
Repo: cryptomator/hub PR: 376
File: frontend/src/components/authority/GroupInfo.vue:5-5
Timestamp: 2025-12-18T08:32:28.253Z
Learning: In the frontend codebase, ensure that authority DTOs for users and groups always include a pictureUrl by having AuthorityService.fillInMissingPicture() run at the service layer (as in backend.ts). As a result, components rendering users or groups (e.g., GroupInfo.vue and related components) can safely rely on pictureUrl being present and should not implement their own fallback logic for missing images. When reviewing, verify that the service layer populates pictureUrl for all user/group DTOs and that UI components do not duplicate fallback handling.

Applied to files:

  • frontend/src/components/authority/UserDeleteDialog.vue
  • frontend/src/components/authority/GroupDetail.vue
  • frontend/src/components/authority/GroupMemberList.vue
  • frontend/src/components/authority/UserDetail.vue
  • frontend/src/components/authority/UserInfo.vue
📚 Learning: 2024-07-27T13:14:40.963Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 282
File: frontend/src/common/userdata.ts:82-88
Timestamp: 2024-07-27T13:14:40.963Z
Learning: The error handling suggestion for `createBrowserKeys` in `frontend/src/common/userdata.ts` was discussed and resolved in PR #282, and should not be repeated in future reviews.

Applied to files:

  • frontend/src/components/authority/GroupDetail.vue
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run Tests
🔇 Additional comments (7)
frontend/src/components/authority/UserInfo.vue (1)

39-53: LGTM!

The script logic is well implemented:

  • Proper use of Composition API with TypeScript
  • sortedRoles computed property correctly handles nullable realmRoles with nullish coalescing
  • Locale-agnostic sorting with localeCompare is appropriate

Based on learnings, the component correctly relies on pictureUrl being populated by the service layer rather than implementing fallback logic.

frontend/src/components/authority/UserDetail.vue (2)

40-65: Responsive layout duplication is acceptable here.

The desktop two-column and mobile single-column layouts have different component ordering and structure. While this duplicates some markup, it's a reasonable trade-off for distinct responsive behaviors.


1-39: LGTM for overall structure and logic.

The component is well-structured with:

  • Clean separation of loading/content states
  • Proper use of Composition API
  • Dialog flow with nextTick for DOM timing
  • Group merge logic with deduplication and sorting

Based on learnings, the component correctly relies on pictureUrl being populated at the service layer.

Also applies to: 66-144

frontend/src/components/authority/UserDeleteDialog.vue (3)

64-70: Local interface is acceptable for component isolation.

While this User interface duplicates fields available in backend DTOs, it defines only the minimal required subset for this component's props. This provides clear documentation of the component's contract and allows flexibility in what can be passed.


100-112: LGTM!

The delete operation is well-implemented:

  • Clears previous error state before attempting
  • Properly handles both Error instances and unknown error types
  • Emits events in correct order (delete before closing)
  • User-facing error display is in the template (lines 45-47)

1-55: LGTM for dialog structure.

The Headless UI dialog implementation follows best practices:

  • Proper transition handling with TransitionRoot and TransitionChild
  • Emits close via @after-leave to ensure animation completes
  • Form with novalidate and @submit.prevent for controlled submission
  • Accessible with DialogTitle and proper button roles
frontend/src/components/authority/GroupMemberList.vue (1)

109-112: Consider notifying parent after member removal.

The onGroupMemberDeleted function updates the local members array but doesn't invoke props.onSaved to notify the parent component. In contrast, when members are added via GroupAddMemberDialog (line 75), the parent is notified through @saved="props.onSaved". This inconsistency might cause the parent's state to become out of sync after deletions.

If the parent component needs to track member changes (as suggested by the presence of the onSaved callback), consider calling it after deletion as well:

🔎 Proposed fix
 function onGroupMemberDeleted(deletedMemberId: string) {
   members.value = members.value.filter(m => m.id !== deletedMemberId);
   deletingGroupMember.value = undefined;
+  props.onSaved(members.value);
 }

Comment on lines +111 to +115
function onMembersSaved(newMembers: AuthorityDto[]) {
const ids = new Set(group.value.members.map(u => u.id));
newMembers.forEach(u => { if (!ids.has(u.id)) group.value.members.push(u); });
group.value.members.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add null safety to the sort comparison.

Line 114 calls a.name.localeCompare(b.name, ...) without checking if name is defined. If any member has an undefined name, this will throw a TypeError at runtime.

🔎 Proposed fix
 function onMembersSaved(newMembers: AuthorityDto[]) {
   const ids = new Set(group.value.members.map(u => u.id));
   newMembers.forEach(u => { if (!ids.has(u.id)) group.value.members.push(u); });
-  group.value.members.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
+  group.value.members.sort((a, b) => {
+    const aKey = a.name?.trim() || a.email || '';
+    const bKey = b.name?.trim() || b.email || '';
+    return aKey.localeCompare(bKey, undefined, { sensitivity: 'base' });
+  });
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function onMembersSaved(newMembers: AuthorityDto[]) {
const ids = new Set(group.value.members.map(u => u.id));
newMembers.forEach(u => { if (!ids.has(u.id)) group.value.members.push(u); });
group.value.members.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}
function onMembersSaved(newMembers: AuthorityDto[]) {
const ids = new Set(group.value.members.map(u => u.id));
newMembers.forEach(u => { if (!ids.has(u.id)) group.value.members.push(u); });
group.value.members.sort((a, b) => {
const aKey = a.name?.trim() || a.email || '';
const bKey = b.name?.trim() || b.email || '';
return aKey.localeCompare(bKey, undefined, { sensitivity: 'base' });
});
}
🤖 Prompt for AI Agents
In frontend/src/components/authority/GroupDetail.vue around lines 111 to 115,
the sort comparator calls a.name.localeCompare(b.name, ...) without null-safety
which will throw if name is undefined; update the comparator to coerce
undefined/null to a safe string (e.g. const na = a.name ?? '' and const nb =
b.name ?? '') before calling localeCompare, or add explicit checks returning
-1/1/0 when either name is missing, so the sort never calls localeCompare on
undefined.

</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a tabindex="0" class="cursor-pointer text-red-600 hover:text-red-900" :title="t('common.leave')" @click="showDeleteDialog(member)">{{ t('common.remove') }}</a>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use a button element instead of an anchor tag for the remove action.

The remove action uses an anchor tag with a click handler, but anchor tags are semantically meant for navigation. For actions like removing a member, a <button> element provides better semantics and built-in keyboard accessibility.

🔎 Proposed fix
-              <a tabindex="0" class="cursor-pointer text-red-600 hover:text-red-900" :title="t('common.leave')" @click="showDeleteDialog(member)">{{ t('common.remove') }}</a>
+              <button type="button" class="text-red-600 hover:text-red-900 focus:outline-hidden focus:ring-2 focus:ring-red-500" :title="t('common.leave')" @click="showDeleteDialog(member)">{{ t('common.remove') }}</button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<a tabindex="0" class="cursor-pointer text-red-600 hover:text-red-900" :title="t('common.leave')" @click="showDeleteDialog(member)">{{ t('common.remove') }}</a>
<button type="button" class="text-red-600 hover:text-red-900 focus:outline-hidden focus:ring-2 focus:ring-red-500" :title="t('common.leave')" @click="showDeleteDialog(member)">{{ t('common.remove') }}</button>
🤖 Prompt for AI Agents
In frontend/src/components/authority/GroupMemberList.vue around line 39, the
remove action uses an anchor (<a>) with a click handler which is semantically
incorrect for an action; replace it with a <button> element (type="button") to
convey action semantics and preserve accessibility. Keep the same classes, title
binding and @click handler (showDeleteDialog(member)), remove the href, and
ensure tabindex isn’t needed (or keep tabindex="0" if explicit focus order is
required); also add an aria-label if the visible text isn’t sufficiently
descriptive.

</td>
</tr>
<tr v-if="!filteredUsers.length">
<td colspan="3" class="py-4 px-4 text-sm text-center text-gray-500">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Correct the colspan to match the table structure.

The empty state row uses colspan="3", but the table only has 2 columns (member and actions). This should be colspan="2".

🔎 Proposed fix
-            <td colspan="3" class="py-4 px-4 text-sm text-center text-gray-500">
+            <td colspan="2" class="py-4 px-4 text-sm text-center text-gray-500">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<td colspan="3" class="py-4 px-4 text-sm text-center text-gray-500">
<td colspan="2" class="py-4 px-4 text-sm text-center text-gray-500">
🤖 Prompt for AI Agents
In frontend/src/components/authority/GroupMemberList.vue around line 43, the
empty-state table row uses colspan="3" but the table only has two columns
(member and actions); change the colspan attribute value from 3 to 2 so the
empty cell spans both columns and aligns with the table structure.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

♻️ Duplicate comments (9)
frontend/src/components/authority/UserEditCreate.vue (2)

189-189: Inconsistent i18n key namespace for password visibility aria-label.

Line 158 uses t('common.showPassword') / t('common.hidePassword'), but line 189 uses t('userEditCreate.showPassword') / t('userEditCreate.hidePassword'). These should be consistent.

🔎 Proposed fix
-                  <button type="button" class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 focus:outline-none" :aria-label="passwordInputType === 'password' ? t('userEditCreate.showPassword') : t('userEditCreate.hidePassword')" @click="togglePasswordVisibility">
+                  <button type="button" class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 focus:outline-none" :aria-label="passwordInputType === 'password' ? t('common.showPassword') : t('common.hidePassword')" @click="togglePasswordVisibility">

445-452: Optimistic state update may cause UX confusion on API failure.

initialUserData is updated before the API call succeeds. If the API call fails, userDataHasUnsavedChanges will return false (since initial matches current), hiding the "unsaved changes" warning even though data wasn't actually saved.

Consider moving this update after the successful API response (after line 475, within the try block).

backend/src/main/java/org/cryptomator/hub/api/UsersResource.java (2)

441-450: Remove broad Exception catch block.

Lines 447-449 catch all exceptions and wrap them in RuntimeException, which obscures the original failure and violates codebase patterns. Based on learnings from this project, exceptions should propagate naturally rather than being caught broadly.

The specific ForbiddenException and NotFoundException catches are appropriate, but the general Exception catch should be removed:

🔎 Suggested fix
 public Response deleteUser(@PathParam("id") String userId) {
-    try {
-        keycloakAdminService.deleteUser(userId);
-        return Response.noContent().build();
-    } catch (ForbiddenException | NotFoundException e) {
-        throw e;
-    } catch (Exception e) {
-        throw new RuntimeException("Failed to delete user", e);
-    }
+    keycloakAdminService.deleteUser(userId);
+    return Response.noContent().build();
 }

If keycloakAdminService.deleteUser throws checked exceptions that must be handled, catch only those specific types.

Based on learnings, as per coding guidelines, ...


325-356: Address exception handling and cleanup concerns.

Two issues to consider:

  1. Exception message exposure (line 352): Returning e.getMessage() directly from ClientErrorException may expose internal implementation details. Consider returning a sanitized message or using predefined constants.

  2. Orphaned Keycloak user on sync failure (lines 341-344): If Keycloak user creation succeeds but the user isn't found in the database after sync, a RuntimeException is thrown, leaving an orphaned user in Keycloak. Consider adding cleanup:

🔎 Suggested cleanup approach
 User user = userRepo.findById(userRepresentation.getId());
 if (user == null) {
+    // Attempt cleanup of orphaned Keycloak user
+    try {
+        keycloakAdminService.deleteUser(userRepresentation.getId());
+        LOG.warning("Cleaned up orphaned Keycloak user: " + userRepresentation.getId());
+    } catch (Exception cleanupEx) {
+        LOG.severe("Failed to cleanup orphaned Keycloak user: " + userRepresentation.getId());
+    }
     throw new RuntimeException("User was created in Keycloak but not found in database after sync");
 }

Based on past review comments.

backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java (2)

340-344: Reconsider direct database deletion for groups.

Similar to deleteUser, this directly deletes from groupRepo. Per past review feedback, consider letting the syncer handle database cleanup for consistency.


175-184: Security concern: Fail-open behavior on exception.

When checking federated identity fails (network issue, Keycloak unavailable), returning false allows modifications to potentially read-only users. Consider fail-closed behavior:

Proposed fix
 public boolean isUserReadOnly(String userId) {
     try {
         UserResource userResource = realm.users().get(userId);
         var federatedIdentities = userResource.getFederatedIdentity();
         return !federatedIdentities.isEmpty();
     } catch (WebApplicationException e) {
         LOG.warn("Failed to check federated identity for user {}. Keycloak responded with status {}", userId, e.getResponse().getStatus(), e);
-        return false;
+        throw new InternalServerErrorException("Unable to verify user editability", e);
     }
 }
backend/src/main/java/org/cryptomator/hub/api/UserDto.java (1)

166-172: Parameter name inconsistency in withCounts method.

The parameter is named vaultsCount but maps to record field accessibleVaultCount. Align the naming for clarity:

Proposed fix
-public WithCounts withCounts(long groupsCount, long vaultsCount, long devicesCount) {
+public WithCounts withCounts(long groupsCount, long accessibleVaultCount, long devicesCount) {
     return new WithCounts(
             this,
             devicesCount,
             groupsCount,
-            vaultsCount);
+            accessibleVaultCount);
 }
frontend/src/common/backend.ts (1)

114-116: Critical: Property name mismatch with backend API.

The frontend expects vaultRole but the backend's MemberDto.java serializes this field as role (via @JsonProperty("role")). This will cause vaultRole to be undefined at runtime.

 export type MemberDto = AuthorityDto & {
-  vaultRole: VaultRole
+  role: VaultRole
 }
backend/src/main/java/org/cryptomator/hub/entities/User.java (1)

202-217: Bidirectional ManyToMany mapping issue.

Both User.directGroupMemberships and Group.members define @JoinTable for the same relationship. One side should use mappedBy to avoid duplicate relationship management.

Recommended fix (make User the inverse side)
-@ManyToMany
-@JoinTable(name = "group_membership",
-        joinColumns = @JoinColumn(name = "member_id", referencedColumnName = "id"),
-        inverseJoinColumns = @JoinColumn(name = "group_id", referencedColumnName = "id")
-)
+@ManyToMany(mappedBy = "members")
 public Set<Group> directGroupMemberships = new HashSet<>();
🧹 Nitpick comments (8)
backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java (2)

199-203: Consider using the getRole() convenience method.

For slightly better encapsulation, use eva.getRole() instead of eva.getId().getRole() on line 201.

🔎 Proposed refactor
 	public Collection<VaultAccess.Role> listRoles(UUID vaultId, String authorityId) {
 		return find("#EffectiveVaultAccess.findByAuthorityAndVault", Parameters.with("vaultId", vaultId).and("authorityId", authorityId)).stream()
-				.map(eva -> eva.getId().getRole())
+				.map(EffectiveVaultAccess::getRole)
 				.collect(Collectors.toUnmodifiableSet());
 	}

77-85: Consider adding fetch joins for the authority relationship in repository queries.

The vault relationship is properly fetched via LEFT JOIN FETCH in User.java and VaultAccess queries. However, the authority relationship lacks equivalent fetch joins in the main repository queries for EffectiveVaultAccess. While current usages of getAuthority() occur within @Transactional methods, adding explicit fetch joins (similar to the vault relationship) would prevent potential lazy loading issues and match the pattern used in VaultAccess repository queries.

frontend/src/components/authority/UserEditCreate.vue (2)

243-251: Unused picture field in UserData interface.

The UserData interface defines a picture?: File | null field at line 249, but this field is never used in the component. The component only uses pictureUrl (string) for the profile picture URL, not a File object.

🔎 Proposed fix
 interface UserData {
   firstName: string;
   lastName: string;
   username: string;
   email: string;
   roles: SelectableRealmRole[];
-  picture?: File | null;
   previewUrl?: string | null;
 }

337-339: Type assertion may be unnecessary.

Lines 337-339 cast fetchedUser to an inline type to access firstName and lastName. According to the AI summary, UserDto was updated to include these fields. Verify that the type definition includes them and remove the assertion if possible.

🔎 Proposed fix (if UserDto includes firstName/lastName)
-      const userData = fetchedUser as { firstName?: string; lastName?: string };
-      firstName.value = userData.firstName || '';
-      lastName.value = userData.lastName || '';
+      firstName.value = fetchedUser.firstName || '';
+      lastName.value = fetchedUser.lastName || '';
frontend/src/common/backend.ts (1)

135-137: Address or remove the TODO comment.

The comment questions whether VaultDtoWithRole is unused. It is actually used in UserDto.accessibleVaults (line 84) and GroupDtoWithDetails.vaults (line 141). Consider removing this outdated TODO.

backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java (2)

160-173: Reconsider direct database deletion.

The method directly deletes from userRepo after Keycloak deletion. Based on past review feedback, this should ideally be handled by the syncer to ensure consistency. If immediate deletion is intentional, document why synchronous cleanup is required here.


186-214: Address TODO: Deduplicate sync logic with KeycloakAuthorityPuller.

The syncUser method duplicates logic from KeycloakAuthorityPuller.syncAddedUsers/syncUpdatedUsers. Consider extracting shared logic into a common helper or reusing the puller's methods to reduce maintenance burden.

backend/src/main/java/org/cryptomator/hub/api/UserDto.java (1)

62-77: Potential issue: Constructor delegates with duplicated parameters.

Line 76 passes ecdhPublicKey twice and privateKeys twice to the delegated constructor. This is intentional for the legacy fallback logic, but the pattern is confusing. Consider adding a comment explaining this.

// Delegate to JSON constructor with same value for both new and legacy parameters
// (no fallback needed when constructing programmatically)
this(id, name, pictureUrl, email, firstName, lastName, language, realmRoles, devices, ecdhPublicKey, ecdhPublicKey, ecdsaPublicKey, privateKeys, privateKeys, setupCode);
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 45c8009 and 2ba5d0a.

📒 Files selected for processing (19)
  • backend/src/main/java/org/cryptomator/hub/api/UserDto.java (5 hunks)
  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java (8 hunks)
  • backend/src/main/java/org/cryptomator/hub/api/VaultResource.java (4 hunks)
  • backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java (2 hunks)
  • backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java (1 hunks)
  • backend/src/main/java/org/cryptomator/hub/entities/User.java (7 hunks)
  • backend/src/main/java/org/cryptomator/hub/filters/VaultRole.java (2 hunks)
  • backend/src/main/java/org/cryptomator/hub/filters/VaultRoleFilter.java (2 hunks)
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java (1 hunks)
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProvider.java (3 hunks)
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java (4 hunks)
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakRealmRoles.java (1 hunks)
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakUserDto.java (1 hunks)
  • backend/src/main/java/org/cryptomator/hub/keycloak/RealmRole.java (1 hunks)
  • backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java (4 hunks)
  • backend/src/test/java/org/cryptomator/hub/filters/VaultRoleFilterTest.java (3 hunks)
  • backend/src/test/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProviderTest.java (4 hunks)
  • frontend/src/common/backend.ts (6 hunks)
  • frontend/src/components/authority/UserEditCreate.vue (1 hunks)
🧰 Additional context used
🧠 Learnings (10)
📚 Learning: 2025-06-06T09:18:42.468Z
Learnt from: SailReal
Repo: cryptomator/hub PR: 343
File: backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java:27-32
Timestamp: 2025-06-06T09:18:42.468Z
Learning: In the KeycloakAuthorityPuller and similar syncer components, don't add broad exception handling to scheduled methods. The Syncer execution context is designed to handle crashes, and it's better to let exceptions propagate rather than catching them broadly. Catching Exception is considered bad practice in this codebase.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java
  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java
📚 Learning: 2025-12-08T11:37:46.953Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 372
File: backend/src/test/java/org/cryptomator/hub/api/ExceedingLicenseLimitsIT.java:116-121
Timestamp: 2025-12-08T11:37:46.953Z
Learning: In the Cryptomator Hub backend schema, foreign key constraints use ON DELETE CASCADE for authority/user references. Specifically, deleting users or groups from the authority table automatically cascades to delete related rows in vault_access and access_token tables. Test cleanup methods that delete users/groups via repositories do not need explicit cleanup of these dependent tables.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java
  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java
  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2024-10-17T15:24:07.752Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 296
File: backend/src/test/java/org/cryptomator/hub/filters/VaultRoleFilterTest.java:183-186
Timestamp: 2024-10-17T15:24:07.752Z
Learning: In `VaultRoleFilterTest`, remember that the `securityContext` mock is set in the outer class's setup method at line 46, which applies to nested classes as well. Avoid suggesting redundant setup in nested classes.

Applied to files:

  • backend/src/test/java/org/cryptomator/hub/filters/VaultRoleFilterTest.java
  • backend/src/main/java/org/cryptomator/hub/filters/VaultRoleFilter.java
  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
  • backend/src/test/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProviderTest.java
📚 Learning: 2024-07-27T13:14:40.963Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 282
File: frontend/src/common/userdata.ts:82-88
Timestamp: 2024-07-27T13:14:40.963Z
Learning: The error handling suggestion for `createBrowserKeys` in `frontend/src/common/userdata.ts` was discussed and resolved in PR #282, and should not be repeated in future reviews.

Applied to files:

  • frontend/src/components/authority/UserEditCreate.vue
  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
  • frontend/src/common/backend.ts
📚 Learning: 2025-12-18T08:32:28.253Z
Learnt from: iammajid
Repo: cryptomator/hub PR: 376
File: frontend/src/components/authority/GroupInfo.vue:5-5
Timestamp: 2025-12-18T08:32:28.253Z
Learning: In the frontend codebase, ensure that authority DTOs for users and groups always include a pictureUrl by having AuthorityService.fillInMissingPicture() run at the service layer (as in backend.ts). As a result, components rendering users or groups (e.g., GroupInfo.vue and related components) can safely rely on pictureUrl being present and should not implement their own fallback logic for missing images. When reviewing, verify that the service layer populates pictureUrl for all user/group DTOs and that UI components do not duplicate fallback handling.

Applied to files:

  • frontend/src/components/authority/UserEditCreate.vue
  • frontend/src/common/backend.ts
📚 Learning: 2025-02-13T10:33:38.555Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 315
File: backend/src/main/java/org/cryptomator/hub/api/UserDto.java:0-0
Timestamp: 2025-02-13T10:33:38.555Z
Learning: In the UserDto class of the Cryptomator Hub project, the `JsonCreator` constructor is used to handle both new and legacy field names (e.g., `ecdhPublicKey`/`publicKey` and `privateKeys`/`privateKey`) during JSON deserialization, while a separate regular constructor is used for programmatic object creation with only the new field names.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
  • backend/src/main/java/org/cryptomator/hub/keycloak/RealmRole.java
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakUserDto.java
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProvider.java
  • backend/src/main/java/org/cryptomator/hub/api/UserDto.java
📚 Learning: 2025-12-18T08:32:42.414Z
Learnt from: iammajid
Repo: cryptomator/hub PR: 376
File: frontend/src/components/authority/GroupInfo.vue:5-5
Timestamp: 2025-12-18T08:32:42.414Z
Learning: In the Cryptomator Hub frontend codebase (frontend/src/common/backend.ts), AuthorityService.fillInMissingPicture() is applied to all user and group DTOs at the service layer, automatically generating Jdenticon fallback images as data URLs when pictureUrl is missing. This means components rendering users or groups can safely assume pictureUrl will always be present and do not need additional fallback handling.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProvider.java
📚 Learning: 2024-06-22T04:41:35.492Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 281
File: backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java:1-66
Timestamp: 2024-06-22T04:41:35.492Z
Learning: The `StringArrayType` class in the `backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java` file is used only for read-only purposes, specifically for the `effective_wot` view. The `nullSafeSet` method throws `UnsupportedOperationException` to enforce this behavior.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java
📚 Learning: 2025-03-12T14:53:06.855Z
Learnt from: SailReal
Repo: cryptomator/hub PR: 331
File: frontend/src/components/LegacyDeviceList.vue:52-87
Timestamp: 2025-03-12T14:53:06.855Z
Learning: In Cryptomator Hub, legacy devices are only of the 'DESKTOP' type, and there are no 'BROWSER' or 'MOBILE' legacy devices.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2025-03-13T08:37:00.992Z
Learnt from: SailReal
Repo: cryptomator/hub PR: 331
File: backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java:204-218
Timestamp: 2025-03-13T08:37:00.992Z
Learning: The event logging for device removals (`eventLogger.logDeviceRemoved()`) doesn't differentiate between legacy and regular devices in Cryptomator Hub to maintain historical consistency in the logs.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
🧬 Code graph analysis (8)
backend/src/test/java/org/cryptomator/hub/filters/VaultRoleFilterTest.java (1)
frontend/src/common/backend.ts (1)
  • RealmRole (66-66)
backend/src/main/java/org/cryptomator/hub/api/UsersResource.java (1)
backend/src/main/java/org/cryptomator/hub/api/GroupDto.java (1)
  • GroupDto (10-46)
backend/src/main/java/org/cryptomator/hub/filters/VaultRole.java (1)
frontend/src/common/backend.ts (1)
  • RealmRole (66-66)
backend/src/main/java/org/cryptomator/hub/keycloak/RealmRole.java (1)
frontend/src/common/backend.ts (1)
  • RealmRole (66-66)
backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java (2)
backend/src/test/java/org/cryptomator/hub/api/BillingResourceIT.java (3)
  • Nested (39-144)
  • Nested (146-169)
  • Nested (171-190)
backend/src/test/java/org/cryptomator/hub/api/GroupsResourceIT.java (3)
  • Nested (91-115)
  • Nested (117-131)
  • Nested (133-365)
backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java (3)
backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java (1)
  • ApplicationScoped (22-176)
backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProvider.java (1)
  • ApplicationScoped (20-144)
backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakRealmRoles.java (1)
  • ApplicationScoped (11-29)
frontend/src/common/backend.ts (3)
backend/src/main/java/org/cryptomator/hub/api/GroupDto.java (1)
  • GroupDto (10-46)
backend/src/main/java/org/cryptomator/hub/api/AuthorityDto.java (1)
  • AuthorityDto (8-45)
backend/src/main/java/org/cryptomator/hub/api/MemberDto.java (1)
  • MemberDto (8-35)
backend/src/main/java/org/cryptomator/hub/api/VaultResource.java (1)
frontend/src/common/backend.ts (3)
  • RealmRole (66-66)
  • VaultRole (64-64)
  • VaultDtoWithRole (135-137)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run Tests
🔇 Additional comments (65)
backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java (2)

12-15: LGTM! New imports support the ManyToOne associations.

The added JPA imports are appropriate for the new lazy-loaded associations.


91-97: LGTM! Standard getter implementations.

The new getters provide appropriate access to the associated entities.

backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakRealmRoles.java (1)

26-28: Consider error handling for missing roles.

If the role doesn't exist in Keycloak, toRepresentation() may throw an exception. Consider whether this should be handled gracefully or if throwing is the intended behavior.

backend/src/main/java/org/cryptomator/hub/filters/VaultRoleFilter.java (2)

45-48: LGTM!

The update correctly uses kcName() to get the string representation for the security context role check, aligning with the RealmRole enum introduced in the annotation.


76-80: LGTM!

Consistent with the bypass check above. The error message at line 78 appropriately uses the enum directly for human-readable output while kcName() is used for the actual role comparison.

backend/src/main/java/org/cryptomator/hub/filters/VaultRole.java (2)

5-5: LGTM!

Import added for the new RealmRole enum.


49-49: Good type-safety improvement.

Changing from String to RealmRole enum improves type safety and prevents typos in role names. The RealmRole.kcName() values ("user", "admin", "create-vaults") correctly match the frontend's RealmRole type.

backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java (3)

38-47: LGTM!

The logic correctly enriches Keycloak user data with realm roles before syncing. The null check at line 43 properly handles cases where a user might be assigned a role but not exist in the users list.


81-84: LGTM!

The new user creation properly populates firstName, lastName, and realmRoles fields, aligning with the enriched user data model.


108-111: LGTM!

User updates now sync firstName, lastName, and realmRoles consistently with the add path.

backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java (4)

43-48: Verify null handling behavior.

The implementation converts null to an empty array (new String[0]) rather than setting SQL NULL. This means the database will store an empty array when the value is null. Ensure this is the intended behavior for fields like realmRoles.

If SQL NULL should be preserved:

🔎 Alternative implementation preserving NULL
 	public void nullSafeSet(PreparedStatement st, String[] value, int index, SharedSessionContractImplementor session) throws SQLException {
-		session.doWork(connection -> {
-			var jdbcArray = connection.createArrayOf("VARCHAR", value == null ? new String[0] : value);
-			st.setArray(index, jdbcArray);
-		});
+		if (value == null) {
+			st.setNull(index, Types.ARRAY);
+		} else {
+			session.doWork(connection -> {
+				var jdbcArray = connection.createArrayOf("VARCHAR", value);
+				st.setArray(index, jdbcArray);
+			});
+		}
 	}

Based on learnings, this class was previously read-only for the effective_wot view. The change enables write operations for storing realmRoles on users.


51-52: LGTM!

Correctly updated to true since the type now supports mutations and proper deep copying.


55-58: LGTM!

Proper defensive copy implementation for mutable array type.


65-72: LGTM!

The assemble method now properly validates the cached type and returns a defensive copy. The IllegalArgumentException provides a clear error message for type mismatches.

backend/src/test/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProviderTest.java (2)

3-4: LGTM: Test class rename and imports align with role-based user listing.

The class rename to KeycloakAuthorityProviderTest and the addition of Hamcrest/role-related imports properly support the new role-based user listing tests introduced in this PR.

Also applies to: 15-16, 27-27


195-229: LGTM: Well-structured tests for role-based user listing.

The new "Roles" nested test suite properly exercises the usersInRole functionality with:

  • Consistent mocking patterns
  • Order-agnostic assertions using Hamcrest
  • Coverage of both populated and empty role membership
backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakUserDto.java (1)

3-5: LGTM: KeycloakUserDto expanded to include user details and roles.

The record now includes firstName, lastName, and roles (as Set<RealmRole>), aligning with the expanded user model and type-safe realm role handling introduced in this PR.

backend/src/test/java/org/cryptomator/hub/filters/VaultRoleFilterTest.java (2)

14-14: LGTM: Test updates properly reference RealmRole enum.

The test display names now reference RealmRole.ADMIN, while isUserInRole calls correctly use the string representation "admin" to match the JAX-RS SecurityContext API.

Also applies to: 190-192, 199-201


230-230: LGTM: Annotation updated to use type-safe RealmRole enum.

The @VaultRole annotation now uses realmRole = RealmRole.ADMIN, providing type-safe realm role specification.

backend/src/main/java/org/cryptomator/hub/keycloak/RealmRole.java (2)

1-22: LGTM: Well-designed enum for type-safe realm role handling.

The RealmRole enum provides type-safe realm role handling with proper JSON binding via @JsonProperty annotations and a clear mapping to Keycloak role names.


24-31: LGTM: fromKcName properly validates input.

The method correctly throws IllegalArgumentException for unknown role names, ensuring callers are aware of invalid input.

backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProvider.java (3)

64-73: LGTM: mapToUser correctly populates expanded KeycloakUserDto.

The mapping now includes firstName, lastName, and realmRoles, properly converting Keycloak role names to the type-safe RealmRole enum using fromKcNames.


75-82: LGTM: Improved null-safety in parsePictureUrl.

The refactored implementation uses explicit null/containsKey checks instead of catching NPE, making the code more readable and maintainable.


126-143: LGTM: Well-implemented role-based user listing with pagination.

The new usersInRole methods follow existing patterns in this class:

  • Public method delegates to package-visible implementation for testability
  • Proper pagination handling for large role memberships
  • Consistent with other listing methods (users, groups)
backend/src/main/java/org/cryptomator/hub/api/VaultResource.java (2)

49-49: LGTM: VaultRole annotations updated to use type-safe RealmRole enum.

The vault access annotations now use RealmRole.ADMIN and RealmRole.CREATE_VAULTS, providing compile-time type safety for realm role references.

Also applies to: 389-389, 403-403


526-544: LGTM: VaultDtoWithRole provides vault info with role context.

The new record properly combines vault details with the user's role, supporting enriched user/group detail views. The static factory follows existing patterns and correctly truncates timestamps.

backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java (6)

12-16: LGTM: Test dependencies properly configured.

New imports and the @InjectMock for KeycloakAdminService properly support the expanded CRUD operation tests.

Also applies to: 20-20, 30-30, 36-36, 58-59


368-399: LGTM: Well-structured test class with proper setup/teardown.

The UserCrudOperations nested class follows established patterns with:

  • Correct admin role annotation
  • Proper mock reset for test isolation
  • Database setup/cleanup in BeforeAll/AfterAll

401-407: LGTM: Basic list users test validates endpoint access.

Simple validation that the admin can retrieve the user list.


409-509: LGTM: Comprehensive POST /users test coverage.

Tests properly validate:

  • Successful user creation (201)
  • Username/email conflicts (409)
  • Invalid input validation (400)

511-608: LGTM: GET and PUT tests cover key scenarios.

Tests validate:

  • Successful retrieval and updates
  • Not found handling
  • Federated user restrictions (403)

610-639: LGTM: DELETE tests validate removal and restrictions.

Tests properly cover:

  • Successful deletion (204)
  • Not found errors (404)
  • Federated user restrictions (403)
backend/src/main/java/org/cryptomator/hub/api/UsersResource.java (6)

11-13: LGTM: Imports and logger support expanded user management.

New imports properly support Keycloak integration, exception handling, and group management features.

Also applies to: 27-27, 35-36, 51-51, 58-58


76-83: LGTM: New dependencies properly injected.

Group.Repository and KeycloakAdminService correctly support the expanded user management functionality.


195-195: LGTM: getMe methods now include firstName, lastName, and realmRoles.

The methods correctly fetch these fields from the database (user entity) and include them in the response, aligning with past review guidance to avoid repeated Keycloak queries.

Also applies to: 219-219


358-402: LGTM: getUser properly restricted and avoids inefficient vault loading.

The endpoint is correctly restricted to admins and uses the user.accessibleVaults relationship to efficiently load only the vaults accessible to the user, addressing past performance concerns.

Based on past review comments.


404-431: LGTM: updateUser properly implements admin-level user updates.

The endpoint correctly:

  • Restricts access to admins
  • Updates both user details and roles in Keycloak
  • Lets specific exceptions propagate naturally (past broad exception handling removed)

Based on past review comments.


452-471: LGTM: DTOs properly structured for user CRUD operations.

Both CreateUserDto and UpdateUserDto have appropriate fields with proper validation annotations and type-safe RealmRole handling.

frontend/src/common/backend.ts (7)

66-67: LGTM!

The RealmRole type correctly mirrors the backend realm role values.


79-84: LGTM!

The extension of UserDto with firstName, lastName, and realmRoles aligns with backend changes. The accessibleVaults type change to VaultDtoWithRole[] correctly includes role information.


91-101: LGTM!

The UserDtoWithCounts and UserDtoWithDetails types properly extend UserDto with additional metadata. The accessibleVaultCount naming avoids the type conflict mentioned in past reviews.


123-142: LGTM!

The new DTO types (CreateUserDto, UpdateUserDto, CreateGroupDto, UpdateGroupDto, VaultDtoWithRole, GroupDtoWithDetails) are well-structured and align with the backend API contracts.


302-353: LGTM!

The new GroupService class provides comprehensive CRUD and membership management. Key observations:

  • All methods correctly apply AuthorityService.fillInMissingPicture for consistency
  • Error handling with rethrowAndConvertIfExpected follows existing patterns
  • The getGroup method properly fills pictures for both the group and its members

369-393: LGTM!

The new UserService methods (removeUser, listAll, createUser, getUser, updateUser) are well-implemented:

  • removeUser correctly returns Promise<void> and handles 404
  • All methods apply fillInMissingPicture consistently

524-526: LGTM!

The service map correctly exposes the new GroupService.

backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java (8)

57-60: LGTM!

The @PostConstruct initialization of the realm resource is appropriate for caching the realm reference.


62-121: LGTM with minor observations.

The createUser method is well-structured with proper error handling for 409 conflicts. The TODO at line 112 about rollback strategy is a valid concern for future consideration, but the current best-effort approach with logging is acceptable for initial implementation.


127-158: LGTM!

The updateUser method correctly:

  • Checks for read-only users before modification
  • Uses the setPicture helper to preserve other attributes
  • Handles password updates separately with proper credential representation

216-246: LGTM!

The syncGroup method properly:

  • Handles both new and existing groups
  • Syncs picture attributes correctly
  • Updates members and triggers effectiveGroupMembershipRepo.updateGroups

248-264: LGTM!

The group membership methods correctly trigger NotFoundException for non-existent groups by calling toRepresentation() before modifications, and properly sync the group afterward.


266-290: LGTM!

The updateUserRoles method correctly:

  • Removes roles not in the provided set
  • Adds roles that are in the provided set
  • Syncs to the database

294-316: LGTM!

The createGroup method follows the same pattern as createUser with proper conflict handling and sync.


346-354: LGTM!

The setPicture helper correctly handles null attributes and preserves other attributes while modifying only the picture.

backend/src/main/java/org/cryptomator/hub/api/UserDto.java (6)

16-17: LGTM!

Adding @JsonInclude(JsonInclude.Include.NON_NULL) at the class level is a good practice to reduce response payload size.


19-28: LGTM!

The new firstName, lastName, and realmRoles fields properly extend the user representation.


30-60: LGTM!

The @JsonCreator constructor properly handles both new and legacy field names while incorporating the new fields. The @NotNull annotations on critical fields ensure proper validation.


84-102: LGTM!

The new getters for firstName, lastName, and realmRoles are properly annotated with @JsonProperty.


149-164: LGTM!

The justPublicInfo factory method correctly incorporates the new fields.


174-196: LGTM!

The withDetails method and the WithCounts/WithDetails records are well-structured with appropriate @JsonUnwrapped and @JsonProperty annotations.

backend/src/main/java/org/cryptomator/hub/entities/User.java (6)

52-83: LGTM!

The new NamedQueries for fetching and counting groups, vaults, vault access, and devices for a user are well-structured and use appropriate joins with EffectiveGroupMembership and EffectiveVaultAccess.


89-100: LGTM!

The new firstName, lastName, and realmRoles fields are properly mapped:

  • realmRoles uses @Type(StringArrayType.class) with String[], which is the correct approach for PostgreSQL array columns
  • This resolves the previous concern about using @Column with Set<String>

122-152: LGTM!

The getters and setters for the new fields are straightforward and follow the existing patterns.


215-217: LGTM!

The @Immutable annotation on accessibleVaults correctly marks this as a read-only view of EffectiveVaultAccess.


251-268: LGTM!

The findByIdWithEagerDetails and findByIds repository methods are well-implemented:

  • Eager fetch joins cover all needed relationships
  • Batch processing in findByIds prevents excessive IN clause sizes

286-326: LGTM!

The new repository methods correctly use the named queries and provide clean interfaces for fetching user-related data:

  • getGroupsForUser, countGroupsForUser
  • getVaultsForUser, getVaultAccessForUser, countVaultsForUser
  • countDevicesForUser

Comment on lines +322 to +338
public GroupRepresentation updateGroup(String groupId, String name, String pictureUrl) {
GroupResource groupResource = realm.groups().group(groupId);
GroupRepresentation group = groupResource.toRepresentation();

if (!name.isBlank()) {
group.setName(name);
}

if (pictureUrl != null) {
var attrs = setPicture(group.getAttributes(), pictureUrl);
group.setAttributes(attrs);
}

groupResource.update(group);
syncGroup(groupId);
return groupResource.toRepresentation();
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential null check needed for name parameter.

Line 326 calls name.isBlank() but name could be null, causing a NullPointerException. The createGroup method doesn't have this issue because it requires the name.

Proposed fix
-if (!name.isBlank()) {
+if (name != null && !name.isBlank()) {
     group.setName(name);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public GroupRepresentation updateGroup(String groupId, String name, String pictureUrl) {
GroupResource groupResource = realm.groups().group(groupId);
GroupRepresentation group = groupResource.toRepresentation();
if (!name.isBlank()) {
group.setName(name);
}
if (pictureUrl != null) {
var attrs = setPicture(group.getAttributes(), pictureUrl);
group.setAttributes(attrs);
}
groupResource.update(group);
syncGroup(groupId);
return groupResource.toRepresentation();
}
public GroupRepresentation updateGroup(String groupId, String name, String pictureUrl) {
GroupResource groupResource = realm.groups().group(groupId);
GroupRepresentation group = groupResource.toRepresentation();
if (name != null && !name.isBlank()) {
group.setName(name);
}
if (pictureUrl != null) {
var attrs = setPicture(group.getAttributes(), pictureUrl);
group.setAttributes(attrs);
}
groupResource.update(group);
syncGroup(groupId);
return groupResource.toRepresentation();
}
🤖 Prompt for AI Agents
In backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java
around lines 322 to 338, the updateGroup method calls name.isBlank() which can
throw a NullPointerException when name is null; change the condition to first
check for null and only set the name when it's non-null and non-blank (e.g. if
(name != null && !name.isBlank()) { group.setName(name); }) so null names leave
the existing group name unchanged; optionally trim the name before checking to
avoid setting blank/whitespace-only values.

Comment on lines +20 to +24
private final EnumMap<RealmRole, RoleRepresentation> cachedRoles = new EnumMap<>(RealmRole.class);

public RoleRepresentation getRealmRole(RealmRole realmRole) {
return cachedRoles.computeIfAbsent(realmRole, this::fetchRealmRole);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Thread-safety concern with EnumMap in application-scoped bean.

EnumMap is not thread-safe, and computeIfAbsent on a non-concurrent map can exhibit race conditions when accessed by multiple threads simultaneously. Since this is an @ApplicationScoped bean, concurrent access is possible.

Consider using ConcurrentHashMap or synchronizing access:

🔎 Proposed fix using ConcurrentHashMap
-import java.util.EnumMap;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.Map;

 @ApplicationScoped
 public class KeycloakRealmRoles {

 	@Inject
 	Keycloak keycloak;

 	@ConfigProperty(name = "hub.keycloak.realm")
 	String keycloakRealm;

-	private final EnumMap<RealmRole, RoleRepresentation> cachedRoles = new EnumMap<>(RealmRole.class);
+	private final Map<RealmRole, RoleRepresentation> cachedRoles = new ConcurrentHashMap<>();

 	public RoleRepresentation getRealmRole(RealmRole realmRole) {
 		return cachedRoles.computeIfAbsent(realmRole, this::fetchRealmRole);
 	}

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakRealmRoles.java
around lines 20 to 24, the cachedRoles field is an EnumMap and computeIfAbsent
is called on it from an @ApplicationScoped bean which may be accessed
concurrently; EnumMap is not thread-safe and can race. Replace the EnumMap with
a thread-safe map (e.g., ConcurrentHashMap<RealmRole, RoleRepresentation>) or
guard access with synchronization so compute-if-absent semantics are safe (use
ConcurrentHashMap.computeIfAbsent or synchronize the fetch path) and ensure no
long-running blocking occurs while holding any lock.

Comment on lines +33 to +46
public static Set<RealmRole> fromKcNames(Collection<String> kcNames) {
if (kcNames == null || kcNames.isEmpty()) {
return EnumSet.noneOf(RealmRole.class);
}
Set<RealmRole> roles = EnumSet.noneOf(RealmRole.class);
for (String kcName : kcNames) {
for (RealmRole role : values()) {
if (role.kcName.equals(kcName)) {
roles.add(role);
}
}
}
return roles;
}
Copy link
Contributor

@coderabbitai coderabbitai bot Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Inconsistent error handling between fromKcName and fromKcNames.

fromKcName throws IllegalArgumentException for unknown roles, but fromKcNames silently skips them. This inconsistency could cause:

  • Authorization issues if a role is misspelled or unrecognized
  • Hard-to-debug problems when roles silently disappear

Consider either:

  1. Logging a warning when unknown roles are encountered
  2. Throwing an exception for unknown roles (with clear error message listing the invalid names)
  3. Returning a result object that includes both matched roles and a list of unknown role names

This would make role mapping failures visible and prevent silent authorization degradation.

🤖 Prompt for AI Agents
In backend/src/main/java/org/cryptomator/hub/keycloak/RealmRole.java around
lines 33 to 46, the fromKcNames method currently ignores unknown kcNames while
fromKcName throws IllegalArgumentException; change fromKcNames to mirror that
behavior by collecting any names that do not match an enum constant and, if any
are found, throw an IllegalArgumentException listing the invalid names (use a
clear message like "Unknown realm role(s): ..." with the list); otherwise return
the EnumSet of matched roles as before — ensure null/empty input still returns
EnumSet.noneOf(RealmRole.class).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

by design

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@overheadhunter, understood! I've noted that the different error handling strategies between fromKcName and fromKcNames are intentional for your use case.


✏️ Learnings added
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 376
File: backend/src/main/java/org/cryptomator/hub/keycloak/RealmRole.java:33-46
Timestamp: 2025-12-19T19:53:33.331Z
Learning: In `backend/src/main/java/org/cryptomator/hub/keycloak/RealmRole.java`, the `fromKcNames` method intentionally skips unknown Keycloak role names silently, while `fromKcName` throws `IllegalArgumentException` for unknown roles. This asymmetric error handling is by design.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment on lines +337 to +340
public async getEffectiveMembers(groupId: string): Promise<UserDto[]> {
return axiosAuth.get<UserDto[]>(`/groups/${groupId}/effective-members`)
.then(response => response.data.map(AuthorityService.fillInMissingPicture));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing error handling for getEffectiveMembers.

Unlike other methods in this service, getEffectiveMembers doesn't handle expected error codes (e.g., 404 for non-existent group).

Proposed fix
 public async getEffectiveMembers(groupId: string): Promise<UserDto[]> {
   return axiosAuth.get<UserDto[]>(`/groups/${groupId}/effective-members`)
-    .then(response => response.data.map(AuthorityService.fillInMissingPicture));
+    .then(response => response.data.map(AuthorityService.fillInMissingPicture))
+    .catch((error) => rethrowAndConvertIfExpected(error, 404));
 }
🤖 Prompt for AI Agents
In frontend/src/common/backend.ts around lines 337 to 340, getEffectiveMembers
calls axiosAuth but lacks error handling for expected HTTP errors (e.g., 404).
Wrap the call to axiosAuth.get in a promise chain or try/catch that: on 404
returns an empty array, on other errors rethrows or rejects so callers can
handle them, and still maps response.data through
AuthorityService.fillInMissingPicture when successful; follow the same
error-handling pattern used by other methods in this service.

Comment on lines +387 to +389
public async getUser(userId: string): Promise<UserDtoWithDetails> {
return axiosAuth.get<UserDtoWithDetails>(`/users/${userId}`).then(response => AuthorityService.fillInMissingPicture(response.data));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing error handling for getUser.

Similar to getEffectiveMembers, this method should handle 404 errors for non-existent users.

Proposed fix
 public async getUser(userId: string): Promise<UserDtoWithDetails> {
-  return axiosAuth.get<UserDtoWithDetails>(`/users/${userId}`).then(response => AuthorityService.fillInMissingPicture(response.data));
+  return axiosAuth.get<UserDtoWithDetails>(`/users/${userId}`)
+    .then(response => AuthorityService.fillInMissingPicture(response.data))
+    .catch((error) => rethrowAndConvertIfExpected(error, 404));
 }
🤖 Prompt for AI Agents
In frontend/src/common/backend.ts around lines 387 to 389, getUser currently
returns the axios promise without handling 404s; update it to catch Axios
errors, detect a 404 (error.response?.status === 404) and return null in that
case, rethrow other errors, and update the method signature to
Promise<UserDtoWithDetails | null> (or adjust callers) so callers can handle
missing users; use axios.isAxiosError to safely detect Axios errors.

errors.value.username = t('userEditCreate.error.userAlreadyExists');
}
} else {
submitError.value = error instanceof Error ? error.message : 'An error occurred';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Use i18n for error message instead of hardcoded string.

The fallback error message 'An error occurred' is hardcoded. Use an i18n key for consistency with the rest of the component.

🔎 Proposed fix
     } else {
-      submitError.value = error instanceof Error ? error.message : 'An error occurred';
+      submitError.value = error instanceof Error ? error.message : t('common.error');

Then add the corresponding key to frontend/src/i18n/en-US.json:

{
  "common": {
    "error": "An error occurred"
  }
}

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In frontend/src/components/authority/UserEditCreate.vue around line 497, the
fallback error string is hardcoded; replace the literal 'An error occurred' with
the component's i18n lookup (e.g. use the existing i18n/t function or $t call to
fetch a key like common.error) so submitError.value = error instanceof Error ?
error.message : t('common.error'); and then add the corresponding key
"common.error": "An error occurred" to frontend/src/i18n/en-US.json; ensure the
i18n composable/import is present in the component and adjust imports if needed.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (2)
frontend/src/common/backend.ts (2)

341-344: Missing error handling for getEffectiveMembers.

Unlike other methods in GroupService, getEffectiveMembers doesn't handle expected error codes (e.g., 404 for non-existent group). This is inconsistent with the established pattern.

🔎 Proposed fix
 public async getEffectiveMembers(groupId: string): Promise<UserDto[]> {
   return axiosAuth.get<UserDto[]>(`/groups/${groupId}/effective-members`)
-    .then(response => response.data.map(AuthorityService.fillInMissingPicture));
+    .then(response => response.data.map(AuthorityService.fillInMissingPicture))
+    .catch((error) => rethrowAndConvertIfExpected(error, 404));
 }

391-393: Missing error handling for getUser.

Similar to other get methods, getUser should handle 404 errors for non-existent users to maintain consistency with the established error-handling pattern.

🔎 Proposed fix
 public async getUser(userId: string): Promise<UserDtoWithDetails> {
-  return axiosAuth.get<UserDtoWithDetails>(`/users/${userId}`).then(response => AuthorityService.fillInMissingPicture(response.data));
+  return axiosAuth.get<UserDtoWithDetails>(`/users/${userId}`)
+    .then(response => AuthorityService.fillInMissingPicture(response.data))
+    .catch((error) => rethrowAndConvertIfExpected(error, 404));
 }
🧹 Nitpick comments (3)
frontend/src/components/authority/UserDetail.vue (2)

120-123: The newGroups parameter is unused.

handleGroupsSaved declares a newGroups parameter but reloads the entire user from the backend instead of using the passed data. Either remove the unused parameter or utilize it to avoid the extra network request.

🔎 Proposed fix

If the parameter is unnecessary, remove it:

-async function handleGroupsSaved(newGroups: GroupDto[]) {
+async function handleGroupsSaved() {
   user.value = await backend.users.getUser(props.id);
   user.value.groups.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
 }

Or use the passed groups to avoid reloading:

 async function handleGroupsSaved(newGroups: GroupDto[]) {
-  user.value = await backend.users.getUser(props.id);
-  user.value.groups.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
+  user.value.groups = newGroups;
+  user.value.groups.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
 }

Note: If vault membership may change when group membership changes, the full reload approach is safer.


125-137: Consider displaying an error message to the user.

The error handler logs to the console but doesn't provide user-facing feedback. Users won't know why the page appears empty or incomplete if the fetch fails.

🔎 Proposed enhancement

Add an error state and display it in the template:

 const loading = ref<boolean>(true);
+const error = ref<string | null>(null);

 onMounted(async () => {
   try {
     user.value = await backend.users.getUser(props.id);
     user.value.groups.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
     user.value.realmRoles = user.value.realmRoles.filter(isSelectableRealmRole);
   } catch (error) {
     console.error('Failed to fetch user:', error);
+    error.value = t('user.detail.loadError');
   } finally {
     loading.value = false;
   }
 });

Then show the error in the template before line 5:

<div v-if="error" class="text-center p-8 text-red-600 text-sm">
  {{ error }}
</div>
frontend/src/common/backend.ts (1)

139-141: Remove the outdated TODO comment.

VaultDtoWithRole is used at line 88 in UserDto.accessibleVaults: VaultDtoWithRole[], so the "TODO: unused??" comment is incorrect.

🔎 Proposed fix
-export type VaultDtoWithRole = VaultDto & { // TODO: unused??
+export type VaultDtoWithRole = VaultDto & {
   role: VaultRole;
 }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2ba5d0a and a988b55.

📒 Files selected for processing (3)
  • frontend/src/common/backend.ts (6 hunks)
  • frontend/src/components/authority/UserDetail.vue (1 hunks)
  • frontend/src/components/authority/UserEditCreate.vue (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/src/components/authority/UserEditCreate.vue
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-12-18T08:32:28.253Z
Learnt from: iammajid
Repo: cryptomator/hub PR: 376
File: frontend/src/components/authority/GroupInfo.vue:5-5
Timestamp: 2025-12-18T08:32:28.253Z
Learning: In the frontend codebase, ensure that authority DTOs for users and groups always include a pictureUrl by having AuthorityService.fillInMissingPicture() run at the service layer (as in backend.ts). As a result, components rendering users or groups (e.g., GroupInfo.vue and related components) can safely rely on pictureUrl being present and should not implement their own fallback logic for missing images. When reviewing, verify that the service layer populates pictureUrl for all user/group DTOs and that UI components do not duplicate fallback handling.

Applied to files:

  • frontend/src/components/authority/UserDetail.vue
  • frontend/src/common/backend.ts
📚 Learning: 2024-07-27T13:14:40.963Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 282
File: frontend/src/common/userdata.ts:82-88
Timestamp: 2024-07-27T13:14:40.963Z
Learning: The error handling suggestion for `createBrowserKeys` in `frontend/src/common/userdata.ts` was discussed and resolved in PR #282, and should not be repeated in future reviews.

Applied to files:

  • frontend/src/common/backend.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run Tests
🔇 Additional comments (2)
frontend/src/common/backend.ts (2)

462-473: LGTM: Visual consistency improvement.

Updating the GROUP Jdenticon hues to match USER hues ensures visual consistency across authority types.


528-530: LGTM: Service properly exported.

The GroupService is correctly added to the services map, making it available to consumers of the backend module.

Comment on lines +395 to 397
public async updateUser(userId: string, dto: UpdateUserDto): Promise<UserDto> {
return axiosAuth.put<UserDto>(`/users/${userId}`, dto).then(response => AuthorityService.fillInMissingPicture(response.data));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Missing error handling for updateUser.

updateUser should catch and convert 404 errors to maintain consistency with other update methods (e.g., updateGroup at lines 329-332).

🔎 Proposed fix
 public async updateUser(userId: string, dto: UpdateUserDto): Promise<UserDto> {
-  return axiosAuth.put<UserDto>(`/users/${userId}`, dto).then(response => AuthorityService.fillInMissingPicture(response.data));
+  return axiosAuth.put<UserDto>(`/users/${userId}`, dto)
+    .then(response => AuthorityService.fillInMissingPicture(response.data))
+    .catch((error) => rethrowAndConvertIfExpected(error, 404));
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public async updateUser(userId: string, dto: UpdateUserDto): Promise<UserDto> {
return axiosAuth.put<UserDto>(`/users/${userId}`, dto).then(response => AuthorityService.fillInMissingPicture(response.data));
}
public async updateUser(userId: string, dto: UpdateUserDto): Promise<UserDto> {
return axiosAuth.put<UserDto>(`/users/${userId}`, dto)
.then(response => AuthorityService.fillInMissingPicture(response.data))
.catch((error) => rethrowAndConvertIfExpected(error, 404));
}
🤖 Prompt for AI Agents
In frontend/src/common/backend.ts around lines 395 to 397, updateUser currently
returns the axios promise directly and lacks the 404-to-NotFound error mapping
used by other update methods; wrap the axiosAuth.put call with a .catch that
checks for err.response?.status === 404 and rethrows the same NotFoundError (or
equivalent) used by updateGroup, otherwise rethrow the original error, and keep
the successful path applying AuthorityService.fillInMissingPicture to
response.data.

Comment on lines +45 to +46
<UserDeviceList :devices="user.devices" :page-size="10" :title="t('user.detail.devices')"/>
<UserDeviceList :devices="user.legacyDevices" :page-size="10" :visible="user.legacyDevices.length != 0" :title="t('legacyDeviceList.title')" :info="t('legacyDeviceList.title')"/>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The :info prop duplicates the :title value.

Lines 46 and 62 set :info="t('legacyDeviceList.title')", which is identical to the :title prop. This likely should be different text—perhaps a description or explanation about legacy devices.

🔎 Suggested fix

If an info/description key exists for legacy devices, use it instead:

-<UserDeviceList :devices="user.legacyDevices" :page-size="10" :visible="user.legacyDevices.length != 0" :title="t('legacyDeviceList.title')" :info="t('legacyDeviceList.title')"/>
+<UserDeviceList :devices="user.legacyDevices" :page-size="10" :visible="user.legacyDevices.length != 0" :title="t('legacyDeviceList.title')" :info="t('legacyDeviceList.description')"/>

Or omit :info entirely if there's no separate description:

-<UserDeviceList :devices="user.legacyDevices" :page-size="10" :visible="user.legacyDevices.length != 0" :title="t('legacyDeviceList.title')" :info="t('legacyDeviceList.title')"/>
+<UserDeviceList :devices="user.legacyDevices" :page-size="10" :visible="user.legacyDevices.length != 0" :title="t('legacyDeviceList.title')"/>

Also applies to: 61-62

🤖 Prompt for AI Agents
In frontend/src/components/authority/UserDetail.vue around lines 45-46 and 61-62
the :info prop for the legacy device UserDeviceList duplicates the :title (both
use t('legacyDeviceList.title')); change :info to use the correct translation
key (e.g. t('legacyDeviceList.info') or t('legacyDeviceList.description')) if
one exists, otherwise remove the :info prop entirely so it no longer repeats the
title.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (7)
backend/src/main/java/org/cryptomator/hub/entities/User.java (1)

181-186: Improper bidirectional many-to-many mapping still present.

Both User.directGroupMemberships and Group.members define @JoinTable for the same relationship. One side should use mappedBy to be the inverse side. This was flagged in a previous review.

backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java (4)

106-116: Document the best-effort group assignment behavior or implement rollback.

The TODO at line 112 asks whether to rollback user creation on group assignment failure. Current behavior continues despite failures. Either document this as intentional best-effort behavior or implement proper rollback to ensure atomicity.


175-184: Fail-open security: exception returns false, allowing modifications to potentially read-only users.

If checking federated identity fails (network issue, Keycloak unavailable), the method returns false, meaning the user appears editable. Consider fail-closed behavior.

🔎 Suggested fix
 } catch (WebApplicationException e) {
     LOG.warn("Failed to check federated identity for user {}. Keycloak responded with status {}", userId, e.getResponse().getStatus(), e);
-    return false;
+    throw new InternalServerErrorException("Unable to verify user editability", e);
 }

321-337: Potential NullPointerException: null check needed for name parameter.

Line 325 calls name.isBlank() but name could be null, causing a NullPointerException.

🔎 Suggested fix
-if (!name.isBlank()) {
+if (name != null && !name.isBlank()) {
     group.setName(name);
 }

339-343: Direct database deletion bypasses the syncer.

Line 342 directly deletes the group via groupRepo.deleteById(groupId). This should ideally be handled by the syncer for consistency, especially if the syncer runs concurrently. The same applies to userRepo.deleteById at line 172.

backend/src/main/java/org/cryptomator/hub/api/UsersResource.java (2)

345-351: Do not expose exception messages to clients.

Line 348 returns e.getMessage() directly in the response body. While the message is controlled ("EMAIL_EXISTS", "USERNAME_EXISTS"), exposing raw exception messages is a security anti-pattern. Use a structured error response instead.

🔎 Suggested fix
     } catch (ClientErrorException e) {
-        // Return 409 with specific error message (EMAIL_EXISTS or USERNAME_EXISTS)
         return Response.status(Response.Status.CONFLICT)
-                .entity(e.getMessage())
-                .type(MediaType.TEXT_PLAIN)
+                .entity(Map.of("error", e.getMessage()))
+                .type(MediaType.APPLICATION_JSON)
                 .build();
     }

438-446: Avoid catching broad Exception; let exceptions propagate naturally.

Lines 443-445 catch all exceptions and wrap them in a RuntimeException. This obscures the original failure. Based on past review comments, this was flagged as addressed but the code still shows the pattern.

🔎 Recommended fix
 public Response deleteUser(@PathParam("id") String userId) {
-    try {
-        keycloakAdminService.deleteUser(userId);
-        return Response.noContent().build();
-    } catch (ForbiddenException | NotFoundException e) {
-        throw e;
-    } catch (Exception e) {
-        throw new RuntimeException("Failed to delete user", e);
-    }
+    keycloakAdminService.deleteUser(userId);
+    return Response.noContent().build();
 }

The ForbiddenException and NotFoundException are JAX-RS exceptions handled by the framework. Other unexpected exceptions should propagate naturally.

🧹 Nitpick comments (5)
backend/src/main/java/org/cryptomator/hub/entities/UserMetrics.java (1)

13-26: Consider adding DISTINCT to direct_group_count for consistency.

Line 16 uses COUNT(g.group_id) without DISTINCT, while lines 17-18 use COUNT(DISTINCT ...). Although the group_membership table likely has unique entries per user-group pair, adding DISTINCT would make the query consistent and guard against potential data anomalies.

🔎 Suggested fix
 SELECT
     u.id AS user_id,
-    COUNT(g.group_id) AS direct_group_count,
+    COUNT(DISTINCT g.group_id) AS direct_group_count,
     COUNT(DISTINCT eva.vault_id) AS effective_vault_count,
     COUNT(DISTINCT d.id) AS device_count
backend/src/main/java/org/cryptomator/hub/entities/User.java (2)

243-248: Potential memory concern with Stream concatenation in findByIds.

The Stream.concat approach accumulates all partial streams in memory. For very large ID collections, consider returning a List directly or using a collector that doesn't hold intermediate streams.

🔎 Alternative approach
 public Stream<User> findByIds(Collection<String> ids) {
-    return Batch.of(200).run(ids, Stream.empty(), (batch, result) -> {
-        var partial = find("id IN :ids", Parameters.with("ids", batch));
-        return Stream.concat(result, partial.stream());
-    });
+    return Batch.of(200).run(ids, new java.util.ArrayList<User>(), (batch, result) -> {
+        result.addAll(find("id IN :ids", Parameters.with("ids", batch)).list());
+        return result;
+    }).stream();
 }

175-190: Inconsistent field visibility: consider making collection fields private.

Fields like accessTokens, devices, directGroupMemberships, and accessibleVaults are public, while other entity fields are private with getters/setters. If this is intentional for Panache active record patterns, consider adding a brief comment explaining the design choice.

backend/src/main/java/org/cryptomator/hub/api/UsersResource.java (1)

419-419: Consider checking if roles changed before updating.

Unlike createUser (line 333) which checks if (!dto.realmRoles().isEmpty()), updateUser always calls updateUserRoles. This causes unnecessary Keycloak API calls when roles haven't changed.

backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java (1)

186-213: Address TODO: Deduplicate with KeycloakAuthorityPuller.

The TODO at line 186 notes code duplication with KeycloakAuthorityPuller. Consider extracting common synchronization logic to a shared helper to maintain consistency and reduce duplication.

Do you want me to propose a shared helper class for user synchronization logic?

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a988b55 and 7ca0c7f.

📒 Files selected for processing (4)
  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java (8 hunks)
  • backend/src/main/java/org/cryptomator/hub/entities/User.java (8 hunks)
  • backend/src/main/java/org/cryptomator/hub/entities/UserMetrics.java (1 hunks)
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java (1 hunks)
🧰 Additional context used
🧠 Learnings (8)
📚 Learning: 2025-02-13T10:33:38.555Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 315
File: backend/src/main/java/org/cryptomator/hub/api/UserDto.java:0-0
Timestamp: 2025-02-13T10:33:38.555Z
Learning: In the UserDto class of the Cryptomator Hub project, the `JsonCreator` constructor is used to handle both new and legacy field names (e.g., `ecdhPublicKey`/`publicKey` and `privateKeys`/`privateKey`) during JSON deserialization, while a separate regular constructor is used for programmatic object creation with only the new field names.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2024-10-17T15:24:07.752Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 296
File: backend/src/test/java/org/cryptomator/hub/filters/VaultRoleFilterTest.java:183-186
Timestamp: 2024-10-17T15:24:07.752Z
Learning: In `VaultRoleFilterTest`, remember that the `securityContext` mock is set in the outer class's setup method at line 46, which applies to nested classes as well. Avoid suggesting redundant setup in nested classes.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
📚 Learning: 2025-06-06T09:18:42.468Z
Learnt from: SailReal
Repo: cryptomator/hub PR: 343
File: backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java:27-32
Timestamp: 2025-06-06T09:18:42.468Z
Learning: In the KeycloakAuthorityPuller and similar syncer components, don't add broad exception handling to scheduled methods. The Syncer execution context is designed to handle crashes, and it's better to let exceptions propagate rather than catching them broadly. Catching Exception is considered bad practice in this codebase.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java
📚 Learning: 2024-07-27T13:14:40.963Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 282
File: frontend/src/common/userdata.ts:82-88
Timestamp: 2024-07-27T13:14:40.963Z
Learning: The error handling suggestion for `createBrowserKeys` in `frontend/src/common/userdata.ts` was discussed and resolved in PR #282, and should not be repeated in future reviews.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
📚 Learning: 2025-12-08T11:37:46.953Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 372
File: backend/src/test/java/org/cryptomator/hub/api/ExceedingLicenseLimitsIT.java:116-121
Timestamp: 2025-12-08T11:37:46.953Z
Learning: In the Cryptomator Hub backend schema, foreign key constraints use ON DELETE CASCADE for authority/user references. Specifically, deleting users or groups from the authority table automatically cascades to delete related rows in vault_access and access_token tables. Test cleanup methods that delete users/groups via repositories do not need explicit cleanup of these dependent tables.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
  • backend/src/main/java/org/cryptomator/hub/entities/User.java
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java
📚 Learning: 2025-03-12T14:53:06.855Z
Learnt from: SailReal
Repo: cryptomator/hub PR: 331
File: frontend/src/components/LegacyDeviceList.vue:52-87
Timestamp: 2025-03-12T14:53:06.855Z
Learning: In Cryptomator Hub, legacy devices are only of the 'DESKTOP' type, and there are no 'BROWSER' or 'MOBILE' legacy devices.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2025-03-13T08:37:00.992Z
Learnt from: SailReal
Repo: cryptomator/hub PR: 331
File: backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java:204-218
Timestamp: 2025-03-13T08:37:00.992Z
Learning: The event logging for device removals (`eventLogger.logDeviceRemoved()`) doesn't differentiate between legacy and regular devices in Cryptomator Hub to maintain historical consistency in the logs.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2025-12-18T08:32:42.414Z
Learnt from: iammajid
Repo: cryptomator/hub PR: 376
File: frontend/src/components/authority/GroupInfo.vue:5-5
Timestamp: 2025-12-18T08:32:42.414Z
Learning: In the Cryptomator Hub frontend codebase (frontend/src/common/backend.ts), AuthorityService.fillInMissingPicture() is applied to all user and group DTOs at the service layer, automatically generating Jdenticon fallback images as data URLs when pictureUrl is missing. This means components rendering users or groups can safely assume pictureUrl will always be present and do not need additional fallback handling.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java
🧬 Code graph analysis (2)
backend/src/main/java/org/cryptomator/hub/entities/User.java (4)
backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java (2)
  • ApplicationScoped (173-204)
  • Entity (27-205)
backend/src/main/java/org/cryptomator/hub/entities/Group.java (2)
  • ApplicationScoped (47-63)
  • Entity (22-64)
backend/src/main/java/org/cryptomator/hub/entities/AccessToken.java (2)
  • ApplicationScoped (164-178)
  • Entity (22-179)
backend/src/main/java/org/cryptomator/hub/entities/UserMetrics.java (1)
  • Entity (11-57)
backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java (3)
backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java (1)
  • ApplicationScoped (22-176)
backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProvider.java (1)
  • ApplicationScoped (20-144)
backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakRealmRoles.java (1)
  • ApplicationScoped (11-29)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run Tests
🔇 Additional comments (8)
backend/src/main/java/org/cryptomator/hub/entities/UserMetrics.java (1)

27-57: LGTM!

The read-only entity is well-designed with appropriate @Immutable and @Subselect annotations. The @JoinColumn with insertable = false, updatable = false correctly establishes the relationship without allowing modifications through this entity.

backend/src/main/java/org/cryptomator/hub/api/UsersResource.java (3)

248-251: LGTM! N+1 query issue resolved with eager metrics loading.

The findAllWithMetrics() approach efficiently fetches user metrics in a single query with LEFT JOIN FETCH, addressing the previous N+1 performance concern.


363-398: LGTM! Efficient eager loading with admin-only access.

The endpoint correctly uses findByIdWithEagerDetails() to load all related entities in a single query, and is properly restricted to admin users.


448-467: LGTM!

The DTO records are well-designed with appropriate validation annotations. @NotNull on realmRoles ensures roles are always provided, while nullable fields in UpdateUserDto allow partial updates.

backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java (3)

57-60: LGTM!

The @PostConstruct initialization pattern is appropriate for caching the RealmResource instance.


345-353: LGTM!

The setPicture helper correctly handles attribute manipulation without mutating the original map, and properly removes or sets the picture attribute based on input.


247-263: LGTM!

Group membership operations correctly sync the group after modifications to ensure the database reflects Keycloak state.

backend/src/main/java/org/cryptomator/hub/entities/User.java (1)

68-70: StringArrayType is properly implemented to handle PostgreSQL array columns. The implementation correctly uses Types.ARRAY and connection.createArrayOf("VARCHAR", ...) to map String[] to the database column defined as VARCHAR[] in the migration.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (8)
frontend/src/components/authority/UserEditCreate.vue (3)

482-482: Use i18n for error message instead of hardcoded string.

The fallback error message 'An error occurred' is hardcoded. Use an i18n key for consistency.

🔎 Proposed fix
-      submitError.value = error instanceof Error ? error.message : 'An error occurred';
+      submitError.value = error instanceof Error ? error.message : t('common.unexpectedError');

189-189: Inconsistent i18n key namespace for password visibility aria-label.

Line 158 uses t('common.showPassword') / t('common.hidePassword'), but line 189 uses t('userEditCreate.showPassword') / t('userEditCreate.hidePassword'). These should be consistent.

🔎 Proposed fix
-                  <button type="button" class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 focus:outline-none" :aria-label="passwordInputType === 'password' ? t('userEditCreate.showPassword') : t('userEditCreate.hidePassword')" @click="togglePasswordVisibility">
+                  <button type="button" class="absolute inset-y-0 right-0 flex items-center px-3 text-gray-400 hover:text-gray-600 focus:outline-none" :aria-label="passwordInputType === 'password' ? t('common.showPassword') : t('common.hidePassword')" @click="togglePasswordVisibility">

445-452: Optimistic state update may cause UX confusion on API failure.

initialUserData is updated before the API call succeeds (lines 445-452). If the API call fails, userDataHasUnsavedChanges will return false since initial matches current, hiding the "unsaved changes" warning even though data wasn't actually saved. Move this update after the successful API response.

🔎 Proposed fix
   processing.value = true;

   firstName.value = firstName.value.trim();
   lastName.value = lastName.value.trim();
   username.value = username.value.trim();
   email.value = email.value.trim();
   pictureUrl.value = pictureUrl.value.trim();

-  initialUserData.value = {
-    firstName: firstName.value,
-    lastName: lastName.value,
-    username: username.value,
-    email: email.value,
-    roles: [...selectedRoles.value],
-    previewUrl: pictureUrl.value
-  };
-
   try {
     if (props.mode === 'EDIT') {
       // ... API call
     } else {
       // ... API call
     }

+    initialUserData.value = {
+      firstName: firstName.value,
+      lastName: lastName.value,
+      username: username.value,
+      email: email.value,
+      roles: [...selectedRoles.value],
+      previewUrl: pictureUrl.value
+    };
+
     userSaved.value = true;
frontend/src/common/backend.ts (3)

122-124: Verify property name alignment with backend API.

This was flagged in a previous review. The frontend expects vaultRole but the backend's MemberDto.java may serialize this field as role. Confirm the backend JSON property name matches.

#!/bin/bash
# Check the backend MemberDto to verify the JSON property name
rg -n "@JsonProperty|vaultRole|\"role\"" --type=java -B 2 -A 2 backend/src/main/java/org/cryptomator/hub/api/MemberDto.java

345-348: Missing error handling for getEffectiveMembers.

Unlike other methods in this service, getEffectiveMembers doesn't handle expected error codes (e.g., 404 for non-existent group). This was flagged in a previous review.

🔎 Proposed fix
 public async getEffectiveMembers(groupId: string): Promise<UserDto[]> {
   return axiosAuth.get<UserDto[]>(`/groups/${groupId}/effective-members`)
-    .then(response => response.data.map(AuthorityService.fillInMissingPicture));
+    .then(response => response.data.map(AuthorityService.fillInMissingPicture))
+    .catch((error) => rethrowAndConvertIfExpected(error, 404));
 }

395-401: Missing error handling for getUser and updateUser.

Both methods should handle 404 errors for consistency with other service methods. This was flagged in a previous review.

🔎 Proposed fix
 public async getUser(userId: string): Promise<UserDtoWithDetails> {
-  return axiosAuth.get<UserDtoWithDetails>(`/users/${userId}`).then(response => AuthorityService.fillInMissingPicture(response.data));
+  return axiosAuth.get<UserDtoWithDetails>(`/users/${userId}`)
+    .then(response => AuthorityService.fillInMissingPicture(response.data))
+    .catch((error) => rethrowAndConvertIfExpected(error, 404));
 }

 public async updateUser(userId: string, dto: UpdateUserDto): Promise<UserDto> {
-  return axiosAuth.put<UserDto>(`/users/${userId}`, dto).then(response => AuthorityService.fillInMissingPicture(response.data));
+  return axiosAuth.put<UserDto>(`/users/${userId}`, dto)
+    .then(response => AuthorityService.fillInMissingPicture(response.data))
+    .catch((error) => rethrowAndConvertIfExpected(error, 404));
 }
backend/src/main/java/org/cryptomator/hub/entities/User.java (1)

181-186: Bidirectional many-to-many mapping still defines @JoinTable on both sides.

This was flagged in a previous review. Both User.directGroupMemberships and Group.members define their own @JoinTable annotations for the same group_membership table. In JPA, one side should use mappedBy to be the inverse side.

🔎 Recommended fix - Make User the inverse side
-	@ManyToMany
-	@JoinTable(name = "group_membership",
-			joinColumns = @JoinColumn(name = "member_id", referencedColumnName = "id"),
-			inverseJoinColumns = @JoinColumn(name = "group_id", referencedColumnName = "id")
-	)
+	@ManyToMany(mappedBy = "members")
 	public Set<Group> directGroupMemberships = new HashSet<>();
frontend/src/components/authority/GroupDetail.vue (1)

111-115: Add null safety to the sort comparison.

Line 114 calls a.name.localeCompare(b.name, ...) without checking if name is defined. If any member has an undefined name, this will throw a TypeError at runtime. This was flagged in a previous review.

🔎 Proposed fix
 function onMembersSaved(newMembers: AuthorityDto[]) {
   const ids = new Set(group.value.members.map(u => u.id));
   newMembers.forEach(u => { if (!ids.has(u.id)) group.value.members.push(u); });
-  group.value.members.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
+  group.value.members.sort((a, b) => {
+    const aName = a.name ?? '';
+    const bName = b.name ?? '';
+    return aName.localeCompare(bName, undefined, { sensitivity: 'base' });
+  });
 }
🧹 Nitpick comments (2)
frontend/src/components/authority/GroupEditCreate.vue (1)

211-225: Good pattern: State update after successful API call.

Unlike UserEditCreate.vue, this component correctly updates initialGroupData after the API call succeeds (lines 211-216), preventing the optimistic update issue.

However, the processing.value = false is only set in the catch block (line 228), not after successful completion. The redirect happens via setTimeout, but if the user navigates away before the timeout, the processing state remains true.

🔎 Consider adding processing reset on success
     // Show saved success state
     groupSaved.value = true;
     debouncedGroupSaved();
+    processing.value = false;

     // Redirect after successful save
     setTimeout(() => {
       router.push('/app/groups');
     }, 1000);
frontend/src/components/authority/GroupDetail.vue (1)

41-60: Duplicated layout sections for responsive design.

Lines 41-52 and 53-60 render the same components twice with different visibility classes (hidden lg:grid vs grid lg:hidden). While this works, it duplicates the component tree and could cause issues with component state or event handlers being called twice.

Consider using a single layout with CSS Grid reordering instead:

<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start pt-3">
  <section class="order-1 lg:order-1 grid gap-6">
    <GroupInfo :group="group"/>
    <VaultList class="hidden lg:block" :vaults="group.vaults" :page-size="10" :visible="true"/>
  </section>
  <section class="order-2 lg:order-2 grid gap-6">
    <GroupMemberList v-model:members="group.members" :group="group" :page-size="10" :on-saved="onMembersSaved" />
  </section>
  <VaultList class="order-3 lg:hidden" :vaults="group.vaults" :page-size="10" :visible="true"/>
</div>

This keeps a single component instance for GroupMemberList and GroupInfo.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3025391 and 79a9741.

📒 Files selected for processing (11)
  • backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java (1 hunks)
  • backend/src/main/java/org/cryptomator/hub/entities/User.java (8 hunks)
  • frontend/eslint.config.js (3 hunks)
  • frontend/src/common/backend.ts (8 hunks)
  • frontend/src/components/authority/GroupDetail.vue (1 hunks)
  • frontend/src/components/authority/GroupEditCreate.vue (1 hunks)
  • frontend/src/components/authority/GroupMemberList.vue (1 hunks)
  • frontend/src/components/authority/GroupMemberRemoveDialog.vue (1 hunks)
  • frontend/src/components/authority/UserDetail.vue (1 hunks)
  • frontend/src/components/authority/UserEditCreate.vue (1 hunks)
  • frontend/src/router/index.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • frontend/src/components/authority/GroupMemberRemoveDialog.vue
  • frontend/src/components/authority/UserDetail.vue
  • frontend/src/router/index.ts
  • frontend/src/components/authority/GroupMemberList.vue
🧰 Additional context used
🧠 Learnings (8)
📚 Learning: 2024-07-27T13:14:40.963Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 282
File: frontend/src/common/userdata.ts:82-88
Timestamp: 2024-07-27T13:14:40.963Z
Learning: The error handling suggestion for `createBrowserKeys` in `frontend/src/common/userdata.ts` was discussed and resolved in PR #282, and should not be repeated in future reviews.

Applied to files:

  • frontend/src/components/authority/UserEditCreate.vue
  • frontend/src/components/authority/GroupDetail.vue
  • frontend/src/common/backend.ts
📚 Learning: 2025-12-18T08:32:28.253Z
Learnt from: iammajid
Repo: cryptomator/hub PR: 376
File: frontend/src/components/authority/GroupInfo.vue:5-5
Timestamp: 2025-12-18T08:32:28.253Z
Learning: In the frontend codebase, ensure that authority DTOs for users and groups always include a pictureUrl by having AuthorityService.fillInMissingPicture() run at the service layer (as in backend.ts). As a result, components rendering users or groups (e.g., GroupInfo.vue and related components) can safely rely on pictureUrl being present and should not implement their own fallback logic for missing images. When reviewing, verify that the service layer populates pictureUrl for all user/group DTOs and that UI components do not duplicate fallback handling.

Applied to files:

  • frontend/src/components/authority/UserEditCreate.vue
  • frontend/src/components/authority/GroupDetail.vue
  • frontend/src/common/backend.ts
  • frontend/src/components/authority/GroupEditCreate.vue
📚 Learning: 2025-02-13T10:33:38.555Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 315
File: backend/src/main/java/org/cryptomator/hub/api/UserDto.java:0-0
Timestamp: 2025-02-13T10:33:38.555Z
Learning: In the UserDto class of the Cryptomator Hub project, the `JsonCreator` constructor is used to handle both new and legacy field names (e.g., `ecdhPublicKey`/`publicKey` and `privateKeys`/`privateKey`) during JSON deserialization, while a separate regular constructor is used for programmatic object creation with only the new field names.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2025-12-19T19:53:33.331Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 376
File: backend/src/main/java/org/cryptomator/hub/keycloak/RealmRole.java:33-46
Timestamp: 2025-12-19T19:53:33.331Z
Learning: In `backend/src/main/java/org/cryptomator/hub/keycloak/RealmRole.java`, the `fromKcNames` method intentionally skips unknown Keycloak role names silently, while `fromKcName` throws `IllegalArgumentException` for unknown roles. This asymmetric error handling is by design.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2025-12-08T11:37:46.953Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 372
File: backend/src/test/java/org/cryptomator/hub/api/ExceedingLicenseLimitsIT.java:116-121
Timestamp: 2025-12-08T11:37:46.953Z
Learning: In the Cryptomator Hub backend schema, foreign key constraints use ON DELETE CASCADE for authority/user references. Specifically, deleting users or groups from the authority table automatically cascades to delete related rows in vault_access and access_token tables. Test cleanup methods that delete users/groups via repositories do not need explicit cleanup of these dependent tables.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2025-03-12T14:53:06.855Z
Learnt from: SailReal
Repo: cryptomator/hub PR: 331
File: frontend/src/components/LegacyDeviceList.vue:52-87
Timestamp: 2025-03-12T14:53:06.855Z
Learning: In Cryptomator Hub, legacy devices are only of the 'DESKTOP' type, and there are no 'BROWSER' or 'MOBILE' legacy devices.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2025-03-13T08:37:00.992Z
Learnt from: SailReal
Repo: cryptomator/hub PR: 331
File: backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java:204-218
Timestamp: 2025-03-13T08:37:00.992Z
Learning: The event logging for device removals (`eventLogger.logDeviceRemoved()`) doesn't differentiate between legacy and regular devices in Cryptomator Hub to maintain historical consistency in the logs.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2024-06-22T04:41:35.492Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 281
File: backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java:1-66
Timestamp: 2024-06-22T04:41:35.492Z
Learning: The `StringArrayType` class in the `backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java` file is used only for read-only purposes, specifically for the `effective_wot` view. The `nullSafeSet` method throws `UnsupportedOperationException` to enforce this behavior.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java
🧬 Code graph analysis (1)
backend/src/main/java/org/cryptomator/hub/entities/User.java (6)
backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java (2)
  • ApplicationScoped (173-204)
  • Entity (27-205)
backend/src/main/java/org/cryptomator/hub/entities/Authority.java (2)
  • ApplicationScoped (95-108)
  • Entity (20-109)
backend/src/main/java/org/cryptomator/hub/entities/Vault.java (2)
  • ApplicationScoped (251-268)
  • Entity (34-269)
backend/src/main/java/org/cryptomator/hub/entities/AccessToken.java (2)
  • ApplicationScoped (164-178)
  • Entity (22-179)
backend/src/main/java/org/cryptomator/hub/entities/UserMetrics.java (1)
  • Entity (11-57)
backend/src/main/java/org/cryptomator/hub/entities/Batch.java (1)
  • Batch (8-44)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run Tests
🔇 Additional comments (18)
frontend/eslint.config.js (4)

7-13: LGTM! Standard ignore patterns applied.

Excluding coverage reports, build artifacts, and dependencies from linting is a best practice.


49-49: LGTM! Good addition for code maintainability.

Enforcing a cyclomatic complexity limit of 10 for TypeScript files helps maintain code quality and readability.


104-104: LGTM! Appropriate complexity threshold for Vue SFCs.

Setting a higher complexity limit (15) for Vue files compared to TypeScript files (10) is reasonable, as single-file components naturally have higher complexity due to template logic and component composition.


100-100: Rule disabling is acceptable for modern SFC-based Vue projects, but verify single-word component usage.

The vue/multi-word-component-names rule requires component names to be multi-word to prevent conflicts with HTML elements, since all HTML elements are single words. However, conflicts only arise when components are used in their lowercase form (HTML templates), while using the uppercase form (SFC templates) completely eliminates conflicts. In SFCs, PascalCase tag names for child components are recommended to differentiate from native HTML elements, and Vue SFC is a compiled format that enables case-sensitive tag names.

If this project exclusively uses SFC templates with PascalCase component names (modern Vue 3 default), the rule disabling carries minimal risk. Verify that:

  • Single-word components like <User>, <Button>, or <Header> are not created, or
  • If used, they only appear in SFC templates with PascalCase casing (not in DOM templates or lowercase forms)
backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java (3)

55-72: LGTM: Correct implementation of mutable UserType methods.

The deepCopy, disassemble, and assemble methods are correctly implemented for a mutable custom type:

  • deepCopy safely clones arrays to prevent shared references.
  • disassemble appropriately delegates to deepCopy for serialization.
  • assemble defensively validates and clones the cached value.

These implementations align with the change to isMutable() = true and follow Hibernate best practices.


50-53: The architectural shift from read-only to mutable is intentional and necessary for the V23 migration. The nullSafeSet() method now properly supports write operations via connection.createArrayOf() and st.setArray(). The change maintains backward compatibility: the EffectiveWot view continues to function with read-only signature chains, while the User entity now supports the new realm_roles column. The implementation correctly implements UserType<String[]> with proper mutability semantics across all methods (deepCopy, assemble, disassemble).


36-40: The implementation is correct and aligns with intended usage patterns.

Regarding the specific code at lines 36-40: Returning new String[0] when the ResultSet array is null is intentional and correct. The realmRoles field in the User entity is initialized with new String[0] as the default value, and the signatureChain in EffectiveWot consistently expects array values rather than null. No null-checking code exists for these fields in the codebase, confirming that empty arrays are the expected representation.

The resource management in nullSafeSet (lines 42-48) is correct. The Array object created by createArrayOf is passed to st.setArray(), where the JDBC driver takes ownership for lifecycle management. The change to isMutable = true is intentional and part of the Web of Trust feature enablement (PR #281), transitioning from read-only database views to full write support.

backend/src/main/java/org/cryptomator/hub/entities/User.java (2)

68-70: LGTM on the realm roles mapping.

The String[] type with @Type(StringArrayType.class) is the correct approach for mapping a PostgreSQL array column. This properly addresses the storage of realm roles without requiring a separate join table.


84-86: Consider using FetchType.LAZY explicitly for clarity and add orphan removal consideration.

The @OneToOne mapping to UserMetrics is lazy, which is appropriate. However, since UserMetrics is @Immutable and derived from a @Subselect, this is read-only. The current mapping looks correct for the use case.

frontend/src/components/authority/UserEditCreate.vue (2)

243-249: Props type definition uses discriminated union correctly.

The discriminated union pattern for CREATE vs EDIT modes is well-designed, ensuring type safety for the id prop based on mode.


263-272: LGTM on the roles comparison fix.

The array comparison now uses JSON.stringify with sorted arrays, which correctly compares by value rather than reference. This addresses the previous review feedback.

frontend/src/common/backend.ts (3)

37-39: Good addition of isAxiosError type guard export.

Exporting the Axios type guard utility enables type-safe error handling in components without importing Axios directly.


70-74: Well-designed type guard for selectable roles.

The isSelectableRealmRole type guard properly narrows RealmRole to SelectableRealmRole, enabling safe filtering in the UI.


310-361: GroupService implementation follows established patterns.

The new GroupService correctly:

  • Applies AuthorityService.fillInMissingPicture to all returned DTOs
  • Uses rethrowAndConvertIfExpected for error handling on most methods
  • Returns Promise<void> for delete/add/remove operations
frontend/src/components/authority/GroupEditCreate.vue (2)

102-108: Props discriminated union is well-designed.

Same clean pattern as UserEditCreate.vue for distinguishing CREATE vs EDIT modes with proper type narrowing.


65-65: focus:outline-hidden is the correct Tailwind CSS v4 utility; no change needed.

In Tailwind CSS v4, outline-hidden is the standard utility for hiding browser outlines, not outline-none which was used in v3. The code on line 65 is correct.

frontend/src/components/authority/GroupDetail.vue (2)

120-131: Good error handling pattern in fetchGroup.

The fetchGroup function properly:

  • Resets loading and error state before fetching
  • Captures errors to fetchError for UI display
  • Uses finally to ensure loading state is cleared

This correctly addresses the previous review feedback about user feedback on fetch failures.


16-16: Inconsistent Tailwind class: focus:outline-hidden.

Same issue as in GroupEditCreate.vue - focus:outline-hidden is not a standard Tailwind utility. Use focus:outline-none instead.

⛔ Skipped due to learnings
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 306
File: frontend/src/components/RegenerateSetupCodeDialog.vue:85-85
Timestamp: 2025-01-30T21:52:25.038Z
Learning: When reviewing code that uses Tailwind CSS v4, the `focus:outline-hidden` utility is the correct way to hide focus outlines, replacing the v3 utility `focus:outline-none`.
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 306
File: frontend/src/components/SimpleNavigationBar.vue:22-22
Timestamp: 2025-01-30T21:49:08.122Z
Learning: When migrating from Tailwind CSS v3 to v4, the `outline-none` utility must be changed to `outline-hidden` as per the official migration guide.
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 306
File: frontend/src/components/RegenerateSetupCodeDialog.vue:34-34
Timestamp: 2025-01-30T21:52:30.816Z
Learning: When migrating from Tailwind CSS v3 to v4, the `outline-none` utility must be changed to `outline-hidden` as per the official migration guide.
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 306
File: frontend/src/components/SimpleNavigationBar.vue:22-22
Timestamp: 2025-01-30T21:49:08.122Z
Learning: When migrating from Tailwind CSS v3 to v4, the opacity modifier syntax has changed - `ring-black ring-opacity-5` should be updated to `ring-black/5` as per the official migration guide.

Comment on lines 42 to 48
@Override
public void nullSafeSet(PreparedStatement st, String[] value, int index, SharedSessionContractImplementor session) {
throw new UnsupportedOperationException("Read Only");
public void nullSafeSet(PreparedStatement st, String[] value, int index, SharedSessionContractImplementor session) throws SQLException {
session.doWork(connection -> {
var jdbcArray = connection.createArrayOf("VARCHAR", value == null ? new String[0] : value);
st.setArray(index, jdbcArray);
});
}
Copy link
Contributor

@coderabbitai coderabbitai bot Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential resource leak: SQL Array not closed.

The java.sql.Array returned by connection.createArrayOf() implements AutoCloseable (as of JDBC 4.2). Not closing it can lead to resource leaks, especially under high load.

🔎 Proposed fix to ensure the array is closed
 public void nullSafeSet(PreparedStatement st, String[] value, int index, SharedSessionContractImplementor session) throws SQLException {
 	session.doWork(connection -> {
-		var jdbcArray = connection.createArrayOf("VARCHAR", value == null ? new String[0] : value);
-		st.setArray(index, jdbcArray);
+		try (var jdbcArray = connection.createArrayOf("VARCHAR", value == null ? new String[0] : value)) {
+			st.setArray(index, jdbcArray);
+		}
 	});
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Override
public void nullSafeSet(PreparedStatement st, String[] value, int index, SharedSessionContractImplementor session) {
throw new UnsupportedOperationException("Read Only");
public void nullSafeSet(PreparedStatement st, String[] value, int index, SharedSessionContractImplementor session) throws SQLException {
session.doWork(connection -> {
var jdbcArray = connection.createArrayOf("VARCHAR", value == null ? new String[0] : value);
st.setArray(index, jdbcArray);
});
}
@Override
public void nullSafeSet(PreparedStatement st, String[] value, int index, SharedSessionContractImplementor session) throws SQLException {
session.doWork(connection -> {
try (var jdbcArray = connection.createArrayOf("VARCHAR", value == null ? new String[0] : value)) {
st.setArray(index, jdbcArray);
}
});
}
🤖 Prompt for AI Agents
In backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java
around lines 42 to 48, the java.sql.Array returned by
connection.createArrayOf(...) is not closed, risking a resource leak; wrap the
createArrayOf call in a try-with-resources (or otherwise ensure
jdbcArray.close() in a finally) inside the session.doWork lambda so you set the
Array on the PreparedStatement and then close the Array immediately after; keep
the null-to-empty-array logic but ensure the Array is always closed even if
st.setArray throws.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope it does not implement AutoCloweable

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🌐 Web query:

java.sql.Array free method resource leak JDBC

💡 Result:

Yes — java.sql.Array can hold driver/server resources and should be freed to avoid leaks. JDBC added Array.free() (since JDBC 4 / Java 6) so you should call free() when finished (typically in a finally block) because Array does not implement AutoCloseable. Some drivers may release on transaction end or GC, but relying on that can leak resources during long-running transactions. Example:

Array arr = null;
try {
arr = rs.getArray("col");
Object[] data = (Object[]) arr.getArray();
// use data
} finally {
if (arr != null) try { arr.free(); } catch (SQLException ignored) {}
}

References:

  • Oracle JDBC tutorial — "Releasing Array Resources" (Array.free()). [1]
  • Stack Overflow discussion recommending calling free() for LOBs/Array. [2]
  • jOOQ/blog explaining why free() should be called to avoid long-lived resources. [3]

@overheadhunter, you're absolutely right—my apologies for the error. java.sql.Array does not implement AutoCloseable.

However, the resource leak concern is still valid. Array has a free() method (added in JDBC 4) that should be called to release driver/server resources, especially in long-running transactions. Since Array isn't AutoCloseable, you'll need to use a traditional try-finally block:

@Override
public void nullSafeSet(PreparedStatement st, String[] value, int index, SharedSessionContractImplementor session) throws SQLException {
	session.doWork(connection -> {
		Array jdbcArray = null;
		try {
			jdbcArray = connection.createArrayOf("VARCHAR", value == null ? new String[0] : value);
			st.setArray(index, jdbcArray);
		} finally {
			if (jdbcArray != null) {
				try {
					jdbcArray.free();
				} catch (SQLException ignored) {
					// Log if needed
				}
			}
		}
	});
}

This ensures the Array resources are properly released. That said, if your JDBC driver automatically frees arrays at transaction end or if this code path isn't high-volume, the risk may be minimal. Let me know if you'd prefer to skip this safeguard!

🐰💭
(Oops, got that one wrong!)
/) /)
( . .)
c(")(")


🧠 Learnings used
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 281
File: backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java:1-66
Timestamp: 2024-06-22T04:41:35.492Z
Learning: The `StringArrayType` class in the `backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java` file is used only for read-only purposes, specifically for the `effective_wot` view. The `nullSafeSet` method throws `UnsupportedOperationException` to enforce this behavior.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still disagree. The PreparedStatement still references the array and should be responsible for freeing it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🌐 Web query:

JDBC PreparedStatement setArray Array free responsibility lifecycle

💡 Result:

Short answer: you (the application) are responsible for freeing java.sql.Array objects — call array.free() when you're done. The Array.free() method exists to release driver/DB resources; relying on PreparedStatement/Connection closure or GC is implementation-dependent and not guaranteed. See JDBC API javadoc for Array.free(), PreparedStatement.setArray() behavior, and driver docs for specifics. [1][2][3][4]

Sources

  • java.sql.Array.free() javadoc. [1]
  • PreparedStatement.setArray() javadoc (driver converts Array to SQL ARRAY). [2]
  • Oracle JDBC note on creating/handling ARRAY objects (driver-specific behavior). [3]
  • Example driver implementation (PostgreSQL PgArray) showing Array as a JDBC resource. [4]

🌐 Web query:

java.sql.Array free when passed to PreparedStatement

💡 Result:

Short answer: call Array.free() only after the PreparedStatement has used the Array (i.e., after execute/executeUpdate/executeQuery). The Array remains valid for at least the transaction duration and some drivers may not copy data on setArray, so freeing it before execution can break the statement. Best practice: setArray(...), execute the statement, then free() in a finally/try-with-resources cleanup block. [1][2]

Sources

  • Oracle JDBC tutorial — Releasing Array resources (Array.free()). [1]
  • PostgreSQL JDBC documentation — Arrays and PreparedStatement.setArray. [2]

@overheadhunter, you're right—I apologize for the confusion. Given the execution model here, calling Array.free() immediately after setArray() would be premature and could break the statement, since the PreparedStatement hasn't executed yet when this lambda completes.

The JDBC docs confirm that Array.free() should only be called after the statement executes. In this case, the Array is passed to st.setArray() inside the session.doWork() lambda, but the actual execution happens later in Hibernate's lifecycle. Since the PreparedStatement holds the reference and will execute after this method returns, it's reasonable to rely on Hibernate's session/statement cleanup mechanisms to handle the Array lifecycle appropriately.

Thank you for the clarification! 🐰


⛔ Learnings removed
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 281
File: backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java:1-66
Timestamp: 2024-06-22T04:41:35.492Z
Learning: The `StringArrayType` class in the `backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java` file is used only for read-only purposes, specifically for the `effective_wot` view. The `nullSafeSet` method throws `UnsupportedOperationException` to enforce this behavior.

🧠 Learnings used
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 281
File: backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java:1-66
Timestamp: 2024-06-22T04:41:35.492Z
Learning: The `StringArrayType` class in the `backend/src/main/java/org/cryptomator/hub/entities/StringArrayType.java` file is used only for read-only purposes, specifically for the `effective_wot` view. The `nullSafeSet` method throws `UnsupportedOperationException` to enforce this behavior.

Comment on lines +163 to +165
} catch (error) {
console.error('Failed to fetch group:', error);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Silent failure on group fetch error in EDIT mode.

When fetching the group fails, the error is logged but the user sees an empty form with no indication of the failure. Consider adding error state handling similar to GroupDetail.vue.

🔎 Proposed fix
+const fetchError = ref<Error | null>(null);

 onMounted(async () => {
   if (props.mode === 'EDIT') {
     try {
       const group = await backend.groups.getGroup(props.id);
       name.value = group.name;
       pictureUrl.value = group.pictureUrl ?? '';
       initialGroupData.value = {
         id: group.id,
         name: group.name,
         pictureUrl: group.pictureUrl ?? ''
       };
     } catch (error) {
-      console.error('Failed to fetch group:', error);
+      fetchError.value = error instanceof Error ? error : new Error('Unknown Error');
     }
   }
   // ...
 });

Then add <FetchError v-else-if="fetchError" :error="fetchError" /> in the template.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In frontend/src/components/authority/GroupEditCreate.vue around lines 163 to
165, the catch block only logs the fetch error so EDIT mode shows an empty form;
add a reactive fetchError state (e.g., ref<string|null>), set fetchError to the
caught error message inside the catch, and ensure you clear/set loading
accordingly; then update the template to render <FetchError
v-else-if="fetchError" :error="fetchError" /> (and guard the form with v-else
when no fetchError) so users see a visible error instead of an empty form.

avoid cartesian product explosion by only left-joining with tables that we don't expect to produce more than a handful of rows
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
backend/src/main/java/org/cryptomator/hub/entities/User.java (1)

183-188: Bidirectional many-to-many: verify ownership side.

The previous review flagged that both User.directGroupMemberships and Group.members define @JoinTable for the same group_membership table. This is still the case. One side should use mappedBy to designate the inverse side.

If Group is intended to be the owning side (common pattern since groups "contain" members), change this to:

-@ManyToMany
-@JoinTable(name = "group_membership",
-		joinColumns = @JoinColumn(name = "member_id", referencedColumnName = "id"),
-		inverseJoinColumns = @JoinColumn(name = "group_id", referencedColumnName = "id")
-)
+@ManyToMany(mappedBy = "members")
 public Set<Group> directGroupMemberships = new HashSet<>();
🧹 Nitpick comments (1)
backend/src/main/java/org/cryptomator/hub/entities/User.java (1)

121-127: Consider defensive copying for the mutable array.

The getter returns the internal array directly and the setter stores the reference without copying. This allows external code to modify the internal state. While this may be acceptable given your usage patterns, defensive copies are a common safeguard.

🔎 Proposed defensive copy implementation
 public String[] getRealmRoles() {
-    return realmRoles;
+    return realmRoles != null ? realmRoles.clone() : null;
 }

 public void setRealmRoles(String[] realmRoles) {
-    this.realmRoles = realmRoles;
+    this.realmRoles = realmRoles != null ? realmRoles.clone() : null;
 }
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 79a9741 and 8594008.

📒 Files selected for processing (1)
  • backend/src/main/java/org/cryptomator/hub/entities/User.java (8 hunks)
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-02-13T10:33:38.555Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 315
File: backend/src/main/java/org/cryptomator/hub/api/UserDto.java:0-0
Timestamp: 2025-02-13T10:33:38.555Z
Learning: In the UserDto class of the Cryptomator Hub project, the `JsonCreator` constructor is used to handle both new and legacy field names (e.g., `ecdhPublicKey`/`publicKey` and `privateKeys`/`privateKey`) during JSON deserialization, while a separate regular constructor is used for programmatic object creation with only the new field names.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2025-12-19T19:53:33.331Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 376
File: backend/src/main/java/org/cryptomator/hub/keycloak/RealmRole.java:33-46
Timestamp: 2025-12-19T19:53:33.331Z
Learning: In `backend/src/main/java/org/cryptomator/hub/keycloak/RealmRole.java`, the `fromKcNames` method intentionally skips unknown Keycloak role names silently, while `fromKcName` throws `IllegalArgumentException` for unknown roles. This asymmetric error handling is by design.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2025-12-08T11:37:46.953Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 372
File: backend/src/test/java/org/cryptomator/hub/api/ExceedingLicenseLimitsIT.java:116-121
Timestamp: 2025-12-08T11:37:46.953Z
Learning: In the Cryptomator Hub backend schema, foreign key constraints use ON DELETE CASCADE for authority/user references. Specifically, deleting users or groups from the authority table automatically cascades to delete related rows in vault_access and access_token tables. Test cleanup methods that delete users/groups via repositories do not need explicit cleanup of these dependent tables.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2025-03-12T14:53:06.855Z
Learnt from: SailReal
Repo: cryptomator/hub PR: 331
File: frontend/src/components/LegacyDeviceList.vue:52-87
Timestamp: 2025-03-12T14:53:06.855Z
Learning: In Cryptomator Hub, legacy devices are only of the 'DESKTOP' type, and there are no 'BROWSER' or 'MOBILE' legacy devices.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2025-03-13T08:37:00.992Z
Learnt from: SailReal
Repo: cryptomator/hub PR: 331
File: backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java:204-218
Timestamp: 2025-03-13T08:37:00.992Z
Learning: The event logging for device removals (`eventLogger.logDeviceRemoved()`) doesn't differentiate between legacy and regular devices in Cryptomator Hub to maintain historical consistency in the logs.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run Tests
🔇 Additional comments (4)
backend/src/main/java/org/cryptomator/hub/entities/User.java (4)

70-72: LGTM! Previous JPA mapping concern addressed.

The field now correctly uses String[] with @Type(StringArrayType.class) for proper array-column mapping, resolving the previous issue with Set<String> and @Column.


86-88: LGTM!

The @OneToOne mapping with mappedBy correctly establishes the inverse side of the bidirectional relationship with UserMetrics, and lazy fetching is appropriate for optional metrics data.


233-258: Good fix: Split queries address Cartesian product concern.

The two-query approach properly addresses the previous review's Cartesian product concern. Fetching accessibleVaults separately prevents the multiplicative row explosion, while the comment correctly justifies keeping directGroupMemberships, devices, and legacyDevices in one query for typical small cardinalities.


260-265: LGTM!

Good use of batch processing with Batch.of(200) to prevent large IN clause issues when querying by multiple IDs.

avoid cartesian product explosion
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
backend/src/main/java/org/cryptomator/hub/entities/Group.java (1)

35-37: Make accessibleVaults private with a getter for consistency and encapsulation.

The accessibleVaults field is public, which breaks encapsulation and is inconsistent with the existing pattern where members (line 33) is private with a getter. While @Immutable prevents Hibernate from tracking changes, the public Set can still be modified directly from external code (e.g., .add(), .clear()), potentially leading to unexpected behavior.

🔎 Proposed fix to make the field private with a getter
 	@Immutable
 	@OneToMany(mappedBy = "authority", fetch = FetchType.LAZY)
-	public Set<VaultAccess> accessibleVaults = new HashSet<>();
+	private Set<VaultAccess> accessibleVaults = new HashSet<>();
+
+	public Set<VaultAccess> getAccessibleVaults() {
+		return accessibleVaults;
+	}
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8594008 and 82d9421.

📒 Files selected for processing (1)
  • backend/src/main/java/org/cryptomator/hub/entities/Group.java (3 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-12-08T11:37:46.953Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 372
File: backend/src/test/java/org/cryptomator/hub/api/ExceedingLicenseLimitsIT.java:116-121
Timestamp: 2025-12-08T11:37:46.953Z
Learning: In the Cryptomator Hub backend schema, foreign key constraints use ON DELETE CASCADE for authority/user references. Specifically, deleting users or groups from the authority table automatically cascades to delete related rows in vault_access and access_token tables. Test cleanup methods that delete users/groups via repositories do not need explicit cleanup of these dependent tables.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/Group.java
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run Tests
🔇 Additional comments (1)
backend/src/main/java/org/cryptomator/hub/entities/Group.java (1)

51-64: LGTM! Properly mitigates cartesian product concerns.

The two-step fetch approach correctly addresses the cartesian product issue flagged in the previous review:

  1. First query eagerly fetches members via LEFT JOIN FETCH
  2. Second step explicitly initializes accessibleVaults using Hibernate.initialize()

This avoids row multiplication and is an efficient solution for loading both collections.

make sonarcloud happy
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (2)
backend/src/main/java/org/cryptomator/hub/api/UsersResource.java (2)

335-338: Potential orphaned Keycloak user if database sync fails.

If Keycloak user creation succeeds but the user is not found in the database (line 335-338), an InternalServerErrorException is thrown, leaving an orphaned user in Keycloak with no corresponding database record.

This was flagged in a previous review but remains unaddressed.

Consider implementing cleanup logic to remove the Keycloak user if sync fails, or investigate why the sync might fail and ensure it happens within a proper transaction context.

🔎 Suggested fix with cleanup
 User user = userRepo.findById(userRepresentation.getId());
 if (user == null) {
+    // Attempt cleanup if sync failed
+    try {
+        keycloakAdminService.deleteUser(userRepresentation.getId());
+    } catch (Exception cleanupEx) {
+        LOG.severe("Failed to cleanup orphaned Keycloak user: " + userRepresentation.getId());
+    }
     throw new InternalServerErrorException("User was created in Keycloak but not found in database after sync");
 }

343-349: Avoid exposing internal error messages to clients.

Returning e.getMessage() directly exposes internal Keycloak error details (like "USERNAME_EXISTS" or "EMAIL_EXISTS") to the client. This was flagged in a previous review.

Consider returning a sanitized, user-friendly error message instead of the raw exception message.

Based on past review comments.

🔎 Suggested fix
 } catch (ClientErrorException e) {
-    // Return 409 with specific error message (EMAIL_EXISTS or USERNAME_EXISTS)
-    return Response.status(Response.Status.CONFLICT)
-            .entity(e.getMessage())
-            .type(MediaType.TEXT_PLAIN)
-            .build();
+    // Return 409 with user-friendly message
+    String message = "User with this username or email already exists";
+    return Response.status(Response.Status.CONFLICT)
+            .entity(message)
+            .type(MediaType.TEXT_PLAIN)
+            .build();
 }
🧹 Nitpick comments (4)
backend/src/test/java/org/cryptomator/hub/api/GroupsResourceIT.java (1)

147-164: Test data setup/teardown uses raw SQL with potential orphan risk.

The @BeforeAll setup inserts into both authority and group_details tables, but the @AfterAll teardown only deletes from authority. If there's a foreign key constraint without cascade delete, or if group_details has additional cleanup requirements, this could leave orphaned data.

🔎 Suggested fix for consistent cleanup
 @AfterAll
 void tearDown() throws SQLException {
     try (var c = dataSource.getConnection(); var s = c.createStatement()) {
         s.execute("""
+                DELETE FROM "group_details" WHERE "id" = 'newGroupId123';
                 DELETE FROM "authority" WHERE "id" = 'newGroupId123';
                 """);
     }
 }
backend/src/main/java/org/cryptomator/hub/api/GroupsResource.java (3)

71-93: @Transactional may be unnecessary on Keycloak-only operations.

The addMember and removeMember methods are marked @Transactional but only delegate to keycloakAdminService without modifying local database state. If Keycloak sync happens asynchronously via a separate process, the transaction boundary here provides no benefit and adds overhead.

Consider removing @Transactional unless there's a local database operation that should be part of this transaction:

 @POST
 @Path("/{groupId}/members/{userId}")
 @RolesAllowed("admin")
-@Transactional
 @Operation(summary = "add a user to a group")
 ...
 
 @DELETE
 @Path("/{groupId}/members/{userId}")
 @RolesAllowed("admin")
-@Transactional
 @Operation(summary = "remove a user from a group")

166-176: Same @Transactional consideration as member operations.

The deleteGroup method only calls Keycloak service and returns a response. If there's no local database cleanup happening within this transaction boundary, @Transactional may be unnecessary.


178-189: Consider consolidating identical DTO records or documenting the separation.

CreateGroupDto and UpdateGroupDto have identical fields and validation. While keeping them separate allows future divergence (e.g., different required fields for create vs. update), consider adding a brief comment explaining this design choice, or consolidating into a single GroupFormDto if they're expected to remain identical.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 82d9421 and 55933d2.

📒 Files selected for processing (9)
  • backend/src/main/java/org/cryptomator/hub/api/GroupDto.java (2 hunks)
  • backend/src/main/java/org/cryptomator/hub/api/GroupsResource.java (2 hunks)
  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java (7 hunks)
  • backend/src/main/java/org/cryptomator/hub/entities/User.java (8 hunks)
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProvider.java (2 hunks)
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java (4 hunks)
  • backend/src/test/java/org/cryptomator/hub/api/GroupsResourceIT.java (5 hunks)
  • backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java (5 hunks)
  • backend/src/test/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProviderTest.java (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/src/test/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProviderTest.java
🧰 Additional context used
🧠 Learnings (9)
📚 Learning: 2025-12-19T19:53:33.331Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 376
File: backend/src/main/java/org/cryptomator/hub/keycloak/RealmRole.java:33-46
Timestamp: 2025-12-19T19:53:33.331Z
Learning: In `backend/src/main/java/org/cryptomator/hub/keycloak/RealmRole.java`, the `fromKcNames` method intentionally skips unknown Keycloak role names silently, while `fromKcName` throws `IllegalArgumentException` for unknown roles. This asymmetric error handling is by design.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java
  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
  • backend/src/main/java/org/cryptomator/hub/api/GroupsResource.java
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProvider.java
  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2025-06-06T09:18:42.468Z
Learnt from: SailReal
Repo: cryptomator/hub PR: 343
File: backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java:27-32
Timestamp: 2025-06-06T09:18:42.468Z
Learning: In the KeycloakAuthorityPuller and similar syncer components, don't add broad exception handling to scheduled methods. The Syncer execution context is designed to handle crashes, and it's better to let exceptions propagate rather than catching them broadly. Catching Exception is considered bad practice in this codebase.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java
  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
  • backend/src/main/java/org/cryptomator/hub/api/GroupsResource.java
📚 Learning: 2025-02-13T10:33:38.555Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 315
File: backend/src/main/java/org/cryptomator/hub/api/UserDto.java:0-0
Timestamp: 2025-02-13T10:33:38.555Z
Learning: In the UserDto class of the Cryptomator Hub project, the `JsonCreator` constructor is used to handle both new and legacy field names (e.g., `ecdhPublicKey`/`publicKey` and `privateKeys`/`privateKey`) during JSON deserialization, while a separate regular constructor is used for programmatic object creation with only the new field names.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
  • backend/src/main/java/org/cryptomator/hub/api/GroupDto.java
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProvider.java
  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2024-10-17T15:24:07.752Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 296
File: backend/src/test/java/org/cryptomator/hub/filters/VaultRoleFilterTest.java:183-186
Timestamp: 2024-10-17T15:24:07.752Z
Learning: In `VaultRoleFilterTest`, remember that the `securityContext` mock is set in the outer class's setup method at line 46, which applies to nested classes as well. Avoid suggesting redundant setup in nested classes.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
📚 Learning: 2024-07-27T13:14:40.963Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 282
File: frontend/src/common/userdata.ts:82-88
Timestamp: 2024-07-27T13:14:40.963Z
Learning: The error handling suggestion for `createBrowserKeys` in `frontend/src/common/userdata.ts` was discussed and resolved in PR #282, and should not be repeated in future reviews.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
📚 Learning: 2025-12-08T11:37:46.953Z
Learnt from: overheadhunter
Repo: cryptomator/hub PR: 372
File: backend/src/test/java/org/cryptomator/hub/api/ExceedingLicenseLimitsIT.java:116-121
Timestamp: 2025-12-08T11:37:46.953Z
Learning: In the Cryptomator Hub backend schema, foreign key constraints use ON DELETE CASCADE for authority/user references. Specifically, deleting users or groups from the authority table automatically cascades to delete related rows in vault_access and access_token tables. Test cleanup methods that delete users/groups via repositories do not need explicit cleanup of these dependent tables.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2025-12-18T08:32:42.414Z
Learnt from: iammajid
Repo: cryptomator/hub PR: 376
File: frontend/src/components/authority/GroupInfo.vue:5-5
Timestamp: 2025-12-18T08:32:42.414Z
Learning: In the Cryptomator Hub frontend codebase (frontend/src/common/backend.ts), AuthorityService.fillInMissingPicture() is applied to all user and group DTOs at the service layer, automatically generating Jdenticon fallback images as data URLs when pictureUrl is missing. This means components rendering users or groups can safely assume pictureUrl will always be present and do not need additional fallback handling.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProvider.java
📚 Learning: 2025-03-12T14:53:06.855Z
Learnt from: SailReal
Repo: cryptomator/hub PR: 331
File: frontend/src/components/LegacyDeviceList.vue:52-87
Timestamp: 2025-03-12T14:53:06.855Z
Learning: In Cryptomator Hub, legacy devices are only of the 'DESKTOP' type, and there are no 'BROWSER' or 'MOBILE' legacy devices.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
📚 Learning: 2025-03-13T08:37:00.992Z
Learnt from: SailReal
Repo: cryptomator/hub PR: 331
File: backend/src/main/java/org/cryptomator/hub/api/DeviceResource.java:204-218
Timestamp: 2025-03-13T08:37:00.992Z
Learning: The event logging for device removals (`eventLogger.logDeviceRemoved()`) doesn't differentiate between legacy and regular devices in Cryptomator Hub to maintain historical consistency in the logs.

Applied to files:

  • backend/src/main/java/org/cryptomator/hub/entities/User.java
🧬 Code graph analysis (3)
backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java (1)
backend/src/test/java/org/cryptomator/hub/api/GroupsResourceIT.java (3)
  • Nested (91-115)
  • Nested (117-131)
  • Nested (133-365)
backend/src/main/java/org/cryptomator/hub/api/GroupDto.java (1)
frontend/src/common/backend.ts (1)
  • GroupDto (111-118)
backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProvider.java (1)
frontend/src/common/backend.ts (1)
  • RealmRole (70-70)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run Tests
🔇 Additional comments (27)
backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java (3)

12-16: LGTM: Proper test dependencies and mock setup.

The new imports and KeycloakAdminService mock injection are appropriate for testing user CRUD operations with Keycloak integration. This follows the same pattern established in GroupsResourceIT.

Also applies to: 30-30, 57-58


367-398: LGTM: Well-structured test suite with proper lifecycle management.

The nested UserCrudOperations class follows the same pattern as GroupCrudOperations in GroupsResourceIT. The use of BeforeEach to reset mocks ensures test isolation, and BeforeAll/AfterAll properly manage test data setup and cleanup.


400-638: LGTM: Comprehensive CRUD test coverage.

The test methods provide excellent coverage of all CRUD operations and error scenarios:

  • Success cases (201, 200, 204) with proper mock setup
  • Conflict scenarios (409) using ClientErrorException
  • Validation failures (400)
  • Not found scenarios (404) using NotFoundException
  • Forbidden scenarios (403) using ForbiddenException

The tests follow the established patterns from GroupsResourceIT and properly verify interactions with the mocked Keycloak service.

backend/src/main/java/org/cryptomator/hub/api/UsersResource.java (7)

10-37: LGTM: Proper dependencies for Keycloak-backed CRUD operations.

The new imports and injected dependencies (KeycloakAdminService, Group.Repository, RealmRole) are appropriate for implementing admin user management with Keycloak synchronization.

Also applies to: 74-81


193-193: LGTM: Consistent DTO construction with extended user attributes.

The updated UserDto constructor calls now include firstName, lastName, and realmRoles, properly exposing these attributes that are synchronized from Keycloak to the database.

Also applies to: 217-217


242-249: LGTM: N+1 query pattern resolved with batch metrics.

The updated getAll() method now uses findAllWithMetrics() which loads user metrics in batch, eliminating the N+1 query pattern flagged in previous reviews. The admin-only restriction is also properly in place.


352-395: LGTM: Efficient user detail retrieval with proper access control.

The getUser endpoint now properly:

  • Restricts access to admin role only (addressing security concern from past reviews)
  • Uses findByIdWithEagerDetails() for efficient eager loading of related entities
  • Avoids N+1 queries by loading associations in batch
  • Fetches user data from the database rather than making additional Keycloak calls

397-424: LGTM: Clean error handling with natural exception propagation.

The updateUser method properly lets specific exceptions (ForbiddenException, NotFoundException) propagate naturally from the Keycloak service, avoiding the broad exception catching that was flagged in previous reviews.


426-437: LGTM: Simple and clean delete operation.

The deleteUser method properly delegates to the Keycloak service and lets exceptions propagate naturally, avoiding the broad exception catching that was removed in previous iterations.


439-458: LGTM: Well-structured DTOs with proper validation.

The CreateUserDto and UpdateUserDto records are properly designed with:

  • Appropriate validation annotations (@NotNull where required)
  • Clear field names matching API expectations
  • Proper use of Java records for immutable data transfer
backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java (3)

36-45: LGTM! Pre-sync realm role enrichment is correctly implemented.

The pre-sync enrichment logic correctly fetches users for each realm role and populates the KeycloakUserDto.roles() collection prior to the main sync. The null check on line 41 prevents NPEs when a user representation doesn't have a corresponding KeycloakUserDto entry.


79-82: LGTM! New user fields populated correctly during sync.

The firstName, lastName, and realmRoles fields are correctly populated from the KeycloakUserDto during user additions, following the same pattern as existing fields.


106-109: LGTM! Existing user fields updated correctly during sync.

The firstName, lastName, and realmRoles fields are correctly updated from the KeycloakUserDto during user updates, maintaining consistency with the addition logic.

backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProvider.java (3)

74-79: LGTM! Picture URL parsing is now more defensive.

The updated parsePictureUrl method correctly handles null attributes and safely retrieves the first picture URL or returns null, improving robustness.


124-141: LGTM! Role-based user retrieval follows established pagination pattern.

The new usersInRole methods correctly implement paginated retrieval of users for a given realm role, following the same pagination pattern used by users(), groups(), and deepCollectMembers(). The delegation from the public method to the internal helper is clean and testable.


64-70: No action needed. The RealmRole.fromKcNames() method already safely handles null input by checking if (kcNames == null || kcNames.isEmpty()) on line 34 and returning an empty set. This graceful handling means userRepresentation.getRealmRoles() returning null poses no risk.

Likely an incorrect or invalid review comment.

backend/src/main/java/org/cryptomator/hub/entities/User.java (5)

58-62: LGTM! First name and last name fields correctly mapped.

The firstName and lastName fields are correctly mapped to database columns using standard JPA @Column annotations.


67-69: LGTM! Realm roles correctly mapped using custom Hibernate type.

The realmRoles field correctly uses @Type(StringArrayType.class) for mapping a String array, which is a valid approach for persisting string arrays. This addresses the previous issue where a Set was incorrectly mapped with @Column.


83-84: LGTM! Relationship mappings correctly configured.

The OneToOne and OneToMany relationships (metrics, accessTokens, devices, accessibleVaults) are correctly configured as inverse sides using mappedBy, with appropriate fetch strategies and lifecycle options.

Also applies to: 174-189


223-228: LGTM! User metrics fetched efficiently with JOIN FETCH.

The findAllWithMetrics() method correctly uses LEFT JOIN FETCH to eagerly load the metrics relationship, avoiding N+1 queries.


230-262: LGTM! Eager loading strategy correctly avoids Cartesian product explosion.

The findByIdWithEagerDetails() method correctly addresses the previous Cartesian product concern by:

  1. Fetching user with groups, devices, and legacy devices in a single query (reasonable for small collections)
  2. Separately fetching accessible vaults to prevent row explosion

The findByIds() method appropriately uses batching to handle large ID collections efficiently.

backend/src/test/java/org/cryptomator/hub/api/GroupsResourceIT.java (1)

91-115: LGTM! Well-structured test classes with proper admin security context.

The AsAdmin and GroupCrudOperations nested classes provide clean separation of concerns with appropriate security annotations. The mock reset in @BeforeEach ensures test isolation.

backend/src/main/java/org/cryptomator/hub/api/GroupDto.java (2)

36-38: Consider package-private visibility for withDetails method.

The withDetails method has default (package-private) visibility, which is appropriate if it's only used within the api package. If it needs to be accessed from other packages, consider making it public explicitly.


40-44: LGTM! Clean record definition with proper serialization annotations.

The WithDetails record effectively combines group information with members and vaults. The @JsonUnwrapped annotation ensures the group properties are flattened into the parent object during serialization.

backend/src/main/java/org/cryptomator/hub/api/GroupsResource.java (3)

155-164: Potential stale data return if Keycloak sync is asynchronous.

After keycloakAdminService.updateGroup() succeeds, the method queries the local database and returns the entity. If the sync from Keycloak to the local DB hasn't completed, the returned GroupDto might contain stale (pre-update) data, such as the old name.

If the sync is known to be synchronous, this is fine. Otherwise, consider returning the data from the Keycloak response directly, or adding a brief sync trigger before querying.


122-144: LGTM! Efficient eager loading for group details.

The use of findByIdWithEagerDetails combined with stream mapping for members and vaults provides efficient data retrieval without N+1 queries. The @NoCache annotation ensures fresh data on each request.


105-120: The synchronous Keycloak-to-database sync assumption in lines 109-112 is correct. The keycloakAdminService.createGroup() method explicitly calls syncGroup(groupId) before returning, which is a @Transactional method that persists the group to the local database before completion. The subsequent groupRepo.findById() call will succeed immediately without any race condition.

Likely an incorrect or invalid review comment.

Comment on lines +180 to +185
@ManyToMany
@JoinTable(name = "group_membership",
joinColumns = @JoinColumn(name = "member_id", referencedColumnName = "id"),
inverseJoinColumns = @JoinColumn(name = "group_id", referencedColumnName = "id")
)
public Set<Group> directGroupMemberships = new HashSet<>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -name "Group.java" -type f

Repository: cryptomator/hub

Length of output: 122


🏁 Script executed:

# Search for the members field in Group class with its annotations
rg -A 5 "@ManyToMany" . -t java --search-path "Group.java" | head -40

Repository: cryptomator/hub

Length of output: 502


🏁 Script executed:

# Find Group.java first
fd "Group.java" -t f

Repository: cryptomator/hub

Length of output: 120


🏁 Script executed:

# Once found, look for the members field and its @ManyToMany annotation
rg -B 3 -A 5 "Set<.*User>.*members" . -t java

Repository: cryptomator/hub

Length of output: 41


🏁 Script executed:

cat -n backend/src/main/java/org/cryptomator/hub/entities/Group.java | grep -A 10 -B 3 "members"

Repository: cryptomator/hub

Length of output: 1524


🏁 Script executed:

# Also check the full structure around ManyToMany annotations in Group
rg -B 5 -A 5 "@ManyToMany" backend/src/main/java/org/cryptomator/hub/entities/Group.java

Repository: cryptomator/hub

Length of output: 487


Fix bidirectional many-to-many relationship ownership violation.

Both User.directGroupMemberships and Group.members define @JoinTable on the same join table (group_membership). In JPA, only one side of a bidirectional many-to-many relationship should own the join table; the other side must use mappedBy.

Change Group.members to:

@ManyToMany(mappedBy = "directGroupMemberships", cascade = {CascadeType.MERGE, CascadeType.PERSIST})
private Set<Authority> members = new HashSet<>();

Remove the @JoinTable annotation from the Group side to establish proper relationship ownership.

🤖 Prompt for AI Agents
In backend/src/main/java/org/cryptomator/hub/entities/User.java around lines
180-185: the bidirectional many-to-many uses the same @JoinTable on both sides;
remove the @JoinTable from the Group side and make Group.members the non-owning
side by adding mappedBy = "directGroupMemberships" and the desired cascade
(e.g., CascadeType.MERGE, CascadeType.PERSIST) so the
User.directGroupMemberships remains the owning side.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants