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 <pegonzal@redhat.com>
# -*- coding: utf-8 -*-
-
import json
import logging
from functools import wraps
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
}, "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 = {
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):
@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
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")
@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):
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 {
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 }
}
]
}
} 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';
})
export class CephfsModule {
constructor(private iconService: IconService) {
- this.iconService.registerAll([AddIcon, Close, Trash]);
+ this.iconService.registerAll([AddIcon, LaunchIcon, Close, Trash]);
}
}
<cds-text-label
labelInputID="cluster_id"
i18n
- helperText="Unique cluster identifier"
+ helperText="Unique identifier"
i18n-helperText
cdRequiredField="Cluster Name"
[invalid]="smbForm.controls.cluster_id.invalid && smbForm.controls.cluster_id.dirty"
<input
cdsText
type="text"
- placeholder="Cluster Name..."
- i18n-placeholder
id="cluster_id"
formControlName="cluster_id"
[invalid]="smbForm.controls.cluster_id.invalid && smbForm.controls.cluster_id.dirty"
class="d-flex">
<cds-text-label labelInputID="domain_settings"
i18n
- cdRequiredField="Domain Settings">Domain Settings
+ cdRequiredField="Domain Settings">Active Directory (AD) Settings
<div class="cds-input-group">
<input
cdsText
type="text"
- placeholder="Domain Settings..."
- i18n-placeholder
id="domain_settings"
formControlName="domain_settings"
[value]="domainSettingsObject?.realm"
smbForm.get('domain_settings').hasError('required') &&
smbForm.controls.domain_settings.touched
"
- i18n>Specify the Realm and Join Sources in the Domain Settings field.</span
+ i18n>Specify the Realm and AD access resources in the Domain Settings field.</span
>
<div></div>
</div>
<!-- User Group Settings -->
<ng-container formArrayName="joinSources"
- *ngFor="let dns of joinSources.controls; index as i">
+ *ngFor="let _ of joinSources.controls; index as i">
<div
cdsRow
- *ngIf="this.smbForm.get('auth_mode').value === 'user'"
+ *ngIf="this.smbForm.get('auth_mode').value === 'user' && usersGroups$ | async as usersGroups"
class="form-item form-item-append"
>
- <div cdsCol
- [columnNumbers]="{ lg: 14 }">
- <cds-text-label for="joinSources"
- i18n
- cdRequiredField="User Group Id">User Group Id
- <input
- cdsText
- type="text"
- placeholder="User Group Id"
- i18n-placeholder
- [id]="'joinSources-' + i"
- [formControlName]="i"
- [invalid]="
- smbForm.controls['joinSources'].controls[i].invalid &&
- smbForm.controls['joinSources'].dirty
- "
- />
- </cds-text-label>
- <ng-template #refError>
+ <div
+ cdsCol
+ [columnNumbers]="{ lg: 14 }"
+ >
+ <cds-select
+ label="Standalone user access resources"
+ i18n-label
+ [formControlName]="i"
+ [invalid]="smbForm.controls.joinSources.controls[i].invalid && smbForm.controls.joinSources.controls[i].dirty"
+ [invalidText]="ugError"
+ >
+ <option
+ [value]="null"
+ i18n
+ >-- List of users and groups access resources --
+ </option>
+ <option *ngFor="let ug of usersGroups"
+ [value]="ug.users_groups_id">{{ ug.users_groups_id }}</option>
+ </cds-select>
+ <ng-template #ugError>
<span
class="invalid-feedback"
- *ngIf="smbForm.showError('joinSources[i]', formDir, 'required')"
i18n
- >This field is required.</span
- >
+ >This field is required.</span>
</ng-template>
</div>
- <div cdsCol
- [columnNumbers]="{ lg: 1 }">
+ <div
+ cdsCol
+ [columnNumbers]="{ lg: 1 }"
+ class="item-action-btn spacing"
+ >
<cds-icon-button
kind="danger"
*ngIf="i > 0"
size="sm"
(click)="removeUserGroupSetting(i)"
>
- <svg cdsIcon="trash-can"
- size="32"
- class="cds--btn__icon"></svg>
+ <svg
+ cdsIcon="trash-can"
+ size="32"
+ class="cds--btn__icon"></svg>
</cds-icon-button>
</div>
</div>
type="button"
(click)="addUserGroupSetting()"
i18n>
- Add User Group Id
+ Add user group
<svg cdsIcon="add"
size="32"
class="cds--btn__icon"
icon></svg>
</button>
+
+ <button
+ cdsButton="tertiary"
+ type="button"
+ (click)="navigateCreateUsersGroups()"
+ i18n
+ >
+ Create user group
+ <svg
+ cdsIcon="launch"
+ size="32"
+ class="cds--btn__icon"></svg>
+ </button>
</div>
<!-- Placement -->
></cds-number>
</div>
- <!-- Clustering -->
- <div class="form-item">
- <cds-select
- formControlName="clustering"
- for="clustering"
- label="Clustering"
- id="clustering"
- helperText="Control if a cluster abstraction actually uses Samba’s clustering mechanism."
- i18n-helperText
- >
- <option *ngFor="let data of allClustering"
- i18n>{{ data | upperFirst }}</option>
- </cds-select>
- </div>
-
<!-- Custom DNS -->
<ng-container formArrayName="custom_dns"
- *ngFor="let dns of custom_dns.controls; index as i">
+ *ngFor="let _ of custom_dns.controls; index as i">
<div cdsRow
class="form-item form-item-append">
<div cdsCol
[columnNumbers]="{ lg: 14 }">
+ <cds-text-label
+ for="custom_dns"
+ i18n
+ >DNS
<input cdsText
[formControlName]="i"
- placeholder="Custom DNS"/>
+ placeholder="192.168.76.204"/>
+ </cds-text-label>
</div>
- <div cdsCol
- [columnNumbers]="{ lg: 1 }">
- <cds-icon-button kind="danger"
- size="sm"
- (click)="removeCustomDNS(i)">
- <svg cdsIcon="trash-can"
- size="32"
- class="cds--btn__icon"></svg>
+ <div
+ cdsCol
+ [columnNumbers]="{ lg: 1 }"
+ class="item-action-btn spacing"
+ >
+ <cds-icon-button
+ kind="danger"
+ size="sm"
+ (click)="removeCustomDNS(i)"
+ >
+ <svg
+ cdsIcon="trash-can"
+ size="32"
+ class="cds--btn__icon"
+ >
+ </svg>
</cds-icon-button>
</div>
</div>
type="button"
(click)="addCustomDns()"
i18n>
- Add Custom DNS
- <svg cdsIcon="add"
- size="32"
- class="cds--btn__icon"
- icon></svg>
+ Add custom DNS
+ <svg
+ cdsIcon="add"
+ size="32"
+ class="cds--btn__icon"
+ icon></svg>
+ </button>
+ <cd-helper i18n>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).
+ </cd-helper>
+ </div>
+
+ <!-- Clustering -->
+ <div class="form-item">
+ <cds-select
+ formControlName="clustering"
+ for="clustering"
+ label="Clustering"
+ id="clustering"
+ helperText="Default value indicates that clustering should be enabled if the placement count value is any value other than 1. Always value enables clustering regardless of the placement count. Never value disables clustering regardless of the placement count. "
+ i18n-helperText
+ >
+ <option *ngFor="let data of allClustering"
+ i18n>{{ data | upperFirst }}</option>
+ </cds-select>
+ </div>
+
+ <!-- Public addrs -->
+ <ng-container formArrayName="public_addrs"
+ *ngFor="let _ of public_addrs.controls; index as i">
+ <ng-container [formGroupName]="i">
+ <div cdsRow
+ class="form-item form-item-append">
+ <!-- Address -->
+ <div cdsCol
+ [columnNumbers]="{ lg: 7 }">
+ <cds-text-label
+ for="public_addrs"
+ i18n
+ helperText="This address will be assigned to one of the host's network devices and managed automatically."
+ i18n-helperText
+ cdrequiredField
+ [invalid]="smbForm?.controls['public_addrs']?.controls[i].controls.address.invalid && smbForm?.controls['public_addrs']?.controls[i].controls.address.dirty"
+ [invalidText]="addressError"
+ >Address
+ <input
+ cdsText
+ type="text"
+ formControlName="address"
+ placeholder="192.168.4.51/24"
+ [invalid]="smbForm?.controls['public_addrs'].controls[i].controls.address.invalid && smbForm?.controls['public_addrs']?.controls[i].controls.address.dirty"
+ />
+ </cds-text-label>
+ <ng-template #addressError>
+ <span
+ class="invalid-feedback"
+ >
+ <ng-container i18n> This field is required. </ng-container>
+ </span>
+ </ng-template>
+ </div>
+ <!-- Destination -->
+ <div cdsCol
+ [columnNumbers]="{ lg: 7 }">
+ <cds-text-label
+ for="public_addrs"
+ i18n
+ helperText="Defines where the system will assign the managed IPs. Each string value must be a network address."
+ i18n-helperText
+ >Destination
+ <input
+ cdsText
+ type="text"
+ formControlName="destination"
+ placeholder="192.168.4.0/24"/>
+ </cds-text-label>
+ </div>
+ <div
+ cdsCol
+ [columnNumbers]="{ lg: 1 }"
+ class="item-action-btn spacing"
+ >
+ <cds-icon-button
+ kind="danger"
+ size="sm"
+ (click)="removePublicAddrs(i)"
+ >
+ <svg cdsIcon="trash-can"
+ size="32"
+ class="cds--btn__icon"></svg>
+ </cds-icon-button>
+ </div>
+ </div>
+ </ng-container>
+ </ng-container>
+ <div
+ *ngIf="(this.smbForm.get('count').value > 1 && this.smbForm.get('clustering').value.toLowerCase() == CLUSTERING.Default) || this.smbForm.get('clustering').value.toLowerCase() == CLUSTERING.Always"
+ class="form-item"
+ >
+ <button cdsButton="tertiary"
+ type="button"
+ (click)="addPublicAddrs()"
+ i18n>
+ Add public address
+ <svg
+ cdsIcon="add"
+ size="32"
+ class="cds--btn__icon"
+ icon></svg>
</button>
+ <cd-helper i18n>Assign virtual IP addresses that will be managed
+ by the clustering subsystem and may automatically
+ move between nodes running Samba containers.</cd-helper>
</div>
<cd-form-button-panel
(submitActionEvent)="submitAction()"
+@use '@carbon/layout';
+
+.spacing {
+ margin-top: layout.$spacing-06;
+}
import { ToastrModule } from 'ngx-toastr';
import { ComboBoxModule, GridModule, InputModule, SelectModule } from 'carbon-components-angular';
import { AUTHMODE } from '../smb.model';
+import { FOO_USERSGROUPS } from '../smb-usersgroups-form/smb-usersgroups-form.component.spec';
+import { of } from 'rxjs';
+import { By } from '@angular/platform-browser';
describe('SmbClusterFormComponent', () => {
let component: 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');
+ });
+ });
});
-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';
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';
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',
hasOrchestrator: boolean;
orchStatus$: Observable<any>;
allClustering: string[] = [];
+ CLUSTERING = CLUSTERING;
selectedLabels: string[] = [];
selectedHosts: string[] = [];
action: string;
icons = Icons;
domainSettingsObject: DomainSettings;
modalData$!: Observable<DomainSettings>;
+ usersGroups$: Observable<SMBUsersGroups[]>;
constructor(
private hostService: HostService,
private orchService: OrchestratorService,
private modalService: ModalCdsService,
private taskWrapperService: TaskWrapperService,
- private router: Router
+ private router: Router,
+ private cd: ChangeDetectorRef
) {
super();
this.resource = $localize`Cluster`;
}
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()
joinSources: new FormArray([]),
clustering: new UntypedFormControl(
CLUSTERING.Default.charAt(0).toUpperCase() + CLUSTERING.Default.slice(1)
- )
+ ),
+ public_addrs: new FormArray([])
});
this.orchService.status().subscribe((status) => {
}
// 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();
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();
}
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();
}
}
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';
selection = new CdTableSelection();
smbClusters$: Observable<SMBCluster[]>;
subject$ = new BehaviorSubject<SMBCluster[]>([]);
- modalRef: NgbModalRef;
constructor(
private authStorageService: AuthStorageService,
];
this.tableActions = [
{
- name: `${this.actionLabels.CREATE}`,
+ name: `${this.actionLabels.CREATE} cluster`,
permission: 'create',
icon: Icons.add,
routerLink: () => this.urlBuilder.getCreate(),
<div class="form-item">
<cds-text-label
label="realm"
- cdRequiredField="Realm Name"
[invalid]="
!domainSettingsForm.controls.realm.valid && domainSettingsForm.controls.realm.dirty
"
<input
cdsText
type="text"
- placeholder="Realm name..."
formControlName="realm"
autofocus
/>
</ng-template>
</div>
- <!-- Join Source -->
- <ng-container
- formArrayName="join_sources"
- *ngFor="let joinSource of join_sources.controls; index as i"
- >
- <ng-container [formGroupName]="i">
- <div cdsRow
- class="form-item form-item-append">
- <div cdsCol
- [columnNumbers]="{ lg: 14 }">
- <input
- cdsText
- type="text"
- placeholder="Id.."
- [id]="'ref' + i"
- formControlName="ref"
- modal-primary-focus
- [invalid]="
- !domainSettingsForm.controls['join_sources'].controls[i].valid &&
- domainSettingsForm.controls['join_sources'].dirty
- "
- [invalidText]="refError"
- />
- <ng-template #refError>
- <span
- class="invalid-feedback"
- *ngIf="domainSettingsForm.showError('join_sources', formDir, 'required')"
- i18n
- >This field is required.</span
+ <div
+ *ngIf="joinAuths$ | async as joinAuths"
+ class="form-item">
+ <ng-container
+ formArrayName="join_sources"
+ *ngFor="let _ of join_sources.controls; index as i"
+ >
+ <ng-container [formGroupName]="i">
+ <div
+ cdsRow
+ class="form-item form-item-append">
+ <div
+ cdsCol
+ [columnNumbers]="{ lg: 14 }"
+ >
+ <cds-select
+ label="Active Directory access resources"
+ i18n-label
+ formControlName="ref"
+ [invalid]="domainSettingsForm.controls?.join_sources.controls[i].invalid && domainSettingsForm.controls?.join_sources.controls[i].controls.ref.dirty"
+ [invalidText]="joinSourceError"
>
- </ng-template>
- </div>
- <div cdsCol
- *ngIf="i > 0"
- [columnNumbers]="{ lg: 1 }">
- <cds-icon-button kind="danger"
- size="sm"
- (click)="removeJoinSource(i)">
- <svg cdsIcon="trash-can"
- size="32"
- class="cds--btn__icon"></svg>
- </cds-icon-button>
+ <option
+ [value]="null"
+ i18n
+ >-- List of AD access resources --
+ </option>
+ <option *ngFor="let jA of joinAuths"
+ [value]="jA.auth_id">{{ jA.auth_id }}</option>
+ </cds-select>
+ <ng-template #joinSourceError>
+ <span
+ class="invalid-feedback"
+ >
+ <ng-container i18n> This field is required. </ng-container>
+ </span>
+ </ng-template>
+ </div>
+ <div cdsCol
+ *ngIf="i > 0"
+ [columnNumbers]="{ lg: 1 }"
+ class="item-action-btn spacing">
+ <cds-icon-button kind="danger"
+ size="sm"
+ (click)="removeJoinSource(i)">
+ <svg cdsIcon="trash-can"
+ size="32"
+ class="cds--btn__icon"></svg>
+ </cds-icon-button>
+ </div>
</div>
- </div>
+ </ng-container>
</ng-container>
- </ng-container>
- <div class="form-item">
- <button cdsButton="tertiary"
- type="button"
- (click)="addJoinSource()"
- i18n>
- Add Join Source
- <svg cdsIcon="add"
- size="32"
- class="cds--btn__icon"
- icon></svg>
- </button>
+ <div class="form-item">
+ <button cdsButton="tertiary"
+ type="button"
+ (click)="addJoinSource()"
+ i18n>
+ Add AD access resource
+ <svg cdsIcon="add"
+ size="32"
+ class="cds--btn__icon"
+ icon></svg>
+ </button>
+ <button cdsButton="tertiary"
+ type="button"
+ (click)="navigateCreateJoinSource()"
+ i18n>
+ Create AD access resource
+ <svg
+ cdsIcon="launch"
+ size="16"
+ class="cds--btn__icon"></svg>
+ </button>
+ </div>
</div>
</div>
+
+ <!-- Join Source -->
+
<cd-form-button-panel
(submitActionEvent)="submit()"
[form]="domainSettingsForm"
+@use '@carbon/layout';
+
+.spacing {
+ margin-top: layout.$spacing-06;
+}
import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
import { InputModule, ModalModule, SelectModule } from 'carbon-components-angular';
import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core';
+import { FOO_JOIN_AUTH } from '../smb-join-auth-form/smb-join-auth-form.component.spec';
+import { of } from 'rxjs';
+import { By } from '@angular/platform-browser';
describe('SmbDomainSettingModalComponent', () => {
let component: 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');
+ });
+ });
});
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';
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',
export class SmbDomainSettingModalComponent extends CdForm implements OnInit {
domainSettingsForm: CdFormGroup;
realmNames: string[];
+ joinAuths$: Observable<SMBJoinAuth[]>;
constructor(
public activeModal: NgbActiveModal,
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()
) {
super();
this.action = this.actionLabels.UPDATE;
- this.resource = $localize`Domain Setting`;
+ this.resource = $localize`Active Directory (AD) parameters`;
}
private createForm() {
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;
}
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;
--- /dev/null
+<div
+ cdsCol
+ [columnNumbers]="{ md: 4 }"
+>
+ <form name="form"
+ #formDir="ngForm"
+ [formGroup]="form"
+ novalidate>
+ <div i18n="form title"
+ class="form-header">
+ {{ action | titlecase }} {{ resource | upperFirst }}
+ </div>
+
+ <!-- Auth Id -->
+ <div class="form-item">
+ <cds-text-label
+ for="authId"
+ i18n
+ helperText="Unique identifier"
+ i18n-helperText
+ [invalid]="form.controls.authId.invalid && form.controls.authId.dirty"
+ [invalidText]="jaError"
+ >Active directory access resource name
+ <input
+ cdsText
+ type="text"
+ id="authId"
+ name="authId"
+ formControlName="authId"
+ [invalid]="form.controls.authId.invalid && form.controls.authId.dirty"
+ />
+ </cds-text-label>
+ <ng-template #jaError>
+ <span
+ class="invalid-feedback"
+ *ngIf="form.showError('authId', form, 'required')"
+ i18n
+ >This field is required.</span
+ >
+ </ng-template>
+ </div>
+
+ <!-- Username -->
+ <div class="form-item">
+ <cds-text-label
+ for="username"
+ i18n
+ [invalid]="form.controls.username.invalid && form.controls.username.dirty"
+ [invalidText]="usernameError"
+ >Username
+ <input
+ cdsText
+ type="text"
+ id="username"
+ name="username"
+ formControlName="username"
+ [invalid]="form.controls.username.invalid && form.controls.username.dirty"
+ />
+ </cds-text-label>
+ <ng-template #usernameError>
+ <span
+ class="invalid-feedback"
+ *ngIf="form.showError('username', form, 'required')"
+ i18n
+ >This field is required.
+ </span>
+ </ng-template>
+ </div>
+
+ <!-- Password -->
+ <div class="form-item">
+ <cds-password-label
+ for="password"
+ i18n
+ [invalid]="form.controls.password.invalid && form.controls.password.dirty"
+ [invalidText]="passwordError"
+ >Password
+ <input
+ cdsPassword
+ type="password"
+ id="password"
+ name="password"
+ formControlName="password"
+ [invalid]="form.controls.password.invalid && form.controls.password.dirty"
+ />
+ </cds-password-label>
+ <ng-template #passwordError>
+ <span
+ class="invalid-feedback"
+ *ngIf="form.showError('password', form, 'required')"
+ i18n
+ >This field is required.
+ </span>
+ </ng-template>
+ </div>
+
+ <!-- linkedToCluster -->
+ <div
+ *ngIf="smbClusters$ | async as clusters"
+ class="form-item"
+ >
+ <cds-select
+ label="Linked to cluster"
+ i18n-label
+ formControlName="linkedToCluster"
+ name="linkedToCluster"
+ cdOptionalField="Linked to cluster"
+ helperText="This resource may only be used with the linked cluster and will automatically be removed when the linked cluster is removed."
+ i18n-helperText
+ >
+ <option
+ [value]="null"
+ i18n
+ >-- List of clusters --
+ </option>
+ <option *ngFor="let cluster of clusters"
+ [value]="cluster.cluster_id">{{ cluster.cluster_id }}</option>
+ </cds-select>
+ </div>
+ <cd-form-button-panel
+ (submitActionEvent)="submit()"
+ [form]="form"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </form>
+</div>
--- /dev/null
+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<SmbJoinAuthFormComponent>;
+ 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
+ });
+ });
+ });
+});
--- /dev/null
+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<SMBCluster[]>;
+
+ 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();
+ }
+ });
+ }
+}
selectionType="single"
[hasDetails]="false"
(fetchData)="loadJoinAuth()"
+ (updateSelection)="updateSelection($event)"
>
+ <div class="table-actions">
+ <cd-table-actions
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </div>
</cd-table>
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[];
joinAuth$: Observable<SMBJoinAuth[]>;
subject$ = new BehaviorSubject<SMBJoinAuth[]>([]);
+ 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;
}
ngOnInit() {
this.columns = [
{
- name: $localize`ID`,
+ name: $localize`Name`,
prop: 'auth_id',
flexGrow: 2
},
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(
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;
+ }
}
>
Active directory access resources
<cd-help-text>
- Logical management units for authorization on Active Directory servers
+ Logical management units for authorization on active directory (AD) servers
</cd-help-text>
</legend>
--- /dev/null
+<div
+ cdsCol
+ [columnNumbers]="{ md: 4 }"
+>
+ <form name="form"
+ #formDir="ngForm"
+ [formGroup]="form"
+ novalidate>
+ <div
+ i18n="form title"
+ class="form-header"
+ >
+ {{ action | titlecase }} {{ resource | upperFirst }}
+ </div>
+
+ <!-- Users Groups Id -->
+ <div class="form-item">
+ <cds-text-label
+ for="usersGroupsId"
+ i18n
+ helperText="Unique identifier"
+ i18n-helperText
+ [invalid]="form.controls.usersGroupsId.invalid && form.controls.usersGroupsId.dirty"
+ [invalidText]="usersgroupsError"
+ >Users and groups access resource name
+ <input
+ cdsText
+ type="text"
+ id="usersGroupsId"
+ name="usersGroupsId"
+ formControlName="usersGroupsId"
+ />
+ </cds-text-label>
+ <ng-template #usersgroupsError>
+ <span
+ class="invalid-feedback"
+ *ngIf="form.showError('usersGroupsId', form, 'required')"
+ i18n
+ >This field is required.</span
+ >
+ </ng-template>
+ </div>
+
+ <!-- LinkedToCluster -->
+ <div
+ *ngIf="smbClusters$ | async as clusters"
+ class="form-item"
+ >
+ <cds-select
+ label="Linked to cluster"
+ i18n-label
+ formControlName="linkedToCluster"
+ name="linkedToCluster"
+ helperText="This resource may only be used with the linked cluster and will automatically be removed when the linked cluster is removed."
+ i18n-helperText
+ cdOptionalField="Linked to cluster"
+ >
+ <option
+ [value]="null"
+ i18n
+ >-- List of clusters --
+ </option>
+ <option
+ *ngFor="let cluster of clusters"
+ [value]="cluster.cluster_id">{{ cluster.cluster_id }}
+ </option>
+ </cds-select>
+ </div>
+
+ <!-- Users -->
+ <ng-container
+ formArrayName="users"
+ *ngFor="let _ of users.controls; index as i"
+ >
+ <ng-container [formGroupName]="i">
+ <div
+ cdsRow
+ class="form-item form-item-append"
+ >
+ <div
+ cdsCol
+ [columnNumbers]="{ lg: 7 }"
+ >
+ <cds-text-label
+ for="name"
+ i18n
+ [invalid]="form.controls['users'].controls[i].controls.name.invalid &&
+ form.controls['users'].controls[i].controls.name.dirty"
+ [invalidText]="usersNameError"
+ >Username
+ <input
+ cdsText
+ type="text"
+ formControlName="name"
+ [invalid]="form.controls['users'].controls[i].controls.name.invalid &&
+ form.controls['users'].controls[i].controls.name.dirty"
+ />
+ </cds-text-label>
+ <ng-template #usersNameError>
+ <span
+ class="invalid-feedback"
+ i18n
+ >This field is required.</span
+ >
+ </ng-template>
+ </div>
+ <div
+ cdsCol
+ [columnNumbers]="{ lg: 7 }"
+ >
+ <cds-password-label
+ for="password"
+ i18n
+ [invalid]="form.controls['users'].controls[i].controls.password.invalid &&
+ form.controls['users'].controls[i].controls.password.dirty"
+ [invalidText]="usersPasswordError"
+ >Password
+ <input
+ cdsPassword
+ type="password"
+ label="Password"
+ formControlName="password"
+ [invalid]="form.controls['users'].controls[i].controls.password.invalid &&
+ form.controls['users'].controls[i].controls.password.dirty"
+ >
+ </cds-password-label>
+ <ng-template #usersPasswordError>
+ <span
+ class="invalid-feedback"
+ i18n
+ >This field is required.</span
+ >
+ </ng-template>
+ </div>
+ <div
+ cdsCol
+ [columnNumbers]="{ lg: 1 }"
+ class="item-action-btn spacing"
+ >
+ <cds-icon-button
+ kind="danger"
+ *ngIf="i > 0"
+ size="sm"
+ (click)="removeUser(i)"
+ >
+ <svg
+ cdsIcon="trash-can"
+ size="32"
+ class="cds--btn__icon">
+ </svg>
+ </cds-icon-button>
+ </div>
+ </div>
+ </ng-container>
+ </ng-container>
+
+ <div class="form-item">
+ <button
+ cdsButton="tertiary"
+ type="button"
+ (click)="addUser()"
+ i18n
+ >Add User
+ <svg
+ cdsIcon="add"
+ size="32"
+ class="cds--btn__icon"
+ icon>
+ </svg>
+ </button>
+ </div>
+
+ <!-- Groups -->
+ <ng-container
+ formArrayName="groups"
+ *ngFor="let _ of groups.controls; index as i"
+ >
+ <ng-container [formGroupName]="i">
+ <div
+ cdsRow
+ class="form-item form-item-append"
+ >
+ <div
+ cdsCol
+ [columnNumbers]="{ lg: 14 }"
+ >
+ <cds-text-label
+ for="name"
+ i18n
+ >Group
+ <input
+ cdsText
+ type="text"
+ formControlName="name"
+ />
+ </cds-text-label>
+ </div>
+ <div
+ cdsCol
+ [columnNumbers]="{ lg: 1 }"
+ class="item-action-btn spacing">
+ <cds-icon-button
+ kind="danger"
+ size="sm"
+ (click)="removeGroup(i)"
+ >
+ <svg
+ cdsIcon="trash-can"
+ size="32"
+ class="cds--btn__icon">
+ </svg>
+ </cds-icon-button>
+ </div>
+ </div>
+ </ng-container>
+ </ng-container>
+
+ <div class="form-item">
+ <button cdsButton="tertiary"
+ type="button"
+ (click)="addGroup()"
+ i18n>
+ Add Group
+ <svg
+ cdsIcon="add"
+ size="32"
+ class="cds--btn__icon"
+ icon></svg>
+ </button>
+ </div>
+
+ <cd-form-button-panel
+ (submitActionEvent)="submit()"
+ [form]="form"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"
+ wrappingClass="text-right"></cd-form-button-panel>
+ </form>
+ </div>
--- /dev/null
+@use '@carbon/layout';
+
+.spacing {
+ margin-top: layout.$spacing-06;
+}
--- /dev/null
+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<SmbUsersgroupsFormComponent>;
+ 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);
+ });
+});
--- /dev/null
+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<SMBCluster[]>;
+
+ 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();
+ }
+}
[hasDetails]="true"
(setExpandedRow)="setExpandedRow($event)"
(fetchData)="loadUsersGroups()"
->
+ (updateSelection)="updateSelection($event)"
+ >
+ <div class="table-actions">
+ <cd-table-actions
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </div>
<cd-smb-usersgroups-details
*cdTableDetail
[selection]="expandedRow"
*ngFor="let group of row?.values.groups"
size="md"
>
- {{ group.name }}
+ {{ group?.name }}
</cds-tag>
</ng-template>
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 })
usersGroups$: Observable<SMBUsersGroups[]>;
subject$ = new BehaviorSubject<SMBUsersGroups[]>([]);
+ 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;
ngOnInit() {
this.columns = [
{
- name: $localize`ID`,
+ name: $localize`Name`,
prop: 'users_groups_id',
flexGrow: 2
},
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(
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;
+ }
}
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;
custom_dns?: string[];
placement?: CephServicePlacement;
clustering?: typeof CLUSTERING;
- public_addrs?: PublicAddress;
+ public_addrs?: PublicAddress[];
}
export interface ClusterRequestModel {
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',
export interface SMBJoinAuth {
resource_type: string;
auth_id: string;
- intent: Intent;
auth: Auth;
linked_to_cluster?: string;
}
export interface SMBUsersGroups {
resource_type: string;
users_groups_id: string;
- intent: Intent;
values: Value;
linked_to_cluster?: string;
}
password: string;
}
-interface User {
+export interface User {
name: string;
password: string;
}
-interface Group {
+export interface Group {
name: string;
}
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';
PlaceholderModule,
SelectModule,
TabsModule,
- TagModule
+ TagModule,
+ FileUploaderModule
} from 'carbon-components-angular';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
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,
SelectModule,
TabsModule,
TagModule,
+ FileUploaderModule,
InputModule,
CheckboxModule,
SelectModule,
SmbUsersgroupsDetailsComponent,
SmbTabsComponent,
SmbJoinAuthListComponent,
+ SmbUsersgroupsDetailsComponent,
+ SmbJoinAuthFormComponent,
+ SmbUsersgroupsFormComponent,
SmbShareFormComponent
- ],
- providers: [provideCharts(withDefaultRegisterables())]
+ ]
})
export class SmbModule {
constructor(private iconService: IconService) {
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;
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: [
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: {
observe: 'response'
});
}
+
+ getJoinAuth(authId: string): Observable<SMBJoinAuth> {
+ return this.http.get<SMBJoinAuth>(`${this.baseURL}/joinauth/${authId}`);
+ }
+
+ getUsersGroups(usersGroupsId: string): Observable<SMBUsersGroups> {
+ return this.http.get<SMBUsersGroups>(`${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'
+ });
+ }
}
'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,
),
'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)
)
};
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}'`;
}
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'
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
type: string
placement:
description: Placement configuration for the resource
- (optional)
properties:
count:
description: Number of instances to place
type: string
user_group_settings:
description: User group settings for user auth mode
- (optional)
items:
properties:
ref:
- 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
type: object
type: array
success:
- description: Overall success status of the operation
+ description: Indicates if the overall operation was successful
type: boolean
required:
- results
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:
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\
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
required:
- volume
- path
+ - provider
- subvolumegroup
- subvolume
- - provider
type: object
cluster_id:
description: Unique identifier for the cluster
- 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
type: object
type: array
success:
- description: Overall success status of the operation
+ description: Indicates if the overall operation was successful
type: boolean
required:
- results
/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:
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: []
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'
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))