1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 05:13:29 +00:00

[PM-22839] Update Device Approvals visibility based on SSO configuration (#16144)

* Add ssoEnabled and ssoMemberDecryptionType properties to ProfileOrganizationResponse

* Add SSO support to Organization model with ssoEnabled and ssoMemberDecryptionType properties, and implement related tests

* Upsert organization SSO settings in memory after save

Updates organization data in memory with new SSO configuration values
to ensure immediate UI updates for Device Approvals page visibility.

* Refactor SSO component to simplify upsertOrganizationWithSsoChanges method

- Updated the method signature to accept a single OrganizationSsoRequest object instead of separate parameters.
- Adjusted the internal logic to directly use properties from the OrganizationSsoRequest for updating the organization state.

* Specify OrganizationData type for updatedOrganization in SSO component
This commit is contained in:
Rui Tomé
2025-09-05 11:22:55 +01:00
committed by GitHub
parent 9a40a8d7ec
commit 8c598b8783
6 changed files with 231 additions and 3 deletions

View File

@@ -16,8 +16,9 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
import {
getOrganizationById,
OrganizationService,
InternalOrganizationServiceAbstraction,
} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
@@ -196,7 +197,7 @@ export class SsoComponent implements OnInit, OnDestroy {
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private organizationService: OrganizationService,
private organizationService: InternalOrganizationServiceAbstraction,
private accountService: AccountService,
private organizationApiService: OrganizationApiServiceAbstraction,
private toastService: ToastService,
@@ -298,6 +299,8 @@ export class SsoComponent implements OnInit, OnDestroy {
const response = await this.organizationApiService.updateSso(this.organizationId, request);
this.populateForm(response);
await this.upsertOrganizationWithSsoChanges(request);
this.toastService.showToast({
variant: "success",
title: null,
@@ -399,4 +402,25 @@ export class SsoComponent implements OnInit, OnDestroy {
document.body.append(div);
}
private async upsertOrganizationWithSsoChanges(
organizationSsoRequest: OrganizationSsoRequest,
): Promise<void> {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const currentOrganization = await firstValueFrom(
this.organizationService
.organizations$(userId)
.pipe(getOrganizationById(this.organizationId)),
);
if (currentOrganization) {
const updatedOrganization: OrganizationData = {
...currentOrganization,
ssoEnabled: organizationSsoRequest.enabled,
ssoMemberDecryptionType: organizationSsoRequest.data.memberDecryptionType,
};
await this.organizationService.upsert(updatedOrganization, userId);
}
}
}

View File

@@ -61,6 +61,8 @@ describe("ORGANIZATIONS state", () => {
useOrganizationDomains: false,
useAdminSponsoredFamilies: false,
isAdminInitiated: false,
ssoEnabled: false,
ssoMemberDecryptionType: undefined,
},
};
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));

View File

@@ -2,6 +2,7 @@
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { MemberDecryptionType } from "../../../auth/enums/sso";
import { ProductTierType } from "../../../billing/enums";
import { OrganizationUserStatusType, OrganizationUserType, ProviderType } from "../../enums";
import { PermissionsApi } from "../api/permissions.api";
@@ -63,6 +64,8 @@ export class OrganizationData {
useRiskInsights: boolean;
useAdminSponsoredFamilies: boolean;
isAdminInitiated: boolean;
ssoEnabled: boolean;
ssoMemberDecryptionType?: MemberDecryptionType;
constructor(
response?: ProfileOrganizationResponse,
@@ -128,6 +131,8 @@ export class OrganizationData {
this.useRiskInsights = response.useRiskInsights;
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
this.isAdminInitiated = response.isAdminInitiated;
this.ssoEnabled = response.ssoEnabled;
this.ssoMemberDecryptionType = response.ssoMemberDecryptionType;
this.isMember = options.isMember;
this.isProviderUser = options.isProviderUser;

View File

@@ -0,0 +1,182 @@
import { MemberDecryptionType } from "../../../auth/enums/sso";
import { ProductTierType } from "../../../billing/enums";
import { OrganizationUserStatusType, OrganizationUserType } from "../../enums";
import { PermissionsApi } from "../api/permissions.api";
import { OrganizationData } from "../data/organization.data";
import { Organization } from "./organization";
describe("Organization", () => {
let data: OrganizationData;
beforeEach(() => {
data = {
id: "test-org-id",
name: "Test Organization",
status: OrganizationUserStatusType.Confirmed,
type: OrganizationUserType.Admin,
enabled: true,
usePolicies: true,
useGroups: true,
useDirectory: true,
useEvents: true,
useTotp: true,
use2fa: true,
useApi: true,
useSso: true,
useOrganizationDomains: true,
useKeyConnector: false,
useScim: true,
useCustomPermissions: false,
useResetPassword: true,
useSecretsManager: true,
usePasswordManager: true,
useActivateAutofillPolicy: false,
selfHost: false,
usersGetPremium: false,
seats: 10,
maxCollections: 100,
maxStorageGb: 1,
ssoBound: false,
identifier: "test-identifier",
permissions: new PermissionsApi({
accessEventLogs: false,
accessImportExport: false,
accessReports: false,
createNewCollections: false,
editAnyCollection: false,
deleteAnyCollection: false,
editAssignedCollections: false,
deleteAssignedCollections: false,
manageCiphers: false,
manageGroups: false,
managePolicies: false,
manageSso: false,
manageUsers: false,
manageResetPassword: false,
manageScim: false,
}),
resetPasswordEnrolled: false,
userId: "user-id",
organizationUserId: "org-user-id",
hasPublicAndPrivateKeys: false,
providerId: null,
providerName: null,
providerType: null,
isProviderUser: false,
isMember: true,
familySponsorshipFriendlyName: null,
familySponsorshipAvailable: false,
productTierType: ProductTierType.Enterprise,
keyConnectorEnabled: false,
keyConnectorUrl: null,
familySponsorshipLastSyncDate: null,
familySponsorshipValidUntil: null,
familySponsorshipToDelete: null,
accessSecretsManager: false,
limitCollectionCreation: false,
limitCollectionDeletion: false,
limitItemDeletion: false,
allowAdminAccessToAllCollectionItems: true,
userIsManagedByOrganization: false,
useRiskInsights: false,
useAdminSponsoredFamilies: false,
isAdminInitiated: false,
ssoEnabled: false,
ssoMemberDecryptionType: MemberDecryptionType.MasterPassword,
} as OrganizationData;
});
describe("canManageDeviceApprovals", () => {
it("should return false when user is not admin and has no manageResetPassword permission", () => {
data.type = OrganizationUserType.User;
data.useSso = true;
data.ssoEnabled = true;
data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
data.permissions.manageResetPassword = false;
const organization = new Organization(data);
expect(organization.canManageDeviceApprovals).toBe(false);
});
it("should return false when useSso is false", () => {
data.type = OrganizationUserType.Admin;
data.useSso = false;
data.ssoEnabled = true;
data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
const organization = new Organization(data);
expect(organization.canManageDeviceApprovals).toBe(false);
});
it("should return false when ssoEnabled is false", () => {
data.type = OrganizationUserType.Admin;
data.useSso = true;
data.ssoEnabled = false;
data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
const organization = new Organization(data);
expect(organization.canManageDeviceApprovals).toBe(false);
});
it("should return false when ssoMemberDecryptionType is not TrustedDeviceEncryption", () => {
data.type = OrganizationUserType.Admin;
data.useSso = true;
data.ssoEnabled = true;
data.ssoMemberDecryptionType = MemberDecryptionType.MasterPassword;
const organization = new Organization(data);
expect(organization.canManageDeviceApprovals).toBe(false);
});
it("should return true when admin has all required SSO settings enabled", () => {
data.type = OrganizationUserType.Admin;
data.useSso = true;
data.ssoEnabled = true;
data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
const organization = new Organization(data);
expect(organization.canManageDeviceApprovals).toBe(true);
});
it("should return true when owner has all required SSO settings enabled", () => {
data.type = OrganizationUserType.Owner;
data.useSso = true;
data.ssoEnabled = true;
data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
const organization = new Organization(data);
expect(organization.canManageDeviceApprovals).toBe(true);
});
it("should return true when user has manageResetPassword permission and all SSO settings enabled", () => {
data.type = OrganizationUserType.User;
data.useSso = true;
data.ssoEnabled = true;
data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
data.permissions.manageResetPassword = true;
const organization = new Organization(data);
expect(organization.canManageDeviceApprovals).toBe(true);
});
it("should return true when provider user has all required SSO settings enabled", () => {
data.type = OrganizationUserType.User;
data.isProviderUser = true;
data.useSso = true;
data.ssoEnabled = true;
data.ssoMemberDecryptionType = MemberDecryptionType.TrustedDeviceEncryption;
const organization = new Organization(data);
expect(organization.canManageDeviceApprovals).toBe(true);
});
});
});

View File

@@ -2,6 +2,7 @@
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { MemberDecryptionType } from "../../../auth/enums/sso";
import { ProductTierType } from "../../../billing/enums";
import { OrganizationId } from "../../../types/guid";
import { OrganizationUserStatusType, OrganizationUserType, ProviderType } from "../../enums";
@@ -94,6 +95,8 @@ export class Organization {
useRiskInsights: boolean;
useAdminSponsoredFamilies: boolean;
isAdminInitiated: boolean;
ssoEnabled: boolean;
ssoMemberDecryptionType?: MemberDecryptionType;
constructor(obj?: OrganizationData) {
if (obj == null) {
@@ -155,6 +158,8 @@ export class Organization {
this.useRiskInsights = obj.useRiskInsights;
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
this.isAdminInitiated = obj.isAdminInitiated;
this.ssoEnabled = obj.ssoEnabled;
this.ssoMemberDecryptionType = obj.ssoMemberDecryptionType;
}
get canAccess() {
@@ -304,7 +309,12 @@ export class Organization {
}
get canManageDeviceApprovals() {
return (this.isAdmin || this.permissions.manageResetPassword) && this.useSso;
return (
(this.isAdmin || this.permissions.manageResetPassword) &&
this.useSso &&
this.ssoEnabled &&
this.ssoMemberDecryptionType === MemberDecryptionType.TrustedDeviceEncryption
);
}
get isExemptFromPolicies() {

View File

@@ -1,3 +1,4 @@
import { MemberDecryptionType } from "../../../auth/enums/sso";
import { ProductTierType } from "../../../billing/enums";
import { BaseResponse } from "../../../models/response/base.response";
import { OrganizationUserStatusType, OrganizationUserType, ProviderType } from "../../enums";
@@ -58,6 +59,8 @@ export class ProfileOrganizationResponse extends BaseResponse {
useRiskInsights: boolean;
useAdminSponsoredFamilies: boolean;
isAdminInitiated: boolean;
ssoEnabled: boolean;
ssoMemberDecryptionType?: MemberDecryptionType;
constructor(response: any) {
super(response);
@@ -127,5 +130,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated");
this.ssoEnabled = this.getResponseProperty("SsoEnabled") ?? false;
this.ssoMemberDecryptionType = this.getResponseProperty("SsoMemberDecryptionType");
}
}