Skip to content

Disabled users should not require a license seat#428

Merged
tobihagemann merged 7 commits intodevelopfrom
feature/disabled-user-seats
Mar 26, 2026
Merged

Disabled users should not require a license seat#428
tobihagemann merged 7 commits intodevelopfrom
feature/disabled-user-seats

Conversation

@tobihagemann
Copy link
Copy Markdown
Member

@tobihagemann tobihagemann commented Mar 24, 2026

Fixes #427

Syncs the enabled property from Keycloak's UserRepresentation into Hub's user entity. Disabled users are excluded from license seat calculations and hidden from non-admin views (authority search, vault member lists, access grant lists, group member queries). Admins can still see disabled users in the user management page, where they're marked with a "Disabled" badge. Admins can also enable or disable users directly from the user detail page.

Changes:

  • New Flyway migration V25 adding enabled column to user_details
  • Keycloak syncer pulls and updates the enabled flag on every sync cycle
  • All seat-counting queries in EffectiveVaultAccess filter on u.enabled
  • Authority search and vault member endpoints filter out disabled users
  • UserDto exposes the enabled field to the frontend
  • Admin user list and user detail pages show a "Disabled" badge
  • New PUT /users/{id}/enabled admin endpoint to toggle a user's enabled state in Keycloak
  • User detail page ellipsis menu shows "Disable" (with confirmation dialog) or "Enable" action

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 24, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a persisted boolean enabled flag to User and syncs it from Keycloak. JPQL named queries and repository-level queries were updated to exclude disabled users from seat/occupancy calculations and relevant result sets. DTOs and mapping layers (KeycloakUserDto, UserDto, Authority mappings) were extended to carry enabled. API/service changes include a new admin endpoint PUT /users/{id}/enabled and KeycloakAdminService.setUserEnabled that updates Keycloak and syncs the local user. Vault/member and authority search endpoints now filter out disabled users. Frontend updates add enable/disable UI, a confirmation dialog, a UserService.setUserEnabled call, and new i18n keys. A Flyway migration adds the DB column and tests were adjusted/added.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Suggested reviewers

  • SailReal
  • overheadhunter
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely summarizes the primary change: disabling users so they don't consume license seats, which is the main objective of this changeset.
Linked Issues check ✅ Passed The PR fully implements all coding requirements from #427: adds enabled property [#427], syncs from Keycloak [#427], excludes disabled users from seat counts [#427], hides them in non-admin views [#427], and provides admin controls [#427].
Out of Scope Changes check ✅ Passed All changes are directly related to implementing the disabled user feature: syncing enabled state, filtering queries, UI updates, and new endpoints. UserDeleteDialog.vue type update is a related refactoring to use shared backend types.
Description check ✅ Passed The PR description comprehensively explains the changes, their purpose, and their scope, directly addressing the changeset across backend and frontend components.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/disabled-user-seats

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
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.

🧹 Nitpick comments (6)
backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java (1)

606-621: Consider adding the symmetric enable-path test as well.

Line 607 currently validates only "enabled": false; a matching "enabled": true case would better protect request-to-service mapping regressions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java` around
lines 606 - 621, Add a symmetric test for the enable-path to mirror the existing
disable test: create a new test (e.g., testSetUserEnabledEnableSuccess) in
UsersResourceIT that stubs keycloakAdminService.setUserEnabled("user1", true)
with Mockito.doNothing(), sends a PUT to "/users/user1/enabled" with a JSON body
{"enabled": true}, asserts a 204 response, and verifies
keycloakAdminService.setUserEnabled("user1", true) was invoked; this ensures the
request-to-service mapping is exercised for the true case as well.
frontend/src/common/backend.ts (1)

529-531: Consider adding error handling for expected failure cases.

The setUserEnabled method doesn't handle expected error responses (e.g., 404 if user not found, 403 if not authorized). Other methods in UserService use rethrowAndConvertIfExpected to convert HTTP errors to typed BackendError subclasses.

Proposed fix
  public async setUserEnabled(userId: string, enabled: boolean): Promise<void> {
-   await axiosAuth.put(`/users/${userId}/enabled`, { enabled });
+   await axiosAuth.put(`/users/${userId}/enabled`, { enabled })
+     .catch((error) => rethrowAndConvertIfExpected(error, 403, 404));
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/common/backend.ts` around lines 529 - 531, The setUserEnabled
method currently calls axiosAuth.put(`/users/${userId}/enabled`, { enabled })
without converting HTTP errors into typed BackendError instances; wrap the
request in the same error-handling pattern used elsewhere by calling
rethrowAndConvertIfExpected around the axiosAuth.put call (preserve the
signature of public async setUserEnabled(userId: string, enabled: boolean):
Promise<void>) so that 404/403 and other expected responses are converted to the
appropriate BackendError subclasses instead of leaking raw HTTP errors.
backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java (1)

34-36: Filter enabled status at the query level instead of in memory.

The search method filters disabled users in memory after querying, which is inefficient for large result sets. Like other User-related queries in the codebase (e.g., User.requiringAccessGrant, User.getEffectiveGroupUsers), the filtering should be applied at the query level in Authority.byName:

SELECT DISTINCT a FROM Authority a 
WHERE LOWER(a.name) LIKE :name 
AND (NOT (a.type = 'USER') OR a.enabled = true)

Alternatively, create a new named query dedicated to searching authorities with disabled users excluded.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java` around
lines 34 - 36, The search method currently filters disabled User instances in
memory; update the query layer instead by changing authorityRepo.byName (or
adding a new repository method, e.g., byNameExcludingDisabledUsers) so the
JPQL/named query excludes disabled users (logic: WHERE LOWER(a.name) LIKE :name
AND (a.type <> 'USER' OR a.enabled = true)); then have AuthorityResource.search
call the updated repository method (AuthorityResource.search ->
authorityRepo.byName... or authorityRepo.byNameExcludingDisabledUsers) and keep
mapping to AuthorityDto.fromEntity(authority, withMemberSize) unchanged.
backend/src/main/java/org/cryptomator/hub/api/UsersResource.java (1)

474-477: Consider using Boolean wrapper type for explicit null handling.

The boolean primitive in SetUserEnabledDto will default to false if the enabled field is omitted from the JSON payload. This could lead to accidentally disabling users if clients send malformed requests. Using @NotNull Boolean enabled would reject requests missing the field.

♻️ Proposed fix
 public record SetUserEnabledDto(
-		`@JsonProperty`("enabled") boolean enabled
+		`@JsonProperty`("enabled") `@NotNull` Boolean enabled
 ) {
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/main/java/org/cryptomator/hub/api/UsersResource.java` around
lines 474 - 477, The DTO SetUserEnabledDto uses a primitive boolean which
defaults to false when the JSON key is missing; change the field to the wrapper
type and enforce not-null by replacing "boolean enabled" with "Boolean enabled"
and annotate it with `@NotNull` (and keep `@JsonProperty`("enabled")) so missing
fields are rejected by validation; update imports for javax/
jakarta.validation.@NotNull as appropriate and ensure any controller method
handling this DTO has `@Valid` on the parameter so validation is triggered.
frontend/src/components/authority/UserDetail.vue (1)

125-132: Consider surfacing errors to the user.

The enableUser function catches errors and logs them to the console, but the user receives no feedback when enabling fails. Consider showing an error notification or inline message similar to the disable dialog's error display.

💡 Proposed improvement
+const enableUserError = ref<Error | null>(null);
+
 const enableUser = async () => {
+  enableUserError.value = null;
   try {
     await backend.users.setUserEnabled(props.id, true);
     user.value = await backend.users.getUser(props.id);
   } catch (error) {
     console.error('Enabling user failed.', error);
+    enableUserError.value = error instanceof Error ? error : new Error('Unknown Error');
+    // Display error via toast or inline message
   }
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/authority/UserDetail.vue` around lines 125 - 132, The
enableUser function swallows errors to console only; update it to surface
failures to the user by catching the error thrown by
backend.users.setUserEnabled(props.id, true) and then invoking the same UI error
path used by the disable flow (e.g., call the notification/toast helper or set
the component's error state used by the disable dialog) so the user sees an
inline message or toast; keep the existing user.value refresh logic (user.value
= await backend.users.getUser(props.id)) in the success path and ensure the
error branch uses the shared error display (or emits an error event) with the
caught error for context.
frontend/src/components/authority/UserDisableDialog.vue (1)

27-33: Consider a fallback for missing pictureUrl.

The pictureUrl is defined as optional in the User interface, but it's used directly in the img tag. If pictureUrl is undefined, this could result in a broken image. Based on learnings, AuthorityService.fillInMissingPicture() should populate this at the service layer, but a defensive fallback would ensure robustness.

💡 Suggested fallback
-                        <img :src="user.pictureUrl" class="w-8 h-8 rounded-full border" />
+                        <img v-if="user.pictureUrl" :src="user.pictureUrl" class="w-8 h-8 rounded-full border" />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/authority/UserDisableDialog.vue` around lines 27 -
33, The img uses user.pictureUrl directly which is optional on User; update the
template in UserDisableDialog.vue to defensively use a fallback when
user.pictureUrl is missing (e.g., render a default avatar URL or a scoped
placeholder component) or conditionally render a styled initials/avatar element;
ensure this complements AuthorityService.fillInMissingPicture by referencing the
same user.pictureUrl property and keep the img src binding to a computed/derived
value (e.g., a local computed like displayPictureUrl) so the fallback logic is
centralized and easy to test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java`:
- Around line 34-36: The search method currently filters disabled User instances
in memory; update the query layer instead by changing authorityRepo.byName (or
adding a new repository method, e.g., byNameExcludingDisabledUsers) so the
JPQL/named query excludes disabled users (logic: WHERE LOWER(a.name) LIKE :name
AND (a.type <> 'USER' OR a.enabled = true)); then have AuthorityResource.search
call the updated repository method (AuthorityResource.search ->
authorityRepo.byName... or authorityRepo.byNameExcludingDisabledUsers) and keep
mapping to AuthorityDto.fromEntity(authority, withMemberSize) unchanged.

In `@backend/src/main/java/org/cryptomator/hub/api/UsersResource.java`:
- Around line 474-477: The DTO SetUserEnabledDto uses a primitive boolean which
defaults to false when the JSON key is missing; change the field to the wrapper
type and enforce not-null by replacing "boolean enabled" with "Boolean enabled"
and annotate it with `@NotNull` (and keep `@JsonProperty`("enabled")) so missing
fields are rejected by validation; update imports for javax/
jakarta.validation.@NotNull as appropriate and ensure any controller method
handling this DTO has `@Valid` on the parameter so validation is triggered.

In `@backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java`:
- Around line 606-621: Add a symmetric test for the enable-path to mirror the
existing disable test: create a new test (e.g., testSetUserEnabledEnableSuccess)
in UsersResourceIT that stubs keycloakAdminService.setUserEnabled("user1", true)
with Mockito.doNothing(), sends a PUT to "/users/user1/enabled" with a JSON body
{"enabled": true}, asserts a 204 response, and verifies
keycloakAdminService.setUserEnabled("user1", true) was invoked; this ensures the
request-to-service mapping is exercised for the true case as well.

In `@frontend/src/common/backend.ts`:
- Around line 529-531: The setUserEnabled method currently calls
axiosAuth.put(`/users/${userId}/enabled`, { enabled }) without converting HTTP
errors into typed BackendError instances; wrap the request in the same
error-handling pattern used elsewhere by calling rethrowAndConvertIfExpected
around the axiosAuth.put call (preserve the signature of public async
setUserEnabled(userId: string, enabled: boolean): Promise<void>) so that 404/403
and other expected responses are converted to the appropriate BackendError
subclasses instead of leaking raw HTTP errors.

In `@frontend/src/components/authority/UserDetail.vue`:
- Around line 125-132: The enableUser function swallows errors to console only;
update it to surface failures to the user by catching the error thrown by
backend.users.setUserEnabled(props.id, true) and then invoking the same UI error
path used by the disable flow (e.g., call the notification/toast helper or set
the component's error state used by the disable dialog) so the user sees an
inline message or toast; keep the existing user.value refresh logic (user.value
= await backend.users.getUser(props.id)) in the success path and ensure the
error branch uses the shared error display (or emits an error event) with the
caught error for context.

In `@frontend/src/components/authority/UserDisableDialog.vue`:
- Around line 27-33: The img uses user.pictureUrl directly which is optional on
User; update the template in UserDisableDialog.vue to defensively use a fallback
when user.pictureUrl is missing (e.g., render a default avatar URL or a scoped
placeholder component) or conditionally render a styled initials/avatar element;
ensure this complements AuthorityService.fillInMissingPicture by referencing the
same user.pictureUrl property and keep the img src binding to a computed/derived
value (e.g., a local computed like displayPictureUrl) so the fallback logic is
centralized and easy to test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4cf40320-9ed2-45a1-ba73-9483a00a8446

📥 Commits

Reviewing files that changed from the base of the PR and between a29d65d and b90d231.

📒 Files selected for processing (21)
  • backend/src/main/java/org/cryptomator/hub/api/AuthorityResource.java
  • backend/src/main/java/org/cryptomator/hub/api/UserDto.java
  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
  • backend/src/main/java/org/cryptomator/hub/api/VaultResource.java
  • backend/src/main/java/org/cryptomator/hub/entities/EffectiveVaultAccess.java
  • backend/src/main/java/org/cryptomator/hub/entities/User.java
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAdminService.java
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProvider.java
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPuller.java
  • backend/src/main/java/org/cryptomator/hub/keycloak/KeycloakUserDto.java
  • backend/src/main/resources/org/cryptomator/hub/flyway/V25__User_Enabled.sql
  • backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java
  • backend/src/test/java/org/cryptomator/hub/keycloak/KeycloakAuthorityProviderTest.java
  • backend/src/test/java/org/cryptomator/hub/keycloak/KeycloakAuthorityPullerTest.java
  • frontend/src/common/backend.ts
  • frontend/src/components/authority/UserDetail.vue
  • frontend/src/components/authority/UserDisableDialog.vue
  • frontend/src/components/authority/UserInfo.vue
  • frontend/src/components/authority/UserList.vue
  • frontend/src/i18n/en-US.json
  • frontend/test/common/emergencyaccess.spec.ts

@tobihagemann tobihagemann requested a review from SailReal March 24, 2026 20:44
Copy link
Copy Markdown
Member

@SailReal SailReal left a comment

Choose a reason for hiding this comment

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

Beside the requested changes, it looks good to me 👍

Copy link
Copy Markdown
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.

🧹 Nitpick comments (1)
frontend/src/common/backend.ts (1)

529-531: Align error handling with the rest of UserService.

This new admin mutation should convert expected backend statuses (at least 403/404) to typed BackendErrors for consistent caller behavior.

♻️ Suggested adjustment
   public async setUserEnabled(userId: string, enabled: boolean): Promise<void> {
-    await axiosAuth.put(`/users/${userId}/enabled`, enabled, { headers: { 'Content-Type': 'text/plain' } });
+    await axiosAuth.put(`/users/${userId}/enabled`, enabled, { headers: { 'Content-Type': 'text/plain' } })
+      .catch((error) => rethrowAndConvertIfExpected(error, 403, 404));
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/common/backend.ts` around lines 529 - 531, The setUserEnabled
method should convert expected backend statuses into typed BackendError like
other UserService methods: wrap the axiosAuth.put call in try/catch inside
setUserEnabled, detect axios error.response.status for 403 and 404 and throw a
BackendError instance with the appropriate status/message (matching how other
methods create BackendError), otherwise rethrow the original error; reference
setUserEnabled and BackendError to locate where to add the catch and mapping
logic so caller behavior is consistent with other UserService methods.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@frontend/src/common/backend.ts`:
- Around line 529-531: The setUserEnabled method should convert expected backend
statuses into typed BackendError like other UserService methods: wrap the
axiosAuth.put call in try/catch inside setUserEnabled, detect axios
error.response.status for 403 and 404 and throw a BackendError instance with the
appropriate status/message (matching how other methods create BackendError),
otherwise rethrow the original error; reference setUserEnabled and BackendError
to locate where to add the catch and mapping logic so caller behavior is
consistent with other UserService methods.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e645a09c-dc55-4674-948f-2987cba58d84

📥 Commits

Reviewing files that changed from the base of the PR and between 047bae1 and 17f4550.

📒 Files selected for processing (3)
  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java
  • backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java
  • frontend/src/common/backend.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • backend/src/test/java/org/cryptomator/hub/api/UsersResourceIT.java
  • backend/src/main/java/org/cryptomator/hub/api/UsersResource.java

@tobihagemann tobihagemann merged commit e01e471 into develop Mar 26, 2026
8 checks passed
@tobihagemann tobihagemann deleted the feature/disabled-user-seats branch March 26, 2026 16:47
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.

Disabled Users should not require a license seat

2 participants