1 import { Component, OnInit } from '@angular/core';
2 import { AbstractControl, ValidationErrors, Validators } from '@angular/forms';
3 import { ActivatedRoute, Router } from '@angular/router';
5 import _ from 'lodash';
6 import { concat as observableConcat, forkJoin as observableForkJoin, Observable } from 'rxjs';
8 import { RgwUserService } from '~/app/shared/api/rgw-user.service';
9 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
10 import { Icons } from '~/app/shared/enum/icons.enum';
11 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
12 import { CdForm } from '~/app/shared/forms/cd-form';
13 import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
14 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
15 import { CdValidators, isEmptyInputValue } from '~/app/shared/forms/cd-validators';
16 import { FormatterService } from '~/app/shared/services/formatter.service';
17 import { ModalService } from '~/app/shared/services/modal.service';
18 import { NotificationService } from '~/app/shared/services/notification.service';
19 import { RgwUserCapabilities } from '../models/rgw-user-capabilities';
20 import { RgwUserCapability } from '../models/rgw-user-capability';
21 import { RgwUserS3Key } from '../models/rgw-user-s3-key';
22 import { RgwUserSubuser } from '../models/rgw-user-subuser';
23 import { RgwUserSwiftKey } from '../models/rgw-user-swift-key';
24 import { RgwUserCapabilityModalComponent } from '../rgw-user-capability-modal/rgw-user-capability-modal.component';
25 import { RgwUserS3KeyModalComponent } from '../rgw-user-s3-key-modal/rgw-user-s3-key-modal.component';
26 import { RgwUserSubuserModalComponent } from '../rgw-user-subuser-modal/rgw-user-subuser-modal.component';
27 import { RgwUserSwiftKeyModalComponent } from '../rgw-user-swift-key-modal/rgw-user-swift-key-modal.component';
30 selector: 'cd-rgw-user-form',
31 templateUrl: './rgw-user-form.component.html',
32 styleUrls: ['./rgw-user-form.component.scss']
34 export class RgwUserFormComponent extends CdForm implements OnInit {
35 userForm: CdFormGroup;
37 submitObservables: Observable<Object>[] = [];
39 subusers: RgwUserSubuser[] = [];
40 s3Keys: RgwUserS3Key[] = [];
41 swiftKeys: RgwUserSwiftKey[] = [];
42 capabilities: RgwUserCapability[] = [];
48 capabilityLabel: string;
49 usernameExists: boolean;
51 previousTenant: string = null;
54 private formBuilder: CdFormBuilder,
55 private route: ActivatedRoute,
56 private router: Router,
57 private rgwUserService: RgwUserService,
58 private modalService: ModalService,
59 private notificationService: NotificationService,
60 public actionLabels: ActionLabelsI18n
63 this.resource = $localize`user`;
64 this.subuserLabel = $localize`subuser`;
65 this.s3keyLabel = $localize`S3 Key`;
66 this.capabilityLabel = $localize`capability`;
67 this.editing = this.router.url.startsWith(`/rgw/user/${URLVerbs.EDIT}`);
68 this.action = this.editing ? this.actionLabels.EDIT : this.actionLabels.CREATE;
73 this.userForm = this.formBuilder.group({
77 [Validators.required, Validators.pattern(/^[a-zA-Z0-9!@#%^&*()_-]+$/)],
81 CdValidators.unique(this.rgwUserService.exists, this.rgwUserService, () =>
82 this.userForm.getValue('tenant')
86 show_tenant: [this.editing],
89 [Validators.pattern(/^[a-zA-Z0-9!@#%^&*()_-]+$/)],
94 this.rgwUserService.exists,
96 () => this.userForm.getValue('user_id'),
101 display_name: [null, [Validators.required]],
104 [CdValidators.email],
105 [CdValidators.unique(this.rgwUserService.emailExists, this.rgwUserService)]
107 max_buckets_mode: [1],
110 [CdValidators.requiredIf({ max_buckets_mode: '1' }), CdValidators.number(false)]
114 generate_key: [true],
115 access_key: [null, [CdValidators.requiredIf({ generate_key: false })]],
116 secret_key: [null, [CdValidators.requiredIf({ generate_key: false })]],
118 user_quota_enabled: [false],
119 user_quota_max_size_unlimited: [true],
120 user_quota_max_size: [
123 CdValidators.composeIf(
125 user_quota_enabled: true,
126 user_quota_max_size_unlimited: false
128 [Validators.required, this.quotaMaxSizeValidator]
132 user_quota_max_objects_unlimited: [true],
133 user_quota_max_objects: [
136 CdValidators.requiredIf({
137 user_quota_enabled: true,
138 user_quota_max_objects_unlimited: false
143 bucket_quota_enabled: [false],
144 bucket_quota_max_size_unlimited: [true],
145 bucket_quota_max_size: [
148 CdValidators.composeIf(
150 bucket_quota_enabled: true,
151 bucket_quota_max_size_unlimited: false
153 [Validators.required, this.quotaMaxSizeValidator]
157 bucket_quota_max_objects_unlimited: [true],
158 bucket_quota_max_objects: [
161 CdValidators.requiredIf({
162 bucket_quota_enabled: true,
163 bucket_quota_max_objects_unlimited: false
171 // Process route parameters.
172 this.route.params.subscribe((params: { uid: string }) => {
173 if (!params.hasOwnProperty('uid')) {
177 const uid = decodeURIComponent(params.uid);
178 // Load the user and quota information.
179 const observables = [];
180 observables.push(this.rgwUserService.get(uid));
181 observables.push(this.rgwUserService.getQuota(uid));
182 observableForkJoin(observables).subscribe(
184 // Get the default values.
185 const defaults = _.clone(this.userForm.value);
186 // Extract the values displayed in the form.
187 let value = _.pick(resp[0], _.keys(this.userForm.value));
188 // Map the max. buckets values.
189 switch (value['max_buckets']) {
191 value['max_buckets_mode'] = -1;
192 value['max_buckets'] = '';
195 value['max_buckets_mode'] = 0;
196 value['max_buckets'] = '';
199 value['max_buckets_mode'] = 1;
202 // Map the quota values.
203 ['user', 'bucket'].forEach((type) => {
204 const quota = resp[1][type + '_quota'];
205 value[type + '_quota_enabled'] = quota.enabled;
206 if (quota.max_size < 0) {
207 value[type + '_quota_max_size_unlimited'] = true;
208 value[type + '_quota_max_size'] = null;
210 value[type + '_quota_max_size_unlimited'] = false;
211 value[type + '_quota_max_size'] = `${quota.max_size} B`;
213 if (quota.max_objects < 0) {
214 value[type + '_quota_max_objects_unlimited'] = true;
215 value[type + '_quota_max_objects'] = null;
217 value[type + '_quota_max_objects_unlimited'] = false;
218 value[type + '_quota_max_objects'] = quota.max_objects;
221 // Merge with default values.
222 value = _.merge(defaults, value);
224 this.userForm.setValue(value);
226 // Get the sub users.
227 this.subusers = resp[0].subusers;
230 this.s3Keys = resp[0].keys;
231 this.swiftKeys = resp[0].swift_keys;
233 // Process the capabilities.
234 const mapPerm = { 'read, write': '*' };
235 resp[0].caps.forEach((cap: any) => {
236 if (cap.perm in mapPerm) {
237 cap.perm = mapPerm[cap.perm];
240 this.capabilities = resp[0].caps;
252 this.router.navigate(['/rgw/user']);
256 let notificationTitle: string;
257 // Exit immediately if the form isn't dirty.
258 if (this.userForm.pristine) {
262 const uid = this.getUID();
265 if (this._isGeneralDirty()) {
266 const args = this._getUpdateArgs();
267 this.submitObservables.push(this.rgwUserService.update(uid, args));
269 notificationTitle = $localize`Updated Object Gateway user '${uid}'`;
272 const args = this._getCreateArgs();
273 this.submitObservables.push(this.rgwUserService.create(args));
274 notificationTitle = $localize`Created Object Gateway user '${uid}'`;
276 // Check if user quota has been modified.
277 if (this._isUserQuotaDirty()) {
278 const userQuotaArgs = this._getUserQuotaArgs();
279 this.submitObservables.push(this.rgwUserService.updateQuota(uid, userQuotaArgs));
281 // Check if bucket quota has been modified.
282 if (this._isBucketQuotaDirty()) {
283 const bucketQuotaArgs = this._getBucketQuotaArgs();
284 this.submitObservables.push(this.rgwUserService.updateQuota(uid, bucketQuotaArgs));
286 // Finally execute all observables one by one in serial.
287 observableConcat(...this.submitObservables).subscribe({
289 // Reset the 'Submit' button.
290 this.userForm.setErrors({ cdSubmitButton: true });
293 this.notificationService.show(NotificationType.success, notificationTitle);
299 updateFieldsWhenTenanted() {
300 this.showTenant = this.userForm.getValue('show_tenant');
301 if (!this.showTenant) {
302 this.userForm.get('user_id').markAsUntouched();
303 this.userForm.get('tenant').patchValue(this.previousTenant);
305 this.userForm.get('user_id').markAsTouched();
306 this.previousTenant = this.userForm.get('tenant').value;
307 this.userForm.get('tenant').patchValue(null);
312 let uid = this.userForm.getValue('user_id');
313 const tenant = this.userForm?.getValue('tenant');
314 if (tenant && tenant.length > 0) {
315 uid = `${this.userForm.getValue('tenant')}$${uid}`;
321 * Validate the quota maximum size, e.g. 1096, 1K, 30M or 1.9MiB.
323 quotaMaxSizeValidator(control: AbstractControl): ValidationErrors | null {
324 if (isEmptyInputValue(control.value)) {
327 const m = RegExp('^(\\d+(\\.\\d+)?)\\s*(B|K(B|iB)?|M(B|iB)?|G(B|iB)?|T(B|iB)?)?$', 'i').exec(
331 return { quotaMaxSize: true };
333 const bytes = new FormatterService().toBytes(control.value);
334 return bytes < 1024 ? { quotaMaxSize: true } : null;
338 * Add/Update a subuser.
340 setSubuser(subuser: RgwUserSubuser, index?: number) {
341 const mapPermissions: Record<string, string> = {
342 'full-control': 'full',
343 'read-write': 'readwrite'
345 const uid = this.getUID();
349 subuser.permissions in mapPermissions
350 ? mapPermissions[subuser.permissions]
351 : subuser.permissions,
353 secret_key: subuser.secret_key,
354 generate_secret: subuser.generate_secret ? 'true' : 'false'
356 this.submitObservables.push(this.rgwUserService.createSubuser(uid, args));
357 if (_.isNumber(index)) {
359 // Create an observable to modify the subuser when the form is submitted.
360 this.subusers[index] = subuser;
363 // Create an observable to add the subuser when the form is submitted.
364 this.subusers.push(subuser);
365 // Add a Swift key. If the secret key is auto-generated, then visualize
366 // this to the user by displaying a notification instead of the key.
367 this.swiftKeys.push({
369 secret_key: subuser.generate_secret ? 'Apply your changes first...' : subuser.secret_key
372 // Mark the form as dirty to be able to submit it.
373 this.userForm.markAsDirty();
378 * @param {number} index The subuser to delete.
380 deleteSubuser(index: number) {
381 const subuser = this.subusers[index];
382 // Create an observable to delete the subuser when the form is submitted.
383 this.submitObservables.push(this.rgwUserService.deleteSubuser(this.getUID(), subuser.id));
384 // Remove the associated S3 keys.
385 this.s3Keys = this.s3Keys.filter((key) => {
386 return key.user !== subuser.id;
388 // Remove the associated Swift keys.
389 this.swiftKeys = this.swiftKeys.filter((key) => {
390 return key.user !== subuser.id;
392 // Remove the subuser to update the UI.
393 this.subusers.splice(index, 1);
394 // Mark the form as dirty to be able to submit it.
395 this.userForm.markAsDirty();
399 * Add/Update a capability.
401 setCapability(cap: RgwUserCapability, index?: number) {
402 const uid = this.getUID();
403 if (_.isNumber(index)) {
405 const oldCap = this.capabilities[index];
406 // Note, the RadosGW Admin OPS API does not support the modification of
407 // user capabilities. Because of that it is necessary to delete it and
408 // then to re-add the capability with its new value/permission.
409 this.submitObservables.push(
410 this.rgwUserService.deleteCapability(uid, oldCap.type, oldCap.perm)
412 this.submitObservables.push(this.rgwUserService.addCapability(uid, cap.type, cap.perm));
413 this.capabilities[index] = cap;
416 // Create an observable to add the capability when the form is submitted.
417 this.submitObservables.push(this.rgwUserService.addCapability(uid, cap.type, cap.perm));
418 this.capabilities = [...this.capabilities, cap]; // Notify Angular CD
420 // Mark the form as dirty to be able to submit it.
421 this.userForm.markAsDirty();
425 * Delete the given capability:
426 * - Delete it from the local array to update the UI
427 * - Create an observable that will be executed on form submit
428 * @param {number} index The capability to delete.
430 deleteCapability(index: number) {
431 const cap = this.capabilities[index];
432 // Create an observable to delete the capability when the form is submitted.
433 this.submitObservables.push(
434 this.rgwUserService.deleteCapability(this.getUID(), cap.type, cap.perm)
436 // Remove the capability to update the UI.
437 this.capabilities.splice(index, 1);
438 this.capabilities = [...this.capabilities]; // Notify Angular CD
439 // Mark the form as dirty to be able to submit it.
440 this.userForm.markAsDirty();
443 hasAllCapabilities(capabilities: RgwUserCapability[]) {
444 return !_.difference(RgwUserCapabilities.getAll(), _.map(capabilities, 'type')).length;
448 * Add/Update a S3 key.
450 setS3Key(key: RgwUserS3Key, index?: number) {
451 if (_.isNumber(index)) {
453 // Nothing to do here at the moment.
456 // Split the key's user name into its user and subuser parts.
457 const userMatches = key.user.match(/([^:]+)(:(.+))?/);
458 // Create an observable to add the S3 key when the form is submitted.
459 const uid = userMatches[1];
461 subuser: userMatches[2] ? userMatches[3] : '',
462 generate_key: key.generate_key ? 'true' : 'false'
464 if (args['generate_key'] === 'false') {
465 if (!_.isNil(key.access_key)) {
466 args['access_key'] = key.access_key;
468 if (!_.isNil(key.secret_key)) {
469 args['secret_key'] = key.secret_key;
472 this.submitObservables.push(this.rgwUserService.addS3Key(uid, args));
473 // If the access and the secret key are auto-generated, then visualize
474 // this to the user by displaying a notification instead of the key.
477 access_key: key.generate_key ? 'Apply your changes first...' : key.access_key,
478 secret_key: key.generate_key ? 'Apply your changes first...' : key.secret_key
481 // Mark the form as dirty to be able to submit it.
482 this.userForm.markAsDirty();
487 * @param {number} index The S3 key to delete.
489 deleteS3Key(index: number) {
490 const key = this.s3Keys[index];
491 // Create an observable to delete the S3 key when the form is submitted.
492 this.submitObservables.push(this.rgwUserService.deleteS3Key(this.getUID(), key.access_key));
493 // Remove the S3 key to update the UI.
494 this.s3Keys.splice(index, 1);
495 // Mark the form as dirty to be able to submit it.
496 this.userForm.markAsDirty();
500 * Show the specified subuser in a modal dialog.
501 * @param {number | undefined} index The subuser to show.
503 showSubuserModal(index?: number) {
504 const uid = this.getUID();
505 const modalRef = this.modalService.show(RgwUserSubuserModalComponent);
506 if (_.isNumber(index)) {
508 const subuser = this.subusers[index];
509 modalRef.componentInstance.setEditing();
510 modalRef.componentInstance.setValues(uid, subuser.id, subuser.permissions);
513 modalRef.componentInstance.setEditing(false);
514 modalRef.componentInstance.setValues(uid);
515 modalRef.componentInstance.setSubusers(this.subusers);
517 modalRef.componentInstance.submitAction.subscribe((subuser: RgwUserSubuser) => {
518 this.setSubuser(subuser, index);
523 * Show the specified S3 key in a modal dialog.
524 * @param {number | undefined} index The S3 key to show.
526 showS3KeyModal(index?: number) {
527 const modalRef = this.modalService.show(RgwUserS3KeyModalComponent);
528 if (_.isNumber(index)) {
530 const key = this.s3Keys[index];
531 modalRef.componentInstance.setViewing();
532 modalRef.componentInstance.setValues(key.user, key.access_key, key.secret_key);
535 const candidates = this._getS3KeyUserCandidates();
536 modalRef.componentInstance.setViewing(false);
537 modalRef.componentInstance.setUserCandidates(candidates);
538 modalRef.componentInstance.submitAction.subscribe((key: RgwUserS3Key) => {
545 * Show the specified Swift key in a modal dialog.
546 * @param {number} index The Swift key to show.
548 showSwiftKeyModal(index: number) {
549 const modalRef = this.modalService.show(RgwUserSwiftKeyModalComponent);
550 const key = this.swiftKeys[index];
551 modalRef.componentInstance.setValues(key.user, key.secret_key);
555 * Show the specified capability in a modal dialog.
556 * @param {number | undefined} index The S3 key to show.
558 showCapabilityModal(index?: number) {
559 const modalRef = this.modalService.show(RgwUserCapabilityModalComponent);
560 if (_.isNumber(index)) {
562 const cap = this.capabilities[index];
563 modalRef.componentInstance.setEditing();
564 modalRef.componentInstance.setValues(cap.type, cap.perm);
567 modalRef.componentInstance.setEditing(false);
568 modalRef.componentInstance.setCapabilities(this.capabilities);
570 modalRef.componentInstance.submitAction.subscribe((cap: RgwUserCapability) => {
571 this.setCapability(cap, index);
576 * Check if the general user settings (display name, email, ...) have been modified.
577 * @return {Boolean} Returns TRUE if the general user settings have been modified.
579 private _isGeneralDirty(): boolean {
580 return ['display_name', 'email', 'max_buckets_mode', 'max_buckets', 'suspended'].some(
582 return this.userForm.get(path).dirty;
588 * Check if the user quota has been modified.
589 * @return {Boolean} Returns TRUE if the user quota has been modified.
591 private _isUserQuotaDirty(): boolean {
593 'user_quota_enabled',
594 'user_quota_max_size_unlimited',
595 'user_quota_max_size',
596 'user_quota_max_objects_unlimited',
597 'user_quota_max_objects'
599 return this.userForm.get(path).dirty;
604 * Check if the bucket quota has been modified.
605 * @return {Boolean} Returns TRUE if the bucket quota has been modified.
607 private _isBucketQuotaDirty(): boolean {
609 'bucket_quota_enabled',
610 'bucket_quota_max_size_unlimited',
611 'bucket_quota_max_size',
612 'bucket_quota_max_objects_unlimited',
613 'bucket_quota_max_objects'
615 return this.userForm.get(path).dirty;
620 * Helper function to get the arguments of the API request when a new
623 private _getCreateArgs() {
626 display_name: this.userForm.getValue('display_name'),
627 suspended: this.userForm.getValue('suspended'),
629 max_buckets: this.userForm.getValue('max_buckets'),
630 generate_key: this.userForm.getValue('generate_key'),
634 const email = this.userForm.getValue('email');
635 if (_.isString(email) && email.length > 0) {
636 _.merge(result, { email: email });
638 const generateKey = this.userForm.getValue('generate_key');
642 access_key: this.userForm.getValue('access_key'),
643 secret_key: this.userForm.getValue('secret_key')
646 const maxBucketsMode = parseInt(this.userForm.getValue('max_buckets_mode'), 10);
647 if (_.includes([-1, 0], maxBucketsMode)) {
648 // -1 => Disable bucket creation.
649 // 0 => Unlimited bucket creation.
650 _.merge(result, { max_buckets: maxBucketsMode });
656 * Helper function to get the arguments for the API request when the user
657 * configuration has been modified.
659 private _getUpdateArgs() {
660 const result: Record<string, any> = {};
661 const keys = ['display_name', 'email', 'max_buckets', 'suspended'];
662 for (const key of keys) {
663 result[key] = this.userForm.getValue(key);
665 const maxBucketsMode = parseInt(this.userForm.getValue('max_buckets_mode'), 10);
666 if (_.includes([-1, 0], maxBucketsMode)) {
667 // -1 => Disable bucket creation.
668 // 0 => Unlimited bucket creation.
669 result['max_buckets'] = maxBucketsMode;
675 * Helper function to get the arguments for the API request when the user
676 * quota configuration has been modified.
678 private _getUserQuotaArgs(): Record<string, any> {
681 enabled: this.userForm.getValue('user_quota_enabled'),
685 if (!this.userForm.getValue('user_quota_max_size_unlimited')) {
686 // Convert the given value to bytes.
687 const bytes = new FormatterService().toBytes(this.userForm.getValue('user_quota_max_size'));
688 // Finally convert the value to KiB.
689 result['max_size_kb'] = (bytes / 1024).toFixed(0) as any;
691 if (!this.userForm.getValue('user_quota_max_objects_unlimited')) {
692 result['max_objects'] = this.userForm.getValue('user_quota_max_objects');
698 * Helper function to get the arguments for the API request when the bucket
699 * quota configuration has been modified.
701 private _getBucketQuotaArgs(): Record<string, any> {
703 quota_type: 'bucket',
704 enabled: this.userForm.getValue('bucket_quota_enabled'),
708 if (!this.userForm.getValue('bucket_quota_max_size_unlimited')) {
709 // Convert the given value to bytes.
710 const bytes = new FormatterService().toBytes(this.userForm.getValue('bucket_quota_max_size'));
711 // Finally convert the value to KiB.
712 result['max_size_kb'] = (bytes / 1024).toFixed(0) as any;
714 if (!this.userForm.getValue('bucket_quota_max_objects_unlimited')) {
715 result['max_objects'] = this.userForm.getValue('bucket_quota_max_objects');
721 * Helper method to get the user candidates for S3 keys.
722 * @returns {Array} Returns a list of user identifiers.
724 private _getS3KeyUserCandidates() {
726 // Add the current user id.
727 const uid = this.getUID();
728 if (_.isString(uid) && !_.isEmpty(uid)) {
731 // Append the subusers.
732 this.subusers.forEach((subUser) => {
733 result.push(subUser.id);
735 // Note that it's possible to create multiple S3 key pairs for a user,
736 // thus we append already configured users, too.
737 this.s3Keys.forEach((key) => {
738 result.push(key.user);
740 result = _.uniq(result);
744 onMaxBucketsModeChange(mode: string) {
746 // If 'Custom' mode is selected, then ensure that the form field
747 // 'Max. buckets' contains a valid value. Set it to default if
749 if (!this.userForm.get('max_buckets').valid) {
750 this.userForm.patchValue({