mirror of
https://github.com/bitwarden/browser
synced 2025-12-19 17:53:39 +00:00
EC-265 - SCIM configuration page in org admin (#3065)
* EC-265 - Initial stubs for SCIM config UI * EC-265 - Scim config screen and plumbing * EC-265 - Scim config component works! Needs cleanup * EC-265 - Finalize scim config screen * EC-265 - Remove scim url from storage and env urls * EC-265 - Refactor to use new component library * EC-265 - Angular warnings on disabled attr resolved * EC-265 - Continued transition to new components * EC-265 - Page loading spinner pattern * EC-265 - final SCIM configuration form changes * scim cleanup * use scim urls * suggested changes * feedback fixes * remove return * Move scimUrl logic to EnvironmentService * Refactor scim url handling Co-authored-by: Kyle Spearrin <kyle.spearrin@gmail.com> Co-authored-by: Thomas Rittson <trittson@bitwarden.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
<div class="page-header">
|
||||
<h1>{{ "scim" | i18n }}</h1>
|
||||
</div>
|
||||
|
||||
<p>{{ "scimDescription" | i18n }}</p>
|
||||
|
||||
<div *ngIf="loading">
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="sr-only">{{ "loading" | i18n }}</span>
|
||||
</div>
|
||||
<form
|
||||
#form
|
||||
(ngSubmit)="submit()"
|
||||
[appApiAction]="formPromise"
|
||||
[formGroup]="formData"
|
||||
*ngIf="!loading"
|
||||
>
|
||||
<div class="form-group">
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
[formControl]="enabled"
|
||||
name="Enabled"
|
||||
aria-describedby="scimEnabledCheckboxDescHelpText"
|
||||
/>
|
||||
<label class="form-check-label" for="enabled">{{ "scimEnabledCheckboxDesc" | i18n }}</label>
|
||||
<div id="scimEnabledCheckboxDescHelpText">
|
||||
<small class="form-text text-muted">{{ "scimEnabledCheckboxDescHelpText" | i18n }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<bit-form-field *ngIf="showScimSettings">
|
||||
<bit-label>{{ "scimUrl" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="endpointUrl" />
|
||||
<button
|
||||
type="button"
|
||||
bitSuffix
|
||||
bitButton
|
||||
(click)="copyScimUrl()"
|
||||
[appA11yTitle]="'copyScimUrl' | i18n"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-lg bwi-clone"></i>
|
||||
</button>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field *ngIf="showScimSettings">
|
||||
<bit-label>{{ "scimApiKey" | i18n }}</bit-label>
|
||||
<input bitInput type="text" formControlName="clientSecret" />
|
||||
<ng-container #rotateButton [appApiAction]="rotatePromise">
|
||||
<button
|
||||
[disabled]="rotateButton.loading"
|
||||
type="button"
|
||||
bitSuffix
|
||||
bitButton
|
||||
(click)="rotateScimKey()"
|
||||
[appA11yTitle]="'rotateScimKey' | i18n"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="bwi bwi-lg bwi-generate"
|
||||
[ngClass]="{ 'bwi-spin': rotateButton.loading }"
|
||||
></i>
|
||||
</button>
|
||||
</ng-container>
|
||||
<button
|
||||
type="button"
|
||||
bitSuffix
|
||||
bitButton
|
||||
(click)="copyScimKey()"
|
||||
[appA11yTitle]="'copyScimKey' | i18n"
|
||||
>
|
||||
<i aria-hidden="true" class="bwi bwi-lg bwi-clone"></i>
|
||||
</button>
|
||||
<bit-hint>{{ "scimApiKeyHelperText" | i18n }}</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-submit-button buttonType="primary" [loading]="form.loading" [disabled]="form.loading">
|
||||
{{ "save" | i18n }}
|
||||
</bit-submit-button>
|
||||
</form>
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Component, OnInit } from "@angular/core";
|
||||
import { FormBuilder, FormControl } from "@angular/forms";
|
||||
import { ActivatedRoute } from "@angular/router";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service";
|
||||
import { OrganizationApiKeyType } from "@bitwarden/common/enums/organizationApiKeyType";
|
||||
import { OrganizationConnectionType } from "@bitwarden/common/enums/organizationConnectionType";
|
||||
import { ScimConfigApi } from "@bitwarden/common/models/api/scimConfigApi";
|
||||
import { OrganizationApiKeyRequest } from "@bitwarden/common/models/request/organizationApiKeyRequest";
|
||||
import { OrganizationConnectionRequest } from "@bitwarden/common/models/request/organizationConnectionRequest";
|
||||
import { ScimConfigRequest } from "@bitwarden/common/models/request/scimConfigRequest";
|
||||
import { OrganizationConnectionResponse } from "@bitwarden/common/models/response/organizationConnectionResponse";
|
||||
|
||||
@Component({
|
||||
selector: "app-org-manage-scim",
|
||||
templateUrl: "scim.component.html",
|
||||
})
|
||||
export class ScimComponent implements OnInit {
|
||||
loading = true;
|
||||
organizationId: string;
|
||||
existingConnectionId: string;
|
||||
formPromise: Promise<any>;
|
||||
rotatePromise: Promise<any>;
|
||||
enabled = new FormControl(false);
|
||||
showScimSettings = false;
|
||||
|
||||
formData = this.formBuilder.group({
|
||||
endpointUrl: new FormControl({ value: "", disabled: true }),
|
||||
clientSecret: new FormControl({ value: "", disabled: true }),
|
||||
});
|
||||
|
||||
constructor(
|
||||
private formBuilder: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
private apiService: ApiService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private i18nService: I18nService,
|
||||
private environmentService: EnvironmentService
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.route.parent.parent.params.subscribe(async (params) => {
|
||||
this.organizationId = params.organizationId;
|
||||
await this.load();
|
||||
});
|
||||
}
|
||||
|
||||
async load() {
|
||||
const connection = await this.apiService.getOrganizationConnection(
|
||||
this.organizationId,
|
||||
OrganizationConnectionType.Scim,
|
||||
ScimConfigApi
|
||||
);
|
||||
await this.setConnectionFormValues(connection);
|
||||
}
|
||||
|
||||
async loadApiKey() {
|
||||
const apiKeyRequest = new OrganizationApiKeyRequest();
|
||||
apiKeyRequest.type = OrganizationApiKeyType.Scim;
|
||||
apiKeyRequest.masterPasswordHash = "N/A";
|
||||
const apiKeyResponse = await this.apiService.postOrganizationApiKey(
|
||||
this.organizationId,
|
||||
apiKeyRequest
|
||||
);
|
||||
this.formData.setValue({
|
||||
endpointUrl: this.getScimEndpointUrl(),
|
||||
clientSecret: apiKeyResponse.apiKey,
|
||||
});
|
||||
}
|
||||
|
||||
async copyScimUrl() {
|
||||
this.platformUtilsService.copyToClipboard(this.getScimEndpointUrl());
|
||||
}
|
||||
|
||||
async rotateScimKey() {
|
||||
const confirmed = await this.platformUtilsService.showDialog(
|
||||
this.i18nService.t("rotateScimKeyWarning"),
|
||||
this.i18nService.t("rotateScimKey"),
|
||||
this.i18nService.t("rotateKey"),
|
||||
this.i18nService.t("cancel"),
|
||||
"warning"
|
||||
);
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const request = new OrganizationApiKeyRequest();
|
||||
request.type = OrganizationApiKeyType.Scim;
|
||||
request.masterPasswordHash = "N/A";
|
||||
|
||||
this.rotatePromise = this.apiService.postOrganizationRotateApiKey(this.organizationId, request);
|
||||
|
||||
try {
|
||||
const response = await this.rotatePromise;
|
||||
this.formData.setValue({
|
||||
endpointUrl: this.getScimEndpointUrl(),
|
||||
clientSecret: response.apiKey,
|
||||
});
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("scimApiKeyRotated"));
|
||||
} catch {
|
||||
// Logged by appApiAction, do nothing
|
||||
}
|
||||
|
||||
this.rotatePromise = null;
|
||||
}
|
||||
|
||||
async copyScimKey() {
|
||||
this.platformUtilsService.copyToClipboard(this.formData.get("clientSecret").value);
|
||||
}
|
||||
|
||||
async submit() {
|
||||
try {
|
||||
const request = new OrganizationConnectionRequest(
|
||||
this.organizationId,
|
||||
OrganizationConnectionType.Scim,
|
||||
true,
|
||||
new ScimConfigRequest(this.enabled.value)
|
||||
);
|
||||
if (this.existingConnectionId == null) {
|
||||
this.formPromise = this.apiService.createOrganizationConnection(request, ScimConfigApi);
|
||||
} else {
|
||||
this.formPromise = this.apiService.updateOrganizationConnection(
|
||||
request,
|
||||
ScimConfigApi,
|
||||
this.existingConnectionId
|
||||
);
|
||||
}
|
||||
const response = (await this.formPromise) as OrganizationConnectionResponse<ScimConfigApi>;
|
||||
await this.setConnectionFormValues(response);
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("scimSettingsSaved"));
|
||||
} catch (e) {
|
||||
// Logged by appApiAction, do nothing
|
||||
}
|
||||
|
||||
this.formPromise = null;
|
||||
}
|
||||
|
||||
getScimEndpointUrl() {
|
||||
return this.environmentService.getScimUrl() + "/" + this.organizationId;
|
||||
}
|
||||
|
||||
private async setConnectionFormValues(connection: OrganizationConnectionResponse<ScimConfigApi>) {
|
||||
this.existingConnectionId = connection?.id;
|
||||
if (connection !== null && connection.config?.enabled) {
|
||||
this.showScimSettings = true;
|
||||
this.enabled.setValue(true);
|
||||
this.formData.setValue({
|
||||
endpointUrl: this.getScimEndpointUrl(),
|
||||
clientSecret: "",
|
||||
});
|
||||
await this.loadApiKey();
|
||||
} else {
|
||||
this.showScimSettings = false;
|
||||
this.enabled.setValue(false);
|
||||
}
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { OrganizationLayoutComponent } from "src/app/organizations/layouts/organ
|
||||
import { ManageComponent } from "src/app/organizations/manage/manage.component";
|
||||
import { NavigationPermissionsService } from "src/app/organizations/services/navigation-permissions.service";
|
||||
|
||||
import { ScimComponent } from "./manage/scim.component";
|
||||
import { SsoComponent } from "./manage/sso.component";
|
||||
|
||||
const routes: Routes = [
|
||||
@@ -33,6 +34,14 @@ const routes: Routes = [
|
||||
permissions: [Permissions.ManageSso],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "scim",
|
||||
component: ScimComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
permissions: [Permissions.ManageScim],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2,12 +2,13 @@ import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { SharedModule } from "src/app/modules/shared.module";
|
||||
|
||||
import { InputCheckboxComponent } from "./components/input-checkbox.component";
|
||||
import { InputTextReadOnlyComponent } from "./components/input-text-readonly.component";
|
||||
import { InputTextComponent } from "./components/input-text.component";
|
||||
import { SelectComponent } from "./components/select.component";
|
||||
import { ScimComponent } from "./manage/scim.component";
|
||||
import { SsoComponent } from "./manage/sso.component";
|
||||
import { OrganizationsRoutingModule } from "./organizations-routing.module";
|
||||
|
||||
@@ -18,7 +19,7 @@ import { OrganizationsRoutingModule } from "./organizations-routing.module";
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
JslibModule,
|
||||
SharedModule,
|
||||
OrganizationsRoutingModule,
|
||||
],
|
||||
declarations: [
|
||||
@@ -27,6 +28,7 @@ import { OrganizationsRoutingModule } from "./organizations-routing.module";
|
||||
InputTextReadOnlyComponent,
|
||||
SelectComponent,
|
||||
SsoComponent,
|
||||
ScimComponent,
|
||||
],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
|
||||
Reference in New Issue
Block a user