From 10af90547fd08162bfb1eadf522a7f6d30fd773a Mon Sep 17 00:00:00 2001 From: Pedro Gonzalez Gomez Date: Fri, 10 Jan 2025 10:23:37 +0100 Subject: [PATCH] mgr/dashboard: add actions to create, edit and delete smb join-auth and usersgroups resources Add join-auth and usersgroups resources management and improve the way to select those from the smb cluster form using a dropdown. Add option to navigate to join-auth/usersgroups resource create form from smb form Add some additional fixes left over from previous work, such as adding helper texts or adding missing smb cluster form fields Fixes: https://tracker.ceph.com/issues/69483 Signed-off-by: Pedro Gonzalez Gomez --- src/pybind/mgr/dashboard/controllers/smb.py | 189 +++++-- .../frontend/src/app/app-routing.module.ts | 22 + .../src/app/ceph/cephfs/cephfs.module.ts | 3 +- .../smb-cluster-form.component.html | 255 ++++++--- .../smb-cluster-form.component.scss | 5 + .../smb-cluster-form.component.spec.ts | 15 + .../smb-cluster-form.component.ts | 52 +- .../smb-cluster-list.component.ts | 4 +- .../smb-domain-setting-modal.component.html | 131 +++-- .../smb-domain-setting-modal.component.scss | 5 + ...smb-domain-setting-modal.component.spec.ts | 14 + .../smb-domain-setting-modal.component.ts | 43 +- .../smb-join-auth-form.component.html | 126 +++++ .../smb-join-auth-form.component.scss | 0 .../smb-join-auth-form.component.spec.ts | 84 +++ .../smb-join-auth-form.component.ts | 113 ++++ .../smb-join-auth-list.component.html | 8 + .../smb-join-auth-list.component.ts | 67 ++- .../ceph/smb/smb-tabs/smb-tabs.component.html | 2 +- .../smb-usersgroups-form.component.html | 238 +++++++++ .../smb-usersgroups-form.component.scss | 5 + .../smb-usersgroups-form.component.spec.ts | 120 +++++ .../smb-usersgroups-form.component.ts | 158 ++++++ .../smb-usersgroups-list.component.html | 12 +- .../smb-usersgroups-list.component.ts | 69 ++- .../frontend/src/app/ceph/smb/smb.model.ts | 31 +- .../frontend/src/app/ceph/smb/smb.module.ts | 14 +- .../src/app/shared/api/smb.service.spec.ts | 47 +- .../src/app/shared/api/smb.service.ts | 32 ++ .../shared/services/task-message.service.ts | 32 ++ src/pybind/mgr/dashboard/openapi.yaml | 505 +++++++++++++++++- src/pybind/mgr/dashboard/tests/test_smb.py | 66 +++ 32 files changed, 2194 insertions(+), 273 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-form/smb-join-auth-form.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-form/smb-join-auth-form.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-form/smb-join-auth-form.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-form/smb-join-auth-form.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.ts diff --git a/src/pybind/mgr/dashboard/controllers/smb.py b/src/pybind/mgr/dashboard/controllers/smb.py index 7a1390f151b7c..9020d4cbd1946 100644 --- a/src/pybind/mgr/dashboard/controllers/smb.py +++ b/src/pybind/mgr/dashboard/controllers/smb.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- - import json import logging from functools import wraps @@ -8,7 +7,7 @@ from typing import List from smb.enums import Intent from smb.proto import Simplified -from smb.resources import Cluster, Share +from smb.resources import Cluster, JoinAuth, Share, UsersAndGroups from dashboard.controllers._docs import EndpointDoc from dashboard.controllers._permissions import CreatePermission, DeletePermission @@ -42,35 +41,6 @@ CLUSTER_SCHEMA = { }, "Placement configuration for the resource") } -CLUSTER_SCHEMA_RESULTS = { - "results": ([{ - "resource": ({ - "resource_type": (str, "ceph.smb.cluster"), - "cluster_id": (str, "Unique identifier for the cluster"), - "auth_mode": (str, "Either 'active-directory' or 'user'"), - "intent": (str, "Desired state of the resource, e.g., 'present' or 'removed'"), - "domain_settings": ({ - "realm": (str, "Domain realm, e.g., 'DOMAIN1.SINK.TEST'"), - "join_sources": ([{ - "source_type": (str, "resource"), - "ref": (str, "Reference identifier for the join auth resource") - }], "List of join auth sources for domain settings") - }, "Domain-specific settings for active-directory auth mode"), - "user_group_settings": ([{ - "source_type": (str, "resource"), - "ref": (str, "Reference identifier for the user group resource") - }], "User group settings for user auth mode (optional)"), - "custom_dns": ([str], "List of custom DNS server addresses (optional)"), - "placement": ({ - "count": (int, "Number of instances to place") - }, "Placement configuration for the resource (optional)"), - }, "Resource details"), - "state": (str, "State of the resource"), - "success": (bool, "Indicates whether the operation was successful") - }], "List of results with resource details"), - "success": (bool, "Overall success status of the operation") -} - LIST_CLUSTER_SCHEMA = [CLUSTER_SCHEMA] SHARE_SCHEMA = { @@ -125,29 +95,26 @@ USERSGROUPS_SCHEMA = { LIST_USERSGROUPS_SCHEMA = [USERSGROUPS_SCHEMA] -SHARE_SCHEMA_RESULTS = { - "results": ([{ - "resource": ({ - "resource_type": (str, "ceph.smb.share"), - "cluster_id": (str, "Unique identifier for the cluster"), - "share_id": (str, "Unique identifier for the share"), - "intent": (str, "Desired state of the resource, e.g., 'present' or 'removed'"), - "name": (str, "Name of the share"), - "readonly": (bool, "Indicates if the share is read-only"), - "browseable": (bool, "Indicates if the share is browseable"), - "cephfs": ({ - "volume": (str, "Name of the CephFS file system"), - "path": (str, "Path within the CephFS file system"), - "subvolumegroup": (str, "Subvolume Group in CephFS file system"), - "subvolume": (str, "Subvolume within the CephFS file system"), - "provider": (str, "Provider of the CephFS share, e.g., 'samba-vfs'") - }, "Configuration for the CephFS share") - }, "Resource details"), - "state": (str, "State of the resource"), - "success": (bool, "Indicates whether the operation was successful") - }], "List of results with resource details"), - "success": (bool, "Overall success status of the operation") -} + +def add_results_to_schema(schema): + + results_field = { + "results": ([{ + "resource": (schema, "Resource"), + "state": (str, "The current state of the resource,\ + e.g., 'created', 'updated', 'deleted'"), + "success": (bool, "Indicates if the operation was successful"), + }], "List of operation results"), + "success": (bool, "Indicates if the overall operation was successful") + } + + return results_field + + +CLUSTER_SCHEMA_RESULTS = add_results_to_schema(CLUSTER_SCHEMA) +SHARE_SCHEMA_RESULTS = add_results_to_schema(SHARE_SCHEMA) +JOIN_AUTH_SCHEMA_RESULTS = add_results_to_schema(JOIN_AUTH_SCHEMA) +USERSGROUPS_SCHEMA_RESULTS = add_results_to_schema(JOIN_AUTH_SCHEMA_RESULTS) def raise_on_failure(func): @@ -319,7 +286,7 @@ class SMBJoinAuth(RESTController): @ReadPermission @EndpointDoc("List smb join authorization resources", responses={200: LIST_JOIN_AUTH_SCHEMA}) - def list(self, join_auth: str = '') -> List[Share]: + def list(self) -> List[JoinAuth]: """ List all smb join auth resources @@ -329,9 +296,61 @@ class SMBJoinAuth(RESTController): res = mgr.remote( 'smb', 'show', - [f'{self._resource}.{join_auth}' if join_auth else self._resource]) + [self._resource]) return res['resources'] if 'resources' in res else [res] + @ReadPermission + @EndpointDoc("Get smb join authorization resource", + responses={200: JOIN_AUTH_SCHEMA}) + def get(self, auth_id: str) -> JoinAuth: + """ + Get Join auth resource + + :return: Returns join auth. + :rtype: Dict + """ + res = mgr.remote( + 'smb', + 'show', + [f'{self._resource}.{auth_id}']) + return res['resources'] if 'resources' in res else res + + @CreatePermission + @EndpointDoc("Create smb join auth", + parameters={ + 'auth_id': (str, 'auth_id'), + 'username': (str, 'username'), + 'password': (str, 'password') + }, + responses={201: JOIN_AUTH_SCHEMA_RESULTS}) + def create(self, join_auth: JoinAuth) -> Simplified: + """ + Create smb join auth resource + + :return: Returns join auth resource. + :rtype: Dict + """ + return mgr.remote('smb', 'apply_resources', json.dumps(join_auth)).to_simplified() + + @CreatePermission + @EndpointDoc("Delete smb join auth", + parameters={ + 'auth_id': (str, 'auth_id') + }, + responses={204: None}) + def delete(self, auth_id: str) -> None: + """ + Delete smb join auth resource + + :param auth_id: Join Auth identifier + :return: None. + """ + resource = {} + resource['resource_type'] = self._resource + resource['auth_id'] = auth_id + resource['intent'] = Intent.REMOVED + return mgr.remote('smb', 'apply_resources', json.dumps(resource)).one().to_simplified() + @APIRouter('/smb/usersgroups', Scope.SMB) @APIDoc("SMB Users Groups API", "SMB") @@ -341,19 +360,71 @@ class SMBUsersgroups(RESTController): @ReadPermission @EndpointDoc("List smb user resources", responses={200: LIST_USERSGROUPS_SCHEMA}) - def list(self, users_groups: str = '') -> List[Share]: + def list(self) -> List[UsersAndGroups]: """ List all smb usersgroups resources - :return: Returns list of usersgroups. + :return: Returns list of usersgroups :rtype: List[Dict] """ res = mgr.remote( 'smb', 'show', - [f'{self._resource}.{users_groups}' if users_groups else self._resource]) + [self._resource]) return res['resources'] if 'resources' in res else [res] + @ReadPermission + @EndpointDoc("Get smb usersgroups authorization resource", + responses={200: USERSGROUPS_SCHEMA}) + def get(self, users_groups_id: str) -> UsersAndGroups: + """ + Get Users and groups resource + + :return: Returns join auth. + :rtype: Dict + """ + res = mgr.remote( + 'smb', + 'show', + [f'{self._resource}.{users_groups_id}']) + return res['resources'] if 'resources' in res else res + + @CreatePermission + @EndpointDoc("Create smb usersgroups", + parameters={ + 'users_groups_id': (str, 'users_groups_id'), + 'username': (str, 'username'), + 'password': (str, 'password') + }, + responses={201: USERSGROUPS_SCHEMA_RESULTS}) + def create(self, usersgroups: UsersAndGroups) -> Simplified: + """ + Create smb usersgroups resource + + :return: Returns usersgroups resource. + :rtype: Dict + """ + return mgr.remote('smb', 'apply_resources', json.dumps(usersgroups)).to_simplified() + + @CreatePermission + @EndpointDoc("Delete smb join auth", + parameters={ + 'users_groups_id': (str, 'users_groups_id') + }, + responses={204: None}) + def delete(self, users_groups_id: str) -> None: + """ + Delete smb usersgroups resource + + :param users_group_id: Users identifier + :return: None. + """ + resource = {} + resource['resource_type'] = self._resource + resource['users_groups_id'] = users_groups_id + resource['intent'] = Intent.REMOVED + return mgr.remote('smb', 'apply_resources', json.dumps(resource)).one().to_simplified() + @UIRouter('/smb') class SMBStatus(RESTController): diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 7fb568cad79be..c755dd4f871fe 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -54,6 +54,8 @@ import { MultiClusterDetailsComponent } from './ceph/cluster/multi-cluster/multi import { SmbClusterFormComponent } from './ceph/smb/smb-cluster-form/smb-cluster-form.component'; import { SmbTabsComponent } from './ceph/smb/smb-tabs/smb-tabs.component'; import { SmbShareFormComponent } from './ceph/smb/smb-share-form/smb-share-form.component'; +import { SmbJoinAuthFormComponent } from './ceph/smb/smb-join-auth-form/smb-join-auth-form.component'; +import { SmbUsersgroupsFormComponent } from './ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component'; @Injectable() export class PerformanceCounterBreadcrumbsResolver extends BreadcrumbsResolver { @@ -457,6 +459,26 @@ const routes: Routes = [ path: `share/${URLVerbs.CREATE}/:clusterId`, component: SmbShareFormComponent, data: { breadcrumbs: ActionLabels.CREATE } + }, + { + path: `ad/${URLVerbs.CREATE}`, + component: SmbJoinAuthFormComponent, + data: { breadcrumbs: ActionLabels.CREATE } + }, + { + path: `ad/${URLVerbs.EDIT}/:authId`, + component: SmbJoinAuthFormComponent, + data: { breadcrumbs: ActionLabels.EDIT } + }, + { + path: `standalone/${URLVerbs.CREATE}`, + component: SmbUsersgroupsFormComponent, + data: { breadcrumbs: ActionLabels.CREATE } + }, + { + path: `standalone/${URLVerbs.EDIT}/:usersGroupsId`, + component: SmbUsersgroupsFormComponent, + data: { breadcrumbs: ActionLabels.EDIT } } ] } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts index 55f957c59c6c1..da530d27dc1c5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts @@ -51,6 +51,7 @@ import { } from 'carbon-components-angular'; import AddIcon from '@carbon/icons/es/add/32'; +import LaunchIcon from '@carbon/icons/es/launch/32'; import Close from '@carbon/icons/es/close/32'; import Trash from '@carbon/icons/es/trash-can/32'; @@ -109,6 +110,6 @@ import Trash from '@carbon/icons/es/trash-can/32'; }) export class CephfsModule { constructor(private iconService: IconService) { - this.iconService.registerAll([AddIcon, Close, Trash]); + this.iconService.registerAll([AddIcon, LaunchIcon, Close, Trash]); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-cluster-form/smb-cluster-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-cluster-form/smb-cluster-form.component.html index 887727c04926f..d21972ce0ec08 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-cluster-form/smb-cluster-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-cluster-form/smb-cluster-form.component.html @@ -15,7 +15,7 @@ Domain Settings + cdRequiredField="Domain Settings">Active Directory (AD) Settings
Specify the Realm and Join Sources in the Domain Settings field.Specify the Realm and AD access resources in the Domain Settings field.
+ *ngFor="let _ of joinSources.controls; index as i">
-
- User Group Id - - - +
+ + + + + This field is required. + >This field is required.
-
+
- +
@@ -183,12 +182,25 @@ type="button" (click)="addUserGroupSetting()" i18n> - Add User Group Id + Add user group + +
@@ -269,40 +281,38 @@ >
- -
- - - -
- + *ngFor="let _ of custom_dns.controls; index as i">
+ DNS + placeholder="192.168.76.204"/> +
-
- - +
+ + +
@@ -313,12 +323,123 @@ type="button" (click)="addCustomDns()" i18n> - Add Custom DNS - + Add custom DNS + + + One or more IP Addresses that will be + applied to the Samba containers to override + the default DNS resolver(s). This option is + intended to be used when the host Ceph node + is not configured to resolve DNS entries within + AD domain(s). + +
+ + +
+ + + +
+ + + + +
+ +
+ Address + + + + + This field is required. + + +
+ +
+ Destination + + +
+
+ + + +
+
+
+
+
+ + Assign virtual IP addresses that will be managed + by the clustering subsystem and may automatically + move between nodes running Samba containers.
{ let component: SmbClusterFormComponent; @@ -88,4 +91,16 @@ describe('SmbClusterFormComponent', () => { component.deleteDomainSettingsModal(); expect(component).toBeTruthy(); }); + + it('should get usersgroups resources on user authmode', () => { + component.smbForm.get('auth_mode').setValue(AUTHMODE.User); + component.usersGroups$ = of([FOO_USERSGROUPS]); + fixture.whenStable().then(() => { + const options = fixture.debugElement.queryAll(By.css('select option')); + + expect(options.length).toBe(1); + expect(options[0].nativeElement.value).toBe('foo'); + expect(options[0].nativeElement.textContent).toBe('foo'); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-cluster-form/smb-cluster-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-cluster-form/smb-cluster-form.component.ts index 2ab36f571316a..cbbcd4abca1d9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-cluster-form/smb-cluster-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-cluster-form/smb-cluster-form.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { forkJoin, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; @@ -12,7 +12,9 @@ import { DomainSettings, JoinSource, CLUSTER_RESOURCE, - ClusterRequestModel + ClusterRequestModel, + SMBUsersGroups, + PublicAddress } from '../smb.model'; import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; import { Icons } from '~/app/shared/enum/icons.enum'; @@ -31,6 +33,7 @@ import { SmbService } from '~/app/shared/api/smb.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { SmbDomainSettingModalComponent } from '../smb-domain-setting-modal/smb-domain-setting-modal.component'; import { CephServicePlacement } from '~/app/shared/models/service.interface'; +import { USERSGROUPS_URL } from '../smb-usersgroups-list/smb-usersgroups-list.component'; @Component({ selector: 'cd-smb-cluster-form', @@ -43,6 +46,7 @@ export class SmbClusterFormComponent extends CdForm implements OnInit { hasOrchestrator: boolean; orchStatus$: Observable; allClustering: string[] = []; + CLUSTERING = CLUSTERING; selectedLabels: string[] = []; selectedHosts: string[] = []; action: string; @@ -50,6 +54,7 @@ export class SmbClusterFormComponent extends CdForm implements OnInit { icons = Icons; domainSettingsObject: DomainSettings; modalData$!: Observable; + usersGroups$: Observable; constructor( private hostService: HostService, @@ -59,7 +64,8 @@ export class SmbClusterFormComponent extends CdForm implements OnInit { private orchService: OrchestratorService, private modalService: ModalCdsService, private taskWrapperService: TaskWrapperService, - private router: Router + private router: Router, + private cd: ChangeDetectorRef ) { super(); this.resource = $localize`Cluster`; @@ -67,12 +73,12 @@ export class SmbClusterFormComponent extends CdForm implements OnInit { } ngOnInit() { this.action = this.actionLabels.CREATE; + this.usersGroups$ = this.smbService.listUsersGroups(); this.smbService.modalData$.subscribe((data: DomainSettings) => { this.domainSettingsObject = data; this.smbForm.get('domain_settings').setValue(data?.realm); }); this.createForm(); - this.hostsAndLabels$ = forkJoin({ hosts: this.hostService.getAllHosts(), labels: this.hostService.getLabels() @@ -114,7 +120,8 @@ export class SmbClusterFormComponent extends CdForm implements OnInit { joinSources: new FormArray([]), clustering: new UntypedFormControl( CLUSTERING.Default.charAt(0).toUpperCase() + CLUSTERING.Default.slice(1) - ) + ), + public_addrs: new FormArray([]) }); this.orchService.status().subscribe((status) => { @@ -148,7 +155,7 @@ export class SmbClusterFormComponent extends CdForm implements OnInit { } // Domain Setting should be optional if authMode is "Users" } else if (authMode === AUTHMODE.User) { - const control = new FormControl('', Validators.required); + const control = new FormControl(null, Validators.required); userGroupSettingsControl.push(control); domainSettingsControl.setErrors(null); domainSettingsControl.clearValidators(); @@ -241,6 +248,14 @@ export class SmbClusterFormComponent extends CdForm implements OnInit { requestModel.cluster_resource.custom_dns = rawFormValue.custom_dns; } + if (rawFormValue.public_addrs?.length > 0) { + requestModel.cluster_resource.public_addrs = rawFormValue.public_addrs.map( + (publicAddress: PublicAddress) => { + return publicAddress.destination ? publicAddress : { address: publicAddress.address }; + } + ); + } + if (rawFormValue.clustering && rawFormValue.clustering.toLowerCase() !== CLUSTERING.Default) { requestModel.cluster_resource.clustering = rawFormValue.clustering.toLowerCase(); } @@ -290,21 +305,44 @@ export class SmbClusterFormComponent extends CdForm implements OnInit { return this.smbForm.get('custom_dns') as FormArray; } + get public_addrs() { + return this.smbForm.get('public_addrs') as FormArray; + } + addUserGroupSetting() { - const control = new FormControl('', Validators.required); + const control = new FormControl(null, Validators.required); this.joinSources.push(control); } + navigateCreateUsersGroups() { + this.router.navigate([`${USERSGROUPS_URL}/${URLVerbs.CREATE}`]); + } + addCustomDns() { const control = new FormControl('', Validators.required); this.custom_dns.push(control); } + addPublicAddrs() { + const control = this.formBuilder.group({ + address: ['', Validators.required], + destination: [''] + }); + this.public_addrs.push(control); + } + removeUserGroupSetting(index: number) { this.joinSources.removeAt(index); + this.cd.detectChanges(); } removeCustomDNS(index: number) { this.custom_dns.removeAt(index); + this.cd.detectChanges(); + } + + removePublicAddrs(index: number) { + this.public_addrs.removeAt(index); + this.cd.detectChanges(); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-cluster-list/smb-cluster-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-cluster-list/smb-cluster-list.component.ts index 4413d6486a1fa..cdbd2a5f9e26e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-cluster-list/smb-cluster-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-cluster-list/smb-cluster-list.component.ts @@ -19,7 +19,6 @@ import { Icons } from '~/app/shared/enum/icons.enum'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { URLBuilderService } from '~/app/shared/services/url-builder.service'; import { SMBCluster } from '../smb.model'; -import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; @@ -42,7 +41,6 @@ export class SmbClusterListComponent extends ListWithDetails implements OnInit { selection = new CdTableSelection(); smbClusters$: Observable; subject$ = new BehaviorSubject([]); - modalRef: NgbModalRef; constructor( private authStorageService: AuthStorageService, @@ -71,7 +69,7 @@ export class SmbClusterListComponent extends ListWithDetails implements OnInit { ]; this.tableActions = [ { - name: `${this.actionLabels.CREATE}`, + name: `${this.actionLabels.CREATE} cluster`, permission: 'create', icon: Icons.add, routerLink: () => this.urlBuilder.getCreate(), diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-domain-setting-modal/smb-domain-setting-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-domain-setting-modal/smb-domain-setting-modal.component.html index 8d23a4919a89b..108e25a5a646b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-domain-setting-modal/smb-domain-setting-modal.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-domain-setting-modal/smb-domain-setting-modal.component.html @@ -15,7 +15,6 @@
@@ -40,65 +38,86 @@
- - - -
-
- - - This field is required. + + +
+
+ - -
-
- - - + + + + + + This field is required. + + +
+
+ + + +
-
+ - -
- +
+ + +
+ + + { let component: SmbDomainSettingModalComponent; @@ -51,4 +54,15 @@ describe('SmbDomainSettingModalComponent', () => { component.submit(); expect(component).toBeTruthy(); }); + + it('should list available join sources', () => { + component.joinAuths$ = of([FOO_JOIN_AUTH]); + fixture.whenStable().then(() => { + const options = fixture.debugElement.queryAll(By.css('select option')); + + expect(options.length).toBe(1); + expect(options[0].nativeElement.value).toBe('foo'); + expect(options[0].nativeElement.textContent).toBe('foo'); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-domain-setting-modal/smb-domain-setting-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-domain-setting-modal/smb-domain-setting-modal.component.ts index 7a9cf1033d41d..24415b9b1d80f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-domain-setting-modal/smb-domain-setting-modal.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-domain-setting-modal/smb-domain-setting-modal.component.ts @@ -1,7 +1,7 @@ import { ChangeDetectorRef, Component, Inject, OnInit, Optional } from '@angular/core'; import { FormArray, FormControl, FormGroup, UntypedFormControl, Validators } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; import { CdValidators } from '~/app/shared/forms/cd-validators'; @@ -9,7 +9,10 @@ import { NotificationService } from '~/app/shared/services/notification.service' import { RgwRealmService } from '~/app/shared/api/rgw-realm.service'; import { SmbService } from '~/app/shared/api/smb.service'; import { CdForm } from '~/app/shared/forms/cd-form'; -import { DomainSettings } from '../smb.model'; +import { DomainSettings, JoinSource, SMBJoinAuth } from '../smb.model'; +import { Observable } from 'rxjs'; +import { Router } from '@angular/router'; +import { JOINAUTH_URL } from '../smb-join-auth-list/smb-join-auth-list.component'; @Component({ selector: 'cd-smb-domain-setting-modal', @@ -19,6 +22,7 @@ import { DomainSettings } from '../smb.model'; export class SmbDomainSettingModalComponent extends CdForm implements OnInit { domainSettingsForm: CdFormGroup; realmNames: string[]; + joinAuths$: Observable; constructor( public activeModal: NgbActiveModal, @@ -27,6 +31,7 @@ export class SmbDomainSettingModalComponent extends CdForm implements OnInit { public notificationService: NotificationService, public smbService: SmbService, private cd: ChangeDetectorRef, + private router: Router, @Optional() @Inject('action') public action: string, @Optional() @Inject('resource') public resource: string, @Optional() @@ -35,7 +40,7 @@ export class SmbDomainSettingModalComponent extends CdForm implements OnInit { ) { super(); this.action = this.actionLabels.UPDATE; - this.resource = $localize`Domain Setting`; + this.resource = $localize`Active Directory (AD) parameters`; } private createForm() { @@ -55,25 +60,18 @@ export class SmbDomainSettingModalComponent extends CdForm implements OnInit { ngOnInit(): void { this.createForm(); this.loadingReady(); + this.joinAuths$ = this.smbService.listJoinAuths(); this.domainSettingsForm.get('realm').setValue(this.domainSettingsObject?.realm); const join_sources = this.domainSettingsForm.get('join_sources') as FormArray; if (this.domainSettingsObject?.join_sources) { - this.domainSettingsObject.join_sources.forEach((source: { ref: string }) => { - join_sources.push( - new FormGroup({ - ref: new FormControl(source.ref || '', Validators.required) - }) - ); + this.domainSettingsObject.join_sources.forEach((source: JoinSource) => { + join_sources.push(this.newJoinSource(source)); }); } if (!this.domainSettingsObject) { - this.join_sources.push( - new FormGroup({ - ref: new FormControl('', Validators.required) - }) - ); + this.addJoinSource(); } else { this.action = this.actionLabels.EDIT; } @@ -88,15 +86,22 @@ export class SmbDomainSettingModalComponent extends CdForm implements OnInit { return this.domainSettingsForm.get('join_sources') as FormArray; } + newJoinSource(joinSource?: JoinSource) { + return new FormGroup({ + ref: new FormControl(joinSource?.ref || null, Validators.required) + }); + } + addJoinSource() { - this.join_sources.push( - new FormGroup({ - ref: new FormControl('', Validators.required) - }) - ); + this.join_sources.push(this.newJoinSource()); this.cd.detectChanges(); } + navigateCreateJoinSource() { + this.closeModal(); + this.router.navigate([`${JOINAUTH_URL}/${URLVerbs.CREATE}`]); + } + removeJoinSource(index: number) { const join_sources = this.domainSettingsForm.get('join_sources') as FormArray; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-form/smb-join-auth-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-form/smb-join-auth-form.component.html new file mode 100644 index 0000000000000..52b4c0541243f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-form/smb-join-auth-form.component.html @@ -0,0 +1,126 @@ +
+
+
+ {{ action | titlecase }} {{ resource | upperFirst }} +
+ + +
+ Active directory access resource name + + + + This field is required. + +
+ + +
+ Username + + + + This field is required. + + +
+ + +
+ Password + + + + This field is required. + + +
+ + +
+ + + + +
+ +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-form/smb-join-auth-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-form/smb-join-auth-form.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-form/smb-join-auth-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-form/smb-join-auth-form.component.spec.ts new file mode 100644 index 0000000000000..810a5c9625d82 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-form/smb-join-auth-form.component.spec.ts @@ -0,0 +1,84 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SmbJoinAuthFormComponent } from './smb-join-auth-form.component'; +import { ToastrModule } from 'ngx-toastr'; +import { SharedModule } from '~/app/shared/shared.module'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideRouter } from '@angular/router'; +import { ReactiveFormsModule } from '@angular/forms'; +import { SmbService } from '~/app/shared/api/smb.service'; +import { JOIN_AUTH_RESOURCE } from '../smb.model'; +import { of } from 'rxjs'; + +export const FOO_JOIN_AUTH = { + auth_id: 'foo', + auth: { + username: 'user', + password: 'pass' + }, + resource_type: JOIN_AUTH_RESOURCE +}; + +describe('SmbJoinAuthFormComponent', () => { + let component: SmbJoinAuthFormComponent; + let fixture: ComponentFixture; + let createJoinAuth: jasmine.Spy; + let getJoinAuth: jasmine.Spy; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ToastrModule.forRoot(), SharedModule, ReactiveFormsModule], + declarations: [SmbJoinAuthFormComponent], + providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])] + }).compileComponents(); + + fixture = TestBed.createComponent(SmbJoinAuthFormComponent); + component = fixture.componentInstance; + component.ngOnInit(); + createJoinAuth = spyOn(TestBed.inject(SmbService), 'createJoinAuth'); + getJoinAuth = spyOn(TestBed.inject(SmbService), 'getJoinAuth'); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set form invalid if any required fields are missing', () => { + component.form.controls['authId'].setValue(''); + component.form.controls['username'].setValue(''); + component.form.controls['password'].setValue(''); + expect(component.form.valid).not.toBeNull(); + }); + + it('should submit the form', () => { + component.form.controls['authId'].setValue('foo'); + component.form.controls['username'].setValue('user'); + component.form.controls['password'].setValue('pass'); + component.form.controls['linkedToCluster'].setValue(undefined); + + component.submit(); + + expect(createJoinAuth).toHaveBeenCalledWith(FOO_JOIN_AUTH); + }); + + describe('when editing', () => { + beforeEach(() => { + component.editing = true; + getJoinAuth.and.returnValue(of(FOO_JOIN_AUTH)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should get resource data and set form fields with it', () => { + expect(getJoinAuth).toHaveBeenCalled(); + expect(component.form.value).toEqual({ + authId: 'foo', + username: 'user', + password: 'pass', + linkedToCluster: undefined + }); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-form/smb-join-auth-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-form/smb-join-auth-form.component.ts new file mode 100644 index 0000000000000..308ad15f36d63 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-form/smb-join-auth-form.component.ts @@ -0,0 +1,113 @@ +import { Component, OnInit } from '@angular/core'; +import { FormControl, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { SmbService } from '~/app/shared/api/smb.service'; +import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdForm } from '~/app/shared/forms/cd-form'; +import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { FinishedTask } from '~/app/shared/models/finished-task'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { JOIN_AUTH_RESOURCE, SMBCluster, SMBJoinAuth } from '../smb.model'; +import { Observable } from 'rxjs'; +import { JOINAUTH_URL } from '../smb-join-auth-list/smb-join-auth-list.component'; +import { Location } from '@angular/common'; + +@Component({ + selector: 'cd-smb-join-auth-form', + templateUrl: './smb-join-auth-form.component.html', + styleUrls: ['./smb-join-auth-form.component.scss'] +}) +export class SmbJoinAuthFormComponent extends CdForm implements OnInit { + form: CdFormGroup; + action: string; + resource: string; + editing: boolean; + icons = Icons; + + smbClusters$: Observable; + + constructor( + private actionLabels: ActionLabelsI18n, + private taskWrapperService: TaskWrapperService, + private formBuilder: CdFormBuilder, + private smbService: SmbService, + private router: Router, + private route: ActivatedRoute, + private location: Location + ) { + super(); + this.editing = this.router.url.startsWith(`${JOINAUTH_URL}/${URLVerbs.EDIT}`); + this.resource = $localize`Active directory (AD) access resource`; + } + + ngOnInit() { + this.action = this.actionLabels.CREATE; + this.smbClusters$ = this.smbService.listClusters(); + this.createForm(); + + if (this.editing) { + this.action = this.actionLabels.UPDATE; + let editingAuthId: string; + this.route.params.subscribe((params: { authId: string }) => { + editingAuthId = params.authId; + }); + + this.smbService.getJoinAuth(editingAuthId).subscribe((joinAuth: SMBJoinAuth) => { + this.form.get('authId').setValue(joinAuth.auth_id); + this.form.get('username').setValue(joinAuth.auth.username); + this.form.get('password').setValue(joinAuth.auth.password); + this.form.get('linkedToCluster').setValue(joinAuth.linked_to_cluster); + }); + } + } + + createForm() { + this.form = this.formBuilder.group({ + authId: new FormControl('', { + validators: [Validators.required] + }), + username: new FormControl('', { + validators: [Validators.required] + }), + password: new FormControl('', { + validators: [Validators.required] + }), + linkedToCluster: new FormControl(null) + }); + } + + submit() { + const authId = this.form.getValue('authId'); + const username = this.form.getValue('username'); + const password = this.form.getValue('password'); + const linkedToCluster = this.form.getValue('linkedToCluster'); + const BASE_URL = 'smb/ad/'; + + const joinAuth: SMBJoinAuth = { + resource_type: JOIN_AUTH_RESOURCE, + auth_id: authId, + auth: { username: username, password: password }, + linked_to_cluster: linkedToCluster + }; + + const self = this; + let taskUrl = `${BASE_URL}${this.editing ? URLVerbs.EDIT : URLVerbs.CREATE}`; + this.taskWrapperService + .wrapTaskAroundCall({ + task: new FinishedTask(taskUrl, { + authId: authId + }), + call: this.smbService.createJoinAuth(joinAuth) + }) + .subscribe({ + error() { + self.form.setErrors({ cdSubmitButton: true }); + }, + complete: () => { + this.location.back(); + } + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-list/smb-join-auth-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-list/smb-join-auth-list.component.html index f1818e7ae3a94..56ee93b88da74 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-list/smb-join-auth-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-list/smb-join-auth-list.component.html @@ -5,5 +5,13 @@ selectionType="single" [hasDetails]="false" (fetchData)="loadJoinAuth()" + (updateSelection)="updateSelection($event)" > +
+ + +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-list/smb-join-auth-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-list/smb-join-auth-list.component.ts index f45cda1084fbd..6cc47f165a859 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-list/smb-join-auth-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-join-auth-list/smb-join-auth-list.component.ts @@ -9,11 +9,22 @@ import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data import { Permission } from '~/app/shared/models/permissions'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { SMBJoinAuth } from '../smb.model'; +import { Router } from '@angular/router'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { URLBuilderService } from '~/app/shared/services/url-builder.service'; +import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import { FinishedTask } from '~/app/shared/models/finished-task'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; + +export const JOINAUTH_URL = '/cephfs/smb/ad'; @Component({ selector: 'cd-smb-join-auth-list', templateUrl: './smb-join-auth-list.component.html', - styleUrls: ['./smb-join-auth-list.component.scss'] + styleUrls: ['./smb-join-auth-list.component.scss'], + providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(JOINAUTH_URL) }] }) export class SmbJoinAuthListComponent implements OnInit { columns: CdTableColumn[]; @@ -23,11 +34,16 @@ export class SmbJoinAuthListComponent implements OnInit { joinAuth$: Observable; subject$ = new BehaviorSubject([]); + selection: CdTableSelection = new CdTableSelection(); constructor( + private router: Router, + private urlBuilder: URLBuilderService, private authStorageService: AuthStorageService, public actionLabels: ActionLabelsI18n, - private smbService: SmbService + private smbService: SmbService, + private modalService: ModalCdsService, + private taskWrapper: TaskWrapperService ) { this.permission = this.authStorageService.getPermissions().smb; } @@ -35,7 +51,7 @@ export class SmbJoinAuthListComponent implements OnInit { ngOnInit() { this.columns = [ { - name: $localize`ID`, + name: $localize`Name`, prop: 'auth_id', flexGrow: 2 }, @@ -45,12 +61,35 @@ export class SmbJoinAuthListComponent implements OnInit { flexGrow: 2 }, { - name: $localize`Linked to Cluster`, + name: $localize`Linked to cluster`, prop: 'linked_to_cluster', flexGrow: 2 } ]; + this.tableActions = [ + { + name: `${this.actionLabels.CREATE} AD`, + permission: 'create', + icon: Icons.add, + click: () => this.router.navigate([this.urlBuilder.getCreate()]), + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection + }, + { + name: this.actionLabels.EDIT, + permission: 'update', + icon: Icons.edit, + click: () => + this.router.navigate([this.urlBuilder.getEdit(String(this.selection.first().auth_id))]) + }, + { + name: this.actionLabels.DELETE, + permission: 'update', + icon: Icons.destroy, + click: () => this.openDeleteModal() + } + ]; + this.joinAuth$ = this.subject$.pipe( switchMap(() => this.smbService.listJoinAuths().pipe( @@ -66,4 +105,24 @@ export class SmbJoinAuthListComponent implements OnInit { loadJoinAuth() { this.subject$.next([]); } + + openDeleteModal() { + const authId = this.selection.first().auth_id; + + this.modalService.show(DeleteConfirmationModalComponent, { + itemDescription: $localize`Active directory access resource`, + itemNames: [authId], + submitActionObservable: () => + this.taskWrapper.wrapTaskAroundCall({ + task: new FinishedTask('smb/ad/remove', { + authId: authId + }), + call: this.smbService.deleteJoinAuth(authId) + }) + }); + } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-tabs/smb-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-tabs/smb-tabs.component.html index d2b6f3aac066e..0eab75f42f2c3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-tabs/smb-tabs.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-tabs/smb-tabs.component.html @@ -14,7 +14,7 @@ > Active directory access resources - Logical management units for authorization on Active Directory servers + Logical management units for authorization on active directory (AD) servers diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.html new file mode 100644 index 0000000000000..f6b1db0740516 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.html @@ -0,0 +1,238 @@ +
+
+
+ {{ action | titlecase }} {{ resource | upperFirst }} +
+ + +
+ Users and groups access resource name + + + + This field is required. + +
+ + +
+ + + + +
+ + + + +
+
+ Username + + + + This field is required. + +
+
+ Password + + + + This field is required. + +
+
+ + + + +
+
+
+
+ +
+ +
+ + + + +
+
+ Group + + +
+
+ + + + +
+
+
+
+ +
+ +
+ + +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.scss new file mode 100644 index 0000000000000..7aa2236febbb5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.scss @@ -0,0 +1,5 @@ +@use '@carbon/layout'; + +.spacing { + margin-top: layout.$spacing-06; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.spec.ts new file mode 100644 index 0000000000000..19752c47d583f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.spec.ts @@ -0,0 +1,120 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SmbUsersgroupsFormComponent } from './smb-usersgroups-form.component'; +import { ToastrModule } from 'ngx-toastr'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { provideRouter } from '@angular/router'; +import { SharedModule } from '~/app/shared/shared.module'; +import { SmbService } from '~/app/shared/api/smb.service'; +import { USERSGROUPS_RESOURCE } from '../smb.model'; +import { of } from 'rxjs'; + +export const FOO_USERSGROUPS = { + users_groups_id: 'foo', + values: { + users: [ + { + name: 'user', + password: 'pass' + } + ], + groups: [ + { + name: 'bar' + } + ] + }, + resource_type: USERSGROUPS_RESOURCE +}; + +describe('SmbUsersgroupsFormComponent', () => { + let component: SmbUsersgroupsFormComponent; + let fixture: ComponentFixture; + let createUsersGroups: jasmine.Spy; + let getUsersGroups: jasmine.Spy; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ToastrModule.forRoot(), SharedModule, ReactiveFormsModule], + declarations: [SmbUsersgroupsFormComponent], + providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])] + }).compileComponents(); + + fixture = TestBed.createComponent(SmbUsersgroupsFormComponent); + component = fixture.componentInstance; + component.ngOnInit(); + createUsersGroups = spyOn(TestBed.inject(SmbService), 'createUsersGroups'); + getUsersGroups = spyOn(TestBed.inject(SmbService), 'getUsersGroups'); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should set form invalid if required username is missing', () => { + const user = component.users.controls[0] as FormGroup; + component.form.controls['usersGroupsId'].setValue('foo'); + user.controls['name'].setValue(''); + expect(component.form.valid).not.toBeNull(); + }); + + it('should set required fields, add group and submit the form', () => { + const user = component.users.controls[0] as FormGroup; + component.form.controls['usersGroupsId'].setValue('foo'); + component.form.controls['linkedToCluster'].setValue(undefined); + user.controls['name'].setValue('user'); + user.controls['password'].setValue('pass'); + component.addGroup(); + const group = component.groups.controls[0] as FormGroup; + group.controls['name'].setValue('bar'); + + component.submit(); + + expect(createUsersGroups).toHaveBeenCalledWith(FOO_USERSGROUPS); + }); + + describe('when editing', () => { + beforeEach(() => { + component.editing = true; + getUsersGroups.and.returnValue(of(FOO_USERSGROUPS)); + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should get resource data and set form fields with it', () => { + expect(getUsersGroups).toHaveBeenCalled(); + expect(component.form.value).toEqual({ + usersGroupsId: 'foo', + users: [ + { + name: 'user', + password: 'pass' + } + ], + groups: [ + { + name: 'bar' + } + ], + linkedToCluster: undefined + }); + }); + }); + + it('should add and remove users and groups', () => { + const nUsers = component.users.length; + const nGroups = component.groups.length; + component.addUser(); + component.addGroup(); + component.addGroup(); + expect(component.users.length).toBe(nUsers + 1); + expect(component.groups.length).toBe(nGroups + 2); + component.removeUser(0); + component.removeGroup(0); + expect(component.users.length).toBe(nUsers); + expect(component.groups.length).toBe(nGroups + 1); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.ts new file mode 100644 index 0000000000000..8f8fa68e0c645 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-form/smb-usersgroups-form.component.ts @@ -0,0 +1,158 @@ +import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { FormArray, FormControl, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Observable } from 'rxjs'; +import { SmbService } from '~/app/shared/api/smb.service'; +import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdForm } from '~/app/shared/forms/cd-form'; +import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { FinishedTask } from '~/app/shared/models/finished-task'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { Group, SMBCluster, SMBUsersGroups, User, USERSGROUPS_RESOURCE } from '../smb.model'; +import { Location } from '@angular/common'; +import { USERSGROUPS_URL } from '../smb-usersgroups-list/smb-usersgroups-list.component'; + +@Component({ + selector: 'cd-smb-usersgroups-form', + templateUrl: './smb-usersgroups-form.component.html', + styleUrls: ['./smb-usersgroups-form.component.scss'] +}) +export class SmbUsersgroupsFormComponent extends CdForm implements OnInit { + form: CdFormGroup; + action: string; + resource: string; + editing: boolean; + icons = Icons; + + smbClusters$: Observable; + + constructor( + private actionLabels: ActionLabelsI18n, + private taskWrapperService: TaskWrapperService, + private formBuilder: CdFormBuilder, + private smbService: SmbService, + private router: Router, + private cd: ChangeDetectorRef, + private route: ActivatedRoute, + private location: Location + ) { + super(); + this.editing = this.router.url.startsWith(`${USERSGROUPS_URL}/${URLVerbs.EDIT}`); + this.resource = $localize`users and groups access resource`; + } + + ngOnInit() { + this.action = this.actionLabels.CREATE; + this.smbClusters$ = this.smbService.listClusters(); + this.createForm(); + + if (this.editing) { + this.action = this.actionLabels.UPDATE; + let editingUsersGroupId: string; + this.route.params.subscribe((params: { usersGroupsId: string }) => { + editingUsersGroupId = params.usersGroupsId; + }); + this.smbService + .getUsersGroups(editingUsersGroupId) + .subscribe((usersGroups: SMBUsersGroups) => { + this.form.get('usersGroupsId').setValue(usersGroups.users_groups_id); + this.form.get('linkedToCluster').setValue(usersGroups.linked_to_cluster); + + usersGroups.values.users.forEach((user: User) => { + this.addUser(user); + }); + + usersGroups.values.groups.forEach((group: Group) => { + this.addGroup(group); + }); + }); + } else { + this.addUser(); + } + } + + createForm() { + this.form = this.formBuilder.group({ + usersGroupsId: new FormControl('', { + validators: [Validators.required] + }), + linkedToCluster: new FormControl(null), + users: new FormArray([]), + groups: new FormArray([]) + }); + } + + submit() { + const usersGroupsId = this.form.getValue('usersGroupsId'); + const linkedToCluster = this.form.getValue('linkedToCluster'); + const users = this.form.getValue('users'); + const groups = this.form.getValue('groups'); + const usersgroups: SMBUsersGroups = { + resource_type: USERSGROUPS_RESOURCE, + users_groups_id: usersGroupsId, + values: { users: users, groups: groups }, + linked_to_cluster: linkedToCluster + }; + + const self = this; + const BASE_URL = 'smb/standalone/'; + + let taskUrl = `${BASE_URL}${this.editing ? URLVerbs.EDIT : URLVerbs.CREATE}`; + this.taskWrapperService + .wrapTaskAroundCall({ + task: new FinishedTask(taskUrl, { + usersGroupsId: usersGroupsId + }), + call: this.smbService.createUsersGroups(usersgroups) + }) + .subscribe({ + error() { + self.form.setErrors({ cdSubmitButton: true }); + }, + complete: () => { + this.location.back(); + } + }); + } + + get users(): FormArray { + return this.form.get('users') as FormArray; + } + + get groups(): FormArray { + return this.form.get('groups') as FormArray; + } + + newUser(user?: User): CdFormGroup { + return this.formBuilder.group({ + name: [user ? user.name : '', Validators.required], + password: [user ? user.password : '', [Validators.required]] + }); + } + + newGroup(group?: Group): CdFormGroup { + return this.formBuilder.group({ + name: [group ? group.name : ''] + }); + } + + addUser(user?: User): void { + this.users.push(this.newUser(user)); + } + + addGroup(group?: Group): void { + this.groups.push(this.newGroup(group)); + } + + removeUser(index: number): void { + this.users.removeAt(index); + this.cd.detectChanges(); + } + + removeGroup(index: number): void { + this.groups.removeAt(index); + this.cd.detectChanges(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-list/smb-usersgroups-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-list/smb-usersgroups-list.component.html index 7aa7ee37d31f2..b0119dbe4392a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-list/smb-usersgroups-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-list/smb-usersgroups-list.component.html @@ -7,7 +7,15 @@ [hasDetails]="true" (setExpandedRow)="setExpandedRow($event)" (fetchData)="loadUsersGroups()" -> + (updateSelection)="updateSelection($event)" + > +
+ + +
- {{ group.name }} + {{ group?.name }} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-list/smb-usersgroups-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-list/smb-usersgroups-list.component.ts index 869a21115da87..2d52f53e990b1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-list/smb-usersgroups-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-usersgroups-list/smb-usersgroups-list.component.ts @@ -14,11 +14,22 @@ import { Permission } from '~/app/shared/models/permissions'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { SmbService } from '~/app/shared/api/smb.service'; import { SMBUsersGroups } from '../smb.model'; +import { Router } from '@angular/router'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { URLBuilderService } from '~/app/shared/services/url-builder.service'; +import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; +import { FinishedTask } from '~/app/shared/models/finished-task'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; + +export const USERSGROUPS_URL = '/cephfs/smb/standalone'; @Component({ selector: 'cd-smb-users-list', templateUrl: './smb-usersgroups-list.component.html', - styleUrls: ['./smb-usersgroups-list.component.scss'] + styleUrls: ['./smb-usersgroups-list.component.scss'], + providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(USERSGROUPS_URL) }] }) export class SmbUsersgroupsListComponent extends ListWithDetails implements OnInit { @ViewChild('groupsNamesTpl', { static: true }) @@ -30,11 +41,16 @@ export class SmbUsersgroupsListComponent extends ListWithDetails implements OnIn usersGroups$: Observable; subject$ = new BehaviorSubject([]); + selection: CdTableSelection = new CdTableSelection(); constructor( + private router: Router, + private urlBuilder: URLBuilderService, private authStorageService: AuthStorageService, public actionLabels: ActionLabelsI18n, - private smbService: SmbService + private smbService: SmbService, + private modalService: ModalCdsService, + private taskWrapper: TaskWrapperService ) { super(); this.permission = this.authStorageService.getPermissions().smb; @@ -43,7 +59,7 @@ export class SmbUsersgroupsListComponent extends ListWithDetails implements OnIn ngOnInit() { this.columns = [ { - name: $localize`ID`, + name: $localize`Name`, prop: 'users_groups_id', flexGrow: 2 }, @@ -59,12 +75,37 @@ export class SmbUsersgroupsListComponent extends ListWithDetails implements OnIn flexGrow: 2 }, { - name: $localize`Linked to`, + name: $localize`Linked to cluster`, prop: 'values.linked_to_cluster', flexGrow: 2 } ]; + this.tableActions = [ + { + name: `${this.actionLabels.CREATE} standalone`, + permission: 'create', + icon: Icons.add, + click: () => this.router.navigate([this.urlBuilder.getCreate()]), + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection + }, + { + name: this.actionLabels.EDIT, + permission: 'update', + icon: Icons.edit, + click: () => + this.router.navigate([ + this.urlBuilder.getEdit(String(this.selection.first().users_groups_id)) + ]) + }, + { + name: this.actionLabels.DELETE, + permission: 'delete', + icon: Icons.destroy, + click: () => this.openDeleteModal() + } + ]; + this.usersGroups$ = this.subject$.pipe( switchMap(() => this.smbService.listUsersGroups().pipe( @@ -80,4 +121,24 @@ export class SmbUsersgroupsListComponent extends ListWithDetails implements OnIn loadUsersGroups() { this.subject$.next([]); } + + openDeleteModal() { + const usersGroupsId = this.selection.first().users_groups_id; + + this.modalService.show(DeleteConfirmationModalComponent, { + itemDescription: $localize`Users and groups access resource`, + itemNames: [usersGroupsId], + submitActionObservable: () => + this.taskWrapper.wrapTaskAroundCall({ + task: new FinishedTask('smb/standalone/remove', { + usersGroupsId: usersGroupsId + }), + call: this.smbService.deleteUsersgroups(usersGroupsId) + }) + }); + } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb.model.ts index 1d4aff01353a7..8f1ea56bbd504 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb.model.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb.model.ts @@ -1,7 +1,7 @@ import { CephServicePlacement } from '~/app/shared/models/service.interface'; export interface SMBCluster { - resource_type: string; + resource_type: typeof CLUSTER_RESOURCE; cluster_id: string; auth_mode: typeof AUTHMODE[keyof typeof AUTHMODE]; domain_settings?: DomainSettings; @@ -9,7 +9,7 @@ export interface SMBCluster { custom_dns?: string[]; placement?: CephServicePlacement; clustering?: typeof CLUSTERING; - public_addrs?: PublicAddress; + public_addrs?: PublicAddress[]; } export interface ClusterRequestModel { @@ -43,15 +43,17 @@ export interface DomainSettings { realm?: string; join_sources?: JoinSource[]; } -export interface PublicAddress { - address: string; - destination: string; -} + export interface JoinSource { - source_type: string; + source_type?: string; ref: string; } +export interface PublicAddress { + address: string; + destination?: string; +} + export const CLUSTERING = { Default: 'default', Always: 'always', @@ -103,7 +105,6 @@ interface SMBShareLoginControl { export interface SMBJoinAuth { resource_type: string; auth_id: string; - intent: Intent; auth: Auth; linked_to_cluster?: string; } @@ -111,7 +112,6 @@ export interface SMBJoinAuth { export interface SMBUsersGroups { resource_type: string; users_groups_id: string; - intent: Intent; values: Value; linked_to_cluster?: string; } @@ -121,12 +121,12 @@ interface Auth { password: string; } -interface User { +export interface User { name: string; password: string; } -interface Group { +export interface Group { name: string; } @@ -135,10 +135,9 @@ interface Value { groups: Group[]; } -type Intent = 'present' | 'removed'; - -export const CLUSTER_RESOURCE = 'ceph.smb.cluster'; - -export const SHARE_RESOURCE = 'ceph.smb.share'; +export const CLUSTER_RESOURCE = 'ceph.smb.cluster' as const; +export const SHARE_RESOURCE = 'ceph.smb.share' as const; +export const JOIN_AUTH_RESOURCE = 'ceph.smb.join.auth' as const; +export const USERSGROUPS_RESOURCE = 'ceph.smb.usersgroups' as const; export const PROVIDER = 'samba-vfs'; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb.module.ts index a375da67e743b..d2ff1c9727e06 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb.module.ts @@ -21,7 +21,8 @@ import { PlaceholderModule, SelectModule, TabsModule, - TagModule + TagModule, + FileUploaderModule } from 'carbon-components-angular'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { CommonModule } from '@angular/common'; @@ -34,11 +35,11 @@ import { SmbUsersgroupsListComponent } from './smb-usersgroups-list/smb-usersgro import { SmbTabsComponent } from './smb-tabs/smb-tabs.component'; import { SmbJoinAuthListComponent } from './smb-join-auth-list/smb-join-auth-list.component'; import { SmbUsersgroupsDetailsComponent } from './smb-usersgroups-details/smb-usersgroups-details.component'; -import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; +import { SmbJoinAuthFormComponent } from './smb-join-auth-form/smb-join-auth-form.component'; +import { SmbUsersgroupsFormComponent } from './smb-usersgroups-form/smb-usersgroups-form.component'; @NgModule({ imports: [ - ReactiveFormsModule, RouterModule, CommonModule, SharedModule, @@ -51,6 +52,7 @@ import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; SelectModule, TabsModule, TagModule, + FileUploaderModule, InputModule, CheckboxModule, SelectModule, @@ -74,9 +76,11 @@ import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; SmbUsersgroupsDetailsComponent, SmbTabsComponent, SmbJoinAuthListComponent, + SmbUsersgroupsDetailsComponent, + SmbJoinAuthFormComponent, + SmbUsersgroupsFormComponent, SmbShareFormComponent - ], - providers: [provideCharts(withDefaultRegisterables())] + ] }) export class SmbModule { constructor(private iconService: IconService) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/smb.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/smb.service.spec.ts index 34f1156a72fb5..a263a11b94d0d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/smb.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/smb.service.spec.ts @@ -4,6 +4,11 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common import { SmbService } from './smb.service'; import { configureTestBed } from '~/testing/unit-test-helper'; import { provideHttpClient } from '@angular/common/http'; +import { + CLUSTER_RESOURCE, + JOIN_AUTH_RESOURCE, + USERSGROUPS_RESOURCE +} from '~/app/ceph/smb/smb.model'; describe('SmbService', () => { let service: SmbService; @@ -32,10 +37,9 @@ describe('SmbService', () => { it('should call create cluster', () => { const request = { cluster_resource: { - resource_type: 'ceph.smb.cluster', + resource_type: CLUSTER_RESOURCE, cluster_id: 'clusterUserTest', auth_mode: 'active-directory', - intent: 'present', domain_settings: { realm: 'DOMAIN1.SINK.TEST', join_sources: [ @@ -74,12 +78,51 @@ describe('SmbService', () => { expect(req.request.method).toBe('GET'); }); + it('should call create join auth', () => { + const request = { + resource_type: JOIN_AUTH_RESOURCE, + auth_id: 'foo', + auth: { + username: 'user', + password: 'pass' + }, + linked_to_cluster: '' + }; + service.createJoinAuth(request).subscribe(); + const req = httpTesting.expectOne('api/smb/joinauth'); + expect(req.request.method).toBe('POST'); + }); + it('should call list usersgroups', () => { service.listUsersGroups().subscribe(); const req = httpTesting.expectOne('api/smb/usersgroups'); expect(req.request.method).toBe('GET'); }); + it('should call create usersgroups', () => { + const request = { + resource_type: USERSGROUPS_RESOURCE, + users_groups_id: 'foo', + values: { + users: [ + { + name: 'user', + password: 'pass' + } + ], + groups: [ + { + name: 'bar' + } + ] + }, + linked_to_cluster: '' + }; + service.createUsersGroups(request).subscribe(); + const req = httpTesting.expectOne('api/smb/usersgroups'); + expect(req.request.method).toBe('POST'); + }); + it('should call create share', () => { const request = { share_resource: { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/smb.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/smb.service.ts index c719519c12d74..d51196ced9cb9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/smb.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/smb.service.ts @@ -61,4 +61,36 @@ export class SmbService { observe: 'response' }); } + + getJoinAuth(authId: string): Observable { + return this.http.get(`${this.baseURL}/joinauth/${authId}`); + } + + getUsersGroups(usersGroupsId: string): Observable { + return this.http.get(`${this.baseURL}/usersgroups/${usersGroupsId}`); + } + + createJoinAuth(joinAuth: SMBJoinAuth) { + return this.http.post(`${this.baseURL}/joinauth`, { + join_auth: joinAuth + }); + } + + createUsersGroups(usersgroups: SMBUsersGroups) { + return this.http.post(`${this.baseURL}/usersgroups`, { + usersgroups: usersgroups + }); + } + + deleteJoinAuth(authId: string) { + return this.http.delete(`${this.baseURL}/joinauth/${authId}`, { + observe: 'response' + }); + } + + deleteUsersgroups(usersGroupsId: string) { + return this.http.delete(`${this.baseURL}/usersgroups/${usersGroupsId}`, { + observe: 'response' + }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts index a943abe0febe4..281b3f714c981 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts @@ -408,6 +408,24 @@ export class TaskMessageService { 'smb/cluster/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) => this.smbCluster(metadata) ), + 'smb/ad/create': this.newTaskMessage(this.commonOperations.create, (metadata) => + this.smbJoinAuth(metadata) + ), + 'smb/ad/edit': this.newTaskMessage(this.commonOperations.update, (metadata) => + this.smbJoinAuth(metadata) + ), + 'smb/ad/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) => + this.smbJoinAuth(metadata) + ), + 'smb/standalone/create': this.newTaskMessage(this.commonOperations.create, (metadata) => + this.smbUsersgroups(metadata) + ), + 'smb/standalone/edit': this.newTaskMessage(this.commonOperations.update, (metadata) => + this.smbUsersgroups(metadata) + ), + 'smb/standalone/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) => + this.smbUsersgroups(metadata) + ), // Grafana tasks 'grafana/dashboards/update': this.newTaskMessage( this.commonOperations.update, @@ -501,6 +519,12 @@ export class TaskMessageService { ), 'smb/share/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => this.smbShare(metadata) + ), + 'cephfs/smb/joinauth/create': this.newTaskMessage(this.commonOperations.create, (metadata) => + this.smbJoinAuth(metadata) + ), + 'cephfs/smb/standalone/create': this.newTaskMessage(this.commonOperations.create, (metadata) => + this.smbUsersgroups(metadata) ) }; @@ -568,6 +592,14 @@ export class TaskMessageService { return $localize`SMB Cluster '${metadata.cluster_id}'`; } + smbJoinAuth(metadata: { authId: string }) { + return $localize`SMB active directory access resource '${metadata.authId}'`; + } + + smbUsersgroups(metadata: { usersGroupsId: string }) { + return $localize`SMB users and groups access resource '${metadata.usersGroupsId}'`; + } + service(metadata: any) { return $localize`service '${metadata.service_name}'`; } diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index dc2ccc370553d..52e6e4e0bdc85 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -14893,11 +14893,11 @@ paths: schema: properties: results: - description: List of results with resource details + description: List of operation results items: properties: resource: - description: Resource details + description: Resource properties: auth_mode: description: Either 'active-directory' or 'user' @@ -14906,7 +14906,7 @@ paths: description: Unique identifier for the cluster type: string custom_dns: - description: List of custom DNS server addresses (optional) + description: List of custom DNS server addresses items: type: string type: array @@ -14944,7 +14944,6 @@ paths: type: string placement: description: Placement configuration for the resource - (optional) properties: count: description: Number of instances to place @@ -14957,7 +14956,6 @@ paths: type: string user_group_settings: description: User group settings for user auth mode - (optional) items: properties: ref: @@ -14983,10 +14981,11 @@ paths: - placement type: object state: - description: State of the resource + description: The current state of the resource, e.g., + 'created', 'updated', 'deleted' type: string success: - description: Indicates whether the operation was successful + description: Indicates if the operation was successful type: boolean required: - resource @@ -14995,7 +14994,7 @@ paths: type: object type: array success: - description: Overall success status of the operation + description: Indicates if the overall operation was successful type: boolean required: - results @@ -15172,12 +15171,7 @@ paths: get: description: "\n List all smb join auth resources\n\n :return:\ \ Returns list of join auth.\n :rtype: List[Dict]\n " - parameters: - - default: '' - in: query - name: join_auth - schema: - type: string + parameters: [] responses: '200': content: @@ -15236,6 +15230,211 @@ paths: summary: List smb join authorization resources tags: - SMB + post: + description: "\n Create smb join auth resource\n\n :return: Returns\ + \ join auth resource.\n :rtype: Dict\n " + parameters: [] + requestBody: + content: + application/json: + schema: + properties: + join_auth: + type: string + required: + - join_auth + type: object + responses: + '201': + content: + application/vnd.ceph.api.v1.0+json: + schema: + properties: + results: + description: List of operation results + items: + properties: + resource: + description: Resource + properties: + auth: + description: Authentication credentials + properties: + password: + description: Password for authentication + type: string + username: + description: Username for authentication + type: string + required: + - username + - password + type: object + auth_id: + description: Unique identifier for the join auth resource + type: string + intent: + description: Desired state of the resource, e.g., 'present' + or 'removed' + type: string + linked_to_cluster: + description: Optional string containing a cluster ID. If + set, the resource is linked to the cluster and will + be automatically removed when the cluster is removed + type: string + resource_type: + description: ceph.smb.join.auth + type: string + required: + - resource_type + - auth_id + - intent + - auth + - linked_to_cluster + type: object + state: + description: The current state of the resource, e.g., + 'created', 'updated', 'deleted' + type: string + success: + description: Indicates if the operation was successful + type: boolean + required: + - resource + - state + - success + type: object + type: array + success: + description: Indicates if the overall operation was successful + type: boolean + required: + - results + - success + type: object + description: Resource created. + '202': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Operation is still executing. Please check the task queue. + '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: Create smb join auth + tags: + - SMB + /api/smb/joinauth/{auth_id}: + delete: + description: "\n Delete smb join auth resource\n\n :param auth_id:\ + \ Join Auth identifier\n :return: None.\n " + parameters: + - description: auth_id + in: path + name: auth_id + required: true + schema: + type: string + responses: + '202': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Operation is still executing. Please check the task queue. + '204': + content: + application/vnd.ceph.api.v1.0+json: + schema: + properties: {} + type: object + description: Resource deleted. + '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: Delete smb join auth + tags: + - SMB + get: + description: "\n Get Join auth resource\n\n :return: Returns join\ + \ auth.\n :rtype: Dict\n " + parameters: + - in: path + name: auth_id + required: true + schema: + type: string + responses: + '200': + content: + application/vnd.ceph.api.v1.0+json: + schema: + properties: + auth: + description: Authentication credentials + properties: + password: + description: Password for authentication + type: string + username: + description: Username for authentication + type: string + required: + - username + - password + type: object + auth_id: + description: Unique identifier for the join auth resource + type: string + intent: + description: Desired state of the resource, e.g., 'present' or + 'removed' + type: string + linked_to_cluster: + description: Optional string containing a cluster ID. If set, + the resource is linked to the cluster and will be automatically + removed when the cluster is removed + type: string + resource_type: + description: ceph.smb.join.auth + type: string + required: + - resource_type + - auth_id + - intent + - auth + - linked_to_cluster + 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: Get smb join authorization resource + tags: + - SMB /api/smb/share: get: description: "\n List all smb shares or all shares for a given cluster\n\ @@ -15349,11 +15548,11 @@ paths: schema: properties: results: - description: List of results with resource details + description: List of operation results items: properties: resource: - description: Resource details + description: Resource properties: browseable: description: Indicates if the share is browseable @@ -15380,9 +15579,9 @@ paths: required: - volume - path + - provider - subvolumegroup - subvolume - - provider type: object cluster_id: description: Unique identifier for the cluster @@ -15414,10 +15613,11 @@ paths: - cephfs type: object state: - description: State of the resource + description: The current state of the resource, e.g., + 'created', 'updated', 'deleted' type: string success: - description: Indicates whether the operation was successful + description: Indicates if the operation was successful type: boolean required: - resource @@ -15426,7 +15626,7 @@ paths: type: object type: array success: - description: Overall success status of the operation + description: Indicates if the overall operation was successful type: boolean required: - results @@ -15500,13 +15700,8 @@ paths: /api/smb/usersgroups: get: description: "\n List all smb usersgroups resources\n\n :return:\ - \ Returns list of usersgroups.\n :rtype: List[Dict]\n " - parameters: - - default: '' - in: query - name: users_groups - schema: - type: string + \ Returns list of usersgroups\n :rtype: List[Dict]\n " + parameters: [] responses: '200': content: @@ -15586,6 +15781,262 @@ paths: summary: List smb user resources tags: - SMB + post: + description: "\n Create smb usersgroups resource\n\n :return:\ + \ Returns usersgroups resource.\n :rtype: Dict\n " + parameters: [] + requestBody: + content: + application/json: + schema: + properties: + usersgroups: + type: string + required: + - usersgroups + type: object + responses: + '201': + content: + application/vnd.ceph.api.v1.0+json: + schema: + properties: + results: + description: List of operation results + items: + properties: + resource: + description: Resource + properties: + results: + description: List of operation results + items: + properties: + resource: + description: Resource + properties: + auth: + description: Authentication credentials + properties: + password: + description: Password for authentication + type: string + username: + description: Username for authentication + type: string + required: + - username + - password + type: object + auth_id: + description: Unique identifier for the join + auth resource + type: string + intent: + description: Desired state of the resource, + e.g., 'present' or 'removed' + type: string + linked_to_cluster: + description: Optional string containing a + cluster ID. If set, the resource is + linked to the cluster and will be automatically + removed when the cluster is removed + type: string + resource_type: + description: ceph.smb.join.auth + type: string + required: + - resource_type + - auth_id + - intent + - auth + - linked_to_cluster + type: object + state: + description: The current state of the resource, e.g., + 'created', 'updated', 'deleted' + type: string + success: + description: Indicates if the operation was successful + type: boolean + required: + - resource + - state + - success + type: object + type: array + success: + description: Indicates if the overall operation was + successful + type: boolean + required: + - results + - success + type: object + state: + description: The current state of the resource, e.g., + 'created', 'updated', 'deleted' + type: string + success: + description: Indicates if the operation was successful + type: boolean + required: + - resource + - state + - success + type: object + type: array + success: + description: Indicates if the overall operation was successful + type: boolean + required: + - results + - success + type: object + description: Resource created. + '202': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Operation is still executing. Please check the task queue. + '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: Create smb usersgroups + tags: + - SMB + /api/smb/usersgroups/{users_groups_id}: + delete: + description: "\n Delete smb usersgroups resource\n\n :param users_group_id:\ + \ Users identifier\n :return: None.\n " + parameters: + - description: users_groups_id + in: path + name: users_groups_id + required: true + schema: + type: string + responses: + '202': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Operation is still executing. Please check the task queue. + '204': + content: + application/vnd.ceph.api.v1.0+json: + schema: + properties: {} + type: object + description: Resource deleted. + '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: Delete smb join auth + tags: + - SMB + get: + description: "\n Get Users and groups resource\n\n :return: Returns\ + \ join auth.\n :rtype: Dict\n " + parameters: + - in: path + name: users_groups_id + required: true + schema: + type: string + responses: + '200': + content: + application/vnd.ceph.api.v1.0+json: + schema: + properties: + intent: + description: Desired state of the resource, e.g., 'present' or + 'removed' + type: string + linked_to_cluster: + description: Optional string containing a cluster ID. If set, + the resource is linked to the cluster and will be automatically + removed when the cluster is removed + type: string + resource_type: + description: ceph.smb.usersgroups + type: string + users_groups_id: + description: A short string identifying the usersgroups resource + type: string + values: + description: Required object containing users and groups information + properties: + groups: + description: List of group objects, each containing a name + items: + properties: + name: + description: The name of the group + type: string + required: + - name + type: object + type: array + users: + description: List of user objects, each containing a name + and password + items: + properties: + name: + description: The user name + type: string + password: + description: The password for the user + type: string + required: + - name + - password + type: object + type: array + required: + - users + - groups + type: object + required: + - resource_type + - users_groups_id + - intent + - values + - linked_to_cluster + 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: Get smb usersgroups authorization resource + tags: + - SMB /api/summary: get: parameters: [] diff --git a/src/pybind/mgr/dashboard/tests/test_smb.py b/src/pybind/mgr/dashboard/tests/test_smb.py index 8160f40d3df58..28a0db031a909 100644 --- a/src/pybind/mgr/dashboard/tests/test_smb.py +++ b/src/pybind/mgr/dashboard/tests/test_smb.py @@ -265,6 +265,39 @@ class SMBJoinAuthTest(ControllerTestCase): self.assertStatus(200) self.assertJsonBody(self._join_auths['resources']) + def test_create_join_auth(self): + mock_simplified = Mock() + mock_simplified.to_simplified.return_value = json.dumps(self._join_auths['resources'][0]) + mgr.remote = Mock(return_value=mock_simplified) + + _join_auth_data = {'join_auth': self._join_auths['resources'][0]} + + self._post(self._endpoint, _join_auth_data) + self.assertStatus(201) + self.assertInJsonBody(json.dumps(self._join_auths['resources'][0])) + + def test_delete(self): + _res = { + "resource_type": "ceph.smb.join.auth", + "auth_id": "join1-admin", + "intent": "removed", + "auth": { + "username": "Administrator", + "password": "Passw0rd" + } + } + _res_simplified = { + "resource_type": "ceph.smb.join.auth", + "auth_id": "join1-admin", + "intent": "removed" + } + + mgr.remote = Mock(return_value=Mock(return_value=_res)) + mgr.remote.return_value.one.return_value.to_simplified = Mock(return_value=_res) + self._delete(f'{self._endpoint}/join1-admin') + self.assertStatus(204) + mgr.remote.assert_called_once_with('smb', 'apply_resources', json.dumps(_res_simplified)) + class SMBUsersgroupsTest(ControllerTestCase): _endpoint = '/api/smb/usersgroups' @@ -319,3 +352,36 @@ class SMBUsersgroupsTest(ControllerTestCase): self._get(self._endpoint) self.assertStatus(200) self.assertJsonBody(self._usersgroups['resources']) + + def test_create_usersgroups(self): + mock_simplified = Mock() + mock_simplified.to_simplified.return_value = json.dumps(self._usersgroups['resources'][0]) + mgr.remote = Mock(return_value=mock_simplified) + + _usersgroups_data = {'usersgroups': self._usersgroups['resources'][0]} + + self._post(self._endpoint, _usersgroups_data) + self.assertStatus(201) + self.assertInJsonBody(json.dumps(self._usersgroups['resources'][0])) + + def test_delete(self): + _res = { + "resource_type": "ceph.smb.usersgroups", + "users_groups_id": "ug1", + "intent": "removed", + "auth": { + "username": "Administrator", + "password": "Passw0rd" + } + } + _res_simplified = { + "resource_type": "ceph.smb.usersgroups", + "users_groups_id": "ug1", + "intent": "removed" + } + + mgr.remote = Mock(return_value=Mock(return_value=_res)) + mgr.remote.return_value.one.return_value.to_simplified = Mock(return_value=_res) + self._delete(f'{self._endpoint}/ug1') + self.assertStatus(204) + mgr.remote.assert_called_once_with('smb', 'apply_resources', json.dumps(_res_simplified)) -- 2.39.5