]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: add account name duplicate check 69839/head
authorNaman Munet <naman.munet@ibm.com>
Tue, 30 Jun 2026 13:03:25 +0000 (18:33 +0530)
committerNaman Munet <naman.munet@ibm.com>
Mon, 6 Jul 2026 05:26:11 +0000 (10:56 +0530)
fixes: https://tracker.ceph.com/issues/77828

Signed-off-by: Naman Munet <naman.munet@ibm.com>
src/pybind/mgr/dashboard/controllers/rgw_iam.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-form/rgw-user-accounts-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-form/rgw-user-accounts-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-accounts-form/rgw-user-accounts-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-user-accounts.service.ts
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/services/rgw_iam.py

index 7bc9ff56b8c5bff8758d90967178d6327904eedb..a7b5429be995e94f4e8394bb2d2198304538c9cc 100644 (file)
@@ -1,6 +1,8 @@
 from typing import Optional
 
 from ..controllers.rgw import RgwRESTController
+from ..exceptions import DashboardException
+from ..rest_client import RequestException
 from ..security import Scope
 from ..services.rgw_iam import RgwAccounts
 from ..tools import str_to_bool
@@ -77,6 +79,23 @@ class RgwUserAccountsController(RgwRESTController):
         """
         return self.get_account(account_id, daemon_name)
 
+    @EndpointDoc("Check if account name exists",
+                 parameters={'account_name': (str, 'Account name'),
+                             'daemon_name': (str, 'Name of the daemon')})
+    @RESTController.Collection(method='GET', path='/exists')
+    def exists(self, account_name: str, daemon_name=None):
+        """
+        Check if an account with the given name exists
+        Returns True if account exists, False otherwise
+        """
+        try:
+            self.proxy(daemon_name, 'GET', 'account', {'name': account_name})
+            # If we get a result without error, the account exists
+            return True
+        except (DashboardException, RequestException):
+            # If we get an error (e.g., account not found), it doesn't exist
+            return False
+
     def get_account(self, account_id, daemon_name=None) -> dict:
         return self.proxy(daemon_name, 'GET', 'account', {'id': account_id})
 
index 7233732e63f8b9e69c49bd497bc096158b16aeca..f06875488485380ed97104da61efb2e9e0aa7937 100644 (file)
@@ -23,6 +23,9 @@
                  placeholder="Enter account name"
                  id="acc_name"
                  name="acc_name"
+                 cdValidate
+                 #accName="cdValidate"
+                 [invalid]="accName.isInvalid"
                  formControlName="name"/>
         </cds-text-label>
         <ng-template #accountIdError>
                 class="invalid-feedback">
             <ng-container i18n>This field is required.</ng-container>
           </span>
+          @if(accountForm.showError('name', formDir, 'notUnique')) {
+            <div class="invalid-feedback">
+          <ng-container i18n>An account with this name already exists.</ng-container>
+            </div>
+          }
         </ng-template>
       </div>
       <!-- Tenant -->
index 0db3d09f6b184caf8463279f9f97a4043124dc60..c138789b47304fbcabcf4e15105c1a1fc7f4cf5d 100644 (file)
@@ -1,4 +1,4 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ComponentFixture, TestBed, fakeAsync, tick, flush } from '@angular/core/testing';
 
 import { RgwUserAccountsFormComponent } from './rgw-user-accounts-form.component';
 import { ComponentsModule } from '~/app/shared/components/components.module';
@@ -10,11 +10,13 @@ import { RgwUserAccountsService } from '~/app/shared/api/rgw-user-accounts.servi
 import { ModalModule } from 'carbon-components-angular';
 import { ReactiveFormsModule } from '@angular/forms';
 import { RgwUserAccountsComponent } from '../rgw-user-accounts/rgw-user-accounts.component';
+import { DUE_TIMER } from '~/app/shared/forms/cd-validators';
 
 class MockRgwUserAccountsService {
   create = jest.fn().mockReturnValue(of(null));
   modify = jest.fn().mockReturnValue(of(null));
   setQuota = jest.fn().mockReturnValue(of(null));
+  exists = jest.fn().mockReturnValue(of(false));
 }
 
 describe('RgwUserAccountsFormComponent', () => {
@@ -50,9 +52,10 @@ describe('RgwUserAccountsFormComponent', () => {
     expect(component).toBeTruthy();
   });
 
-  it('should call create method of MockRgwUserAccountsService and show success notification', () => {
+  it('should call create method of MockRgwUserAccountsService and show success notification', fakeAsync(() => {
     component.editing = false;
-    component.accountForm.get('name').setValue('test');
+    component.accountForm.get('name').setValue('test', { emitEvent: true });
+    tick(DUE_TIMER);
     const payload = {
       account_name: 'test',
       email: '',
@@ -63,18 +66,24 @@ describe('RgwUserAccountsFormComponent', () => {
       max_group: 1000,
       max_access_keys: 4
     };
+    const mockAccount = { id: 'RGW12312312312312312', name: 'test' };
     const spy = jest.spyOn(component, 'submit');
-    const createDataSpy = jest.spyOn(rgwUserAccountsService, 'create').mockReturnValue(of(null));
+    const createDataSpy = jest
+      .spyOn(rgwUserAccountsService, 'create')
+      .mockReturnValue(of(mockAccount));
     component.submit();
     expect(component.accountForm.valid).toBe(true);
     expect(spy).toHaveBeenCalled();
     expect(createDataSpy).toHaveBeenCalled();
     expect(createDataSpy).toHaveBeenCalledWith(payload);
-  });
+    flush();
+  }));
 
-  it('should call modify method of MockRgwUserAccountsService and show success notification', () => {
+  it('should call modify method of MockRgwUserAccountsService and show success notification', fakeAsync(() => {
     component.editing = true;
-    component.accountForm.get('name').setValue('test');
+    component.originalAccountName = 'test';
+    component.accountForm.get('name').setValue('test', { emitEvent: true });
+    tick(DUE_TIMER);
     component.accountForm.get('id').setValue('RGW12312312312312312');
     component.accountForm.get('email').setValue('test@test.com');
     const payload = {
@@ -88,14 +97,18 @@ describe('RgwUserAccountsFormComponent', () => {
       max_group: 1000,
       max_access_keys: 4
     };
+    const mockAccount = { id: 'RGW12312312312312312', name: 'test' };
     const spy = jest.spyOn(component, 'submit');
-    const modifyDataSpy = jest.spyOn(rgwUserAccountsService, 'modify').mockReturnValue(of(null));
+    const modifyDataSpy = jest
+      .spyOn(rgwUserAccountsService, 'modify')
+      .mockReturnValue(of(mockAccount));
     component.submit();
     expect(component.accountForm.valid).toBe(true);
     expect(spy).toHaveBeenCalled();
     expect(modifyDataSpy).toHaveBeenCalled();
     expect(modifyDataSpy).toHaveBeenCalledWith(payload);
-  });
+    flush();
+  }));
 
   it('should call setQuota for "account" if account quota is dirty', () => {
     component.accountForm.get('id').setValue('123');
index 1c1506d7afea7c20eaa436e2a159e5645da6a3c2..da78beb5d69e0964bfb8dac791a020a0b60efb9b 100644 (file)
@@ -26,6 +26,7 @@ export class RgwUserAccountsFormComponent extends CdForm implements OnInit {
   resource: string;
   editing: boolean = false;
   submitObservables: Observable<Object>[] = [];
+  originalAccountName: string = '';
 
   constructor(
     private router: Router,
@@ -48,6 +49,7 @@ export class RgwUserAccountsFormComponent extends CdForm implements OnInit {
       this.route.paramMap.subscribe((params: any) => {
         const account_id = params.get('id');
         this.rgwUserAccountsService.get(account_id).subscribe((accountData: Account) => {
+          this.originalAccountName = accountData.name;
           // Get the default values.
           const defaults = _.clone(this.accountForm.value);
           // Extract the values displayed in the form.
@@ -112,7 +114,11 @@ export class RgwUserAccountsFormComponent extends CdForm implements OnInit {
     this.accountForm = this.formBuilder.group({
       id: [''],
       tenant: [''],
-      name: ['', Validators.required],
+      name: [
+        '',
+        [Validators.required],
+        [CdValidators.unique(this.rgwUserAccountsService.exists, this.rgwUserAccountsService)]
+      ],
       email: ['', CdValidators.email],
       max_users_mode: ['1'],
       max_users: [
index b5035fd344a6df76874d57132faab4765c403a5a..4c53a46c054ad2d1bb61ff2993cf81af0806a8a4 100644 (file)
@@ -54,4 +54,11 @@ export class RgwUserAccountsService {
   ) {
     return this.http.put(`${this.url}/${account_id}/quota`, payload);
   }
+
+  exists(account_name: string): Observable<boolean> {
+    return this.rgwDaemonService.request((params: HttpParams) => {
+      params = params.append('account_name', account_name);
+      return this.http.get<boolean>(`${this.url}/exists`, { params });
+    });
+  }
 }
index 83f0ca149111e66cdb63a68bc436cb9ceda53148..3c8f18041881385c88b7f1d5e5d6c7f1111c4741 100644 (file)
@@ -18053,6 +18053,47 @@ paths:
       summary: Update RGW account info
       tags:
       - RgwUserAccounts
+  /api/rgw/accounts/exists:
+    get:
+      description: "\n        Check if an account with the given name exists\n   \
+        \     Returns True if account exists, False otherwise\n        "
+      parameters:
+      - description: Account name
+        in: query
+        name: account_name
+        required: true
+        schema:
+          type: string
+      - allowEmptyValue: true
+        description: Name of the daemon
+        in: query
+        name: daemon_name
+        schema:
+          type: string
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                type: object
+            application/vnd.ceph.api.v1.0+json:
+              schema:
+                type: object
+          description: OK
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      summary: Check if account name exists
+      tags:
+      - RgwUserAccounts
   /api/rgw/accounts/{account_id}:
     delete:
       description: "\n        Removes an account\n\n        :param account_id: account\
index ed1bfe09581d27003f0054490ec5db6d5812f2da..928560f154d50b669eedc3587d07e2201437683b 100644 (file)
@@ -25,6 +25,15 @@ class RgwAccounts:
         get_accounts_cmd = ['account', 'list']
         return cls.send_rgw_cmd(get_accounts_cmd)
 
+    @classmethod
+    def get_account_by_name(cls, account_name: str):
+        """
+        Get account info by account name
+        Returns account info if found, raises exception otherwise
+        """
+        get_account_cmd = ['account', 'info', '--account-name', account_name]
+        return cls.send_rgw_cmd(get_account_cmd)
+
     @classmethod
     def set_quota(cls, quota_type: str, account_id: str, max_size: str, max_objects: str,
                   enabled: bool):