Unverified Commit f7039bb7 authored by Evgeny Stepanovych's avatar Evgeny Stepanovych Committed by GitHub
Browse files

NAS-116570 / 22.12 / NAS-116570: Refactoring cloudcredentials form (#6918)

parent 71521a21
Showing with 716 additions and 167 deletions
+716 -167
......@@ -191,7 +191,8 @@ module.exports = {
"unused-imports/no-unused-vars": ["error", {
vars: "local",
args: "after-used",
argsIgnorePattern: "^_$"
argsIgnorePattern: "^_$",
ignoreRestSiblings: true,
}],
"@typescript-eslint/ban-types": ["error"],
"unicorn/filename-case": ["error", { case: "kebabCase"}],
......
......@@ -18,3 +18,9 @@ export enum CloudsyncProviderName {
Webdav = 'WEBDAV',
Yandex = 'YANDEX',
}
export enum OneDriveType {
Personal = 'PERSONAL',
Business = 'BUSINESS',
DocumentLibrary = 'DOCUMENT_LIBRARY',
}
import { Validators } from '@angular/forms';
import { marker as T } from '@biesbjerg/ngx-translate-extract-marker';
import { regexValidator } from 'app/modules/entity/entity-form/validators/regex-validation';
export const helptextSystemCloudcredentials = {
fieldset_basic: T('Name and Provider'),
fieldset_authentication: T('Authentication'),
fieldset_advanced: T('Advanced Options'),
fieldset_oauth_authentication: T('OAuth Authentication'),
add_tooltip: T('Add Cloud Credential'),
name: {
placeholder: T('Name'),
tooltip: T('Enter a name for the new credential.'),
validation: [Validators.required],
},
provider: {
placeholder: T('Provider'),
tooltip: T('Third-party Cloud service providers. Choose a provider \
to configure connection credentials.'),
validation: [Validators.required],
},
client_id: {
placeholder: T('OAuth Client ID'),
tooltip: T(''),
},
client_secret: {
placeholder: T('OAuth Client Secret'),
tooltip: T(''),
},
access_key_id_s3: {
placeholder: T('Access Key ID'),
tooltip: T(
'Amazon Web Services Key ID. This is found on \
<a href="https://aws.amazon.com/" target="_blank">Amazon AWS</a> by \
......@@ -43,9 +23,7 @@ export const helptextSystemCloudcredentials = {
between 5 and 20 characters.',
),
},
secret_access_key_s3: {
placeholder: T('Secret Access Key'),
tooltip: T(
'Amazon Web Services password. If the Secret Access Key cannot be \
found or remembered, go to <i>My Account -> Security Credentials -> \
......@@ -53,16 +31,11 @@ export const helptextSystemCloudcredentials = {
between 8 and 40 characters.',
),
},
max_upload_parts_s3: {
placeholder: T('Maximum Upload Parts'),
tooltip: T('Define the maximum number of chunks for a multipart upload. This can \
be useful if a service does not support the 10,000 chunk AWS S3 specification.'),
validation: [regexValidator(/^\d+$/)],
},
endpoint_s3: {
placeholder: T('Endpoint URL'),
tooltip: T('<a href="https://docs.aws.amazon.com/AmazonS3/latest/dev/WebsiteEndpoints.html" \
target="_blank">S3 API endpoint URL</a>. When using AWS, the endpoint \
field can be empty to use the default endpoint for the region, and \
......@@ -71,9 +44,7 @@ export const helptextSystemCloudcredentials = {
<a href="https://docs.aws.amazon.com/general/latest/gr/rande.html#s3_website_region_endpoints \
target="_blank">Simple Storage Service Website Endpoints</a>.'),
},
region_s3: {
placeholder: T('Region'),
tooltip: T('<a href="https://docs.aws.amazon.com/general/latest/gr/rande-manage.html" \
target="_blank">AWS resources in a geographic area</a>. Leave empty to \
automatically detect the correct public region for the bucket. Entering \
......@@ -83,17 +54,13 @@ export const helptextSystemCloudcredentials = {
<a href="https://docs.aws.amazon.com/govcloud-us/latest/UserGuide/whatis.html" target="_blank">AWS GovCloud</a> \
region.'),
},
skip_region_s3: {
placeholder: T('Disable Endpoint Region'),
tooltip: T(
'Skip automatic detection of the Endpoint URL region. Set this \
when configuring a custom Endpoint URL.',
),
},
signatures_v2_s3: {
placeholder: T('Use Signature Version 2'),
tooltip: T(
'Force using \
<a href="https://docs.aws.amazon.com/general/latest/gr/signature-version-2.html" \
......@@ -101,9 +68,7 @@ export const helptextSystemCloudcredentials = {
when configuring a custom Endpoint URL.',
),
},
account_b2: {
placeholder: T('Key ID'),
tooltip: T('Alphanumeric \
<a href="https://www.backblaze.com/b2/cloud-storage.html" \
target="_blank">Backblaze B2</a> Application Key ID. To \
......@@ -111,18 +76,14 @@ generate a new application key, log in to the Backblaze account, \
go to the <i>App Keys</i> page, and add a new application key. \
Copy the application <i>keyID</i> string to this field.'),
},
key_b2: {
placeholder: T('Application Key'),
tooltip: T('<a href="https://www.backblaze.com/b2/cloud-storage.html" \
target="_blank">Backblaze B2</a> Application Key. To generate \
a new application key, log in to the Backblaze account, go to the \
<i>App Keys</i> page, and add a new application key. Copy the \
<i>applicationKey</i> string to this field.'),
},
token_box: {
placeholder: T('Access Token'),
tooltip: T(
'A User Access Token for <a href="https://developer.box.com/" \
target="_blank">Box</a>. An \
......@@ -132,9 +93,7 @@ a new application key, log in to the Backblaze account, go to the \
<i>T9cE5asGnuyYCCqIZFoWjFHvNbvVqHjl</i>.',
),
},
token_dropbox: {
placeholder: T('Access Token'),
tooltip: T(
'Access Token for a Dropbox account. A \
<a href="https://blogs.dropbox.com/developers/2014/05/generate-an-access-token-for-your-own-account/" \
......@@ -143,39 +102,27 @@ a new application key, log in to the Backblaze account, go to the \
before adding it here.',
),
},
host_ftp: {
placeholder: T('Host'),
tooltip: T('FTP Host to connect to. Example: <i>ftp.example.com</i>.'),
},
port_ftp: {
placeholder: T('Port'),
tooltip: T(
'FTP Port number. Leave blank to use the default port <i>21</i>.',
),
},
user_ftp: {
placeholder: T('Username'),
tooltip: T(
'A username on the FTP Host system. This user must already exist \
on the FTP Host.',
),
},
pass_ftp: {
placeholder: T('Password'),
tooltip: T('Password for the user account.'),
},
preview_google_cloud_storage: {
placeholder: T('Preview JSON Service Account Key'),
tooltip: T('Contents of the uploaded Service Account JSON file.'),
},
service_account_credentials_google_cloud_storage: {
placeholder: T('Service Account'),
tooltip: T(
'Upload a Google \
<a href="https://rclone.org/googlecloudstorage/#service-account-support" \
......@@ -184,11 +131,8 @@ a new application key, log in to the Backblaze account, go to the \
<a href="https://console.cloud.google.com/apis/credentials" \
target="_blank">Google Cloud Platform Console</a>.',
),
validation: [Validators.required],
},
token_google_drive: {
placeholder: T('Access Token'),
tooltip: T(
'Token created with \
<a href="https://developers.google.com/drive/api/v3/about-auth"\
......@@ -196,73 +140,53 @@ a new application key, log in to the Backblaze account, go to the \
must be refreshed.',
),
},
token_google_photos: {
placeholder: T('Access Token'),
tooltip: T(
'Token created with \
<a href="https://developers.google.com/drive/api/v3/about-auth"\
target="_blank">Google Drive</a>.',
),
},
team_drive_google_drive: {
placeholder: T('Team Drive ID'),
tooltip: T(
'Only needed when connecting to a Team Drive. The ID of the top \
level folder of the Team Drive.',
),
},
url_http: {
placeholder: T('URL'),
tooltip: T('HTTP host URL.'),
},
token_hubic: {
placeholder: T('Access Token'),
tooltip: T(
'Access Token <a href="https://api.hubic.com/sandbox/" \
target="_blank">generated by a Hubic account</a>.',
),
},
user_mega: {
placeholder: T('Username'),
tooltip: T(
'<a href="https://mega.nz/" target="_blank">MEGA</a> account \
username.',
),
},
pass_mega: {
placeholder: T('Password'),
tooltip: T(
'<a href="https://mega.nz/" target="_blank">MEGA</a> account \
password.',
),
},
account_azureblob: {
placeholder: T('Account Name'),
tooltip: T(
'<a href="https://docs.microsoft.com/en-us/azure/storage/common/storage-create-storage-account" \
target="_blank">Microsoft Azure</a> account name.',
),
},
key_azureblob: {
placeholder: T('Account Key'),
tooltip: T('Base64 encoded key for the Azure account.'),
},
endpoint_azureblob: {
placeholder: T('Endpoint'),
tooltip: T('Example: blob.core.usgovcloudapi.net'),
},
token_onedrive: {
placeholder: T('Access Token'),
tooltip: T(
'Microsoft Onedrive \
<a href="https://docs.microsoft.com/en-us/onedrive/developer/rest-api/getting-started/authentication" \
......@@ -270,192 +194,133 @@ a new application key, log in to the Backblaze account, go to the \
add an access token.',
),
},
drives_onedrive: {
placeholder: T('Drives List'),
tooltip: T('Drives and IDs registered to the Microsoft account. \
Selecting a drive also fills the <i>Drive ID</i> field.'),
},
drive_type_onedrive: {
placeholder: T('Drive Account Type'),
tooltip: T(
'Type of Microsoft acount. Logging in to a Microsoft account \
automatically chooses the correct account type.',
),
},
drive_id_onedrive: {
placeholder: T('Drive ID'),
tooltip: T(
'Unique drive identifier. Log in to a Microsoft account and choose \
a drive from the <i>Drives List</i> drop-down to add a valid ID.',
),
},
user_openstack_swift: {
placeholder: T('User Name'),
tooltip: T('Openstack user name for login. This is the OS_USERNAME from an \
<a href="https://rclone.org/swift/#configuration-from-an-openstack-credentials-file" \
target="_blank">OpenStack credentials file</a>.'),
},
key_openstack_swift: {
placeholder: T('API Key or Password'),
tooltip: T('Openstack API key or password. This is the OS_PASSWORD from an \
<a href="https://rclone.org/swift/#configuration-from-an-openstack-credentials-file" \
target="_blank">OpenStack credentials file</a>.'),
},
auth_openstack_swift: {
placeholder: T('Authentication URL'),
tooltip: T('Authentication URL for the server. This is the OS_AUTH_URL from an \
<a href="https://rclone.org/swift/#configuration-from-an-openstack-credentials-file" \
target="_blank">OpenStack credentials file</a>.'),
},
user_id_openstack_swift: {
placeholder: T('User ID'),
tooltip: T('User ID to log in - optional - most swift systems use user and leave this blank \
<a href="https://rclone.org/swift/#standard-options" target="_blank">(rclone documentation)</a>.'),
},
domain_openstack_swift: {
placeholder: T('User Domain'),
tooltip: T('User domain - optional \
<a href="https://rclone.org/swift/#standard-options" target="_blank">(rclone documentation)</a>.'),
},
tenant_openstack_swift: {
placeholder: T('Tenant Name'),
tooltip: T('This is the OS_TENANT_NAME from an \
<a href="https://rclone.org/swift/#configuration-from-an-openstack-credentials-file" \
target="_blank">OpenStack credentials file</a>.'),
},
tenant_id_openstack_swift: {
placeholder: T('Tenant ID'),
tooltip: T('Tenant ID - optional for v1 auth, this or tenant required otherwise \
<a href="https://rclone.org/swift/#standard-options" target="_blank">(rclone documentation)</a>.'),
},
tenant_domain_openstack_swift: {
placeholder: T('Tenant Domain'),
tooltip: T('Tenant domain - optional \
<a href="https://rclone.org/swift/#standard-options" target="_blank">(rclone documentation)</a>.'),
},
region_openstack_swift: {
placeholder: T('Region Name'),
tooltip: T('Region name - optional \
<a href="https://rclone.org/swift/#standard-options" target="_blank">(rclone documentation)</a>.'),
},
storage_url_openstack_swift: {
placeholder: T('Storage URL'),
tooltip: T('Storage URL - optional \
<a href="https://rclone.org/swift/#standard-options" target="_blank">(rclone documentation)</a>.'),
},
auth_token_openstack_swift: {
placeholder: T('Auth Token'),
tooltip: T('Auth Token from alternate authentication - optional \
<a href="https://rclone.org/swift/#standard-options" target="_blank">(rclone documentation)</a>.'),
},
application_credential_id_openstack_swift: {
placeholder: T('Application Credential ID'),
tooltip: T('<a href="https://rclone.org/swift/#standard-options" target="_blank">(rclone documentation)</a>.'),
},
application_credential_name_openstack_swift: {
placeholder: T('Application Credential Name'),
tooltip: T('<a href="https://rclone.org/swift/#standard-options" target="_blank">(rclone documentation)</a>.'),
},
application_credential_secret_openstack_swift: {
placeholder: T('Application Credential Secret'),
tooltip: T('<a href="https://rclone.org/swift/#standard-options" target="_blank">(rclone documentation)</a>.'),
},
auth_version_openstack_swift: {
placeholder: T('AuthVersion'),
tooltip: T('AuthVersion - optional - set to (1,2,3) if your auth URL has no version \
<a href="https://rclone.org/swift/#standard-options" target="_blank">(rclone documentation)</a>.'),
},
endpoint_type_openstack_swift: {
placeholder: T('Endpoint Type'),
tooltip: T('Endpoint type to choose from the service catalogue. <i>Public</i> is recommended, see the \
<a href="https://rclone.org/swift/#standard-options" target="_blank">rclone documentation</a>.'),
},
token_pcloud: {
placeholder: T('Access Token'),
tooltip: T(
'<a href="https://docs.pcloud.com/methods/intro/authentication.html" \
target="_blank">pCloud Access Token</a>. These tokens can expire and \
require extension.',
),
},
hostname_pcloud: {
placeholder: T('Hostname'),
tooltip: T('Enter the hostname to connect to.'),
},
host_sftp: {
placeholder: T('Host'),
tooltip: T('SSH Host to connect to.'),
},
port_sftp: {
placeholder: T('Port'),
tooltip: T(
'SSH port number. Leave empty to use the default port\
<i>22</i>.',
),
},
user_sftp: {
placeholder: T('Username'),
tooltip: T('SSH Username.'),
},
pass_sftp: {
placeholder: T('Password'),
tooltip: T('Password for the SSH Username account.'),
},
private_key_sftp: {
placeholder: T('Private Key ID'),
tooltip: T('Import the private key from an existing SSH keypair or \
select <i>Generate New</i> to create a new SSH key for this credential.'),
},
url_webdav: {
placeholder: T('URL'),
tooltip: T('URL of the HTTP host to connect to.'),
},
vendor_webdav: {
placeholder: T('WebDAV service'),
tooltip: T('Name of the WebDAV site, service, or software being used.'),
},
user_webdav: {
placeholder: T('Username'),
tooltip: T('WebDAV account username.'),
},
pass_webdav: {
placeholder: T('Password'),
tooltip: T('WebDAV account password.'),
},
token_yandex: {
placeholder: T('Access Token'),
tooltip: T(
'Yandex \
<a href="https://tech.yandex.com/direct/doc/dg-v4/concepts/auth-token-docpage/" \
target="_blank">Access Token</a>.',
),
},
formTitle: T('Cloud Credentials'),
};
......@@ -8,7 +8,7 @@ export interface CloudCredential {
id: number;
name: string;
provider: string;
attributes: { [key: string]: string };
attributes: { [key: string]: string | number | boolean };
}
export interface BwLimit {
......
import { CloudsyncProviderName } from 'app/enums/cloudsync-provider-name.enum';
import { CloudsyncProviderName, OneDriveType } from 'app/enums/cloudsync-provider.enum';
export interface CloudsyncCredential {
attributes: {
[attribute: string]: string;
[attribute: string]: string | number | boolean;
};
id: number;
name: string;
......@@ -25,7 +25,7 @@ export interface CloudsyncBucket {
}
export interface CloudsyncOneDriveDrive {
drive_type: string;
drive_type: OneDriveType;
drive_id: string;
}
......
import { CloudsyncProviderName } from 'app/enums/cloudsync-provider-name.enum';
import { CloudsyncProviderName } from 'app/enums/cloudsync-provider.enum';
import { TransferMode } from 'app/enums/transfer-mode.enum';
export interface CloudsyncProvider {
......@@ -7,8 +7,8 @@ export interface CloudsyncProvider {
credentials_oauth: string;
credentials_schema: any[];
name: CloudsyncProviderName;
task_schema: any[];
title: CloudsyncProviderName;
task_schema: unknown[]; // Not really used
title: string;
}
export type CloudsyncRestoreParams = [
......
......@@ -19,6 +19,13 @@
[header]="config.placeholder"
[message]="config.tooltip"
></ix-tooltip>
<a
*ngIf="config.linkText"
(click)="linkClicked()"
class="link"
>{{ config.linkText | translate }}<mat-icon style="font-size: x-small;">open_in_new</mat-icon>
</a>
</div>
<ng-container *ngIf="config.inlineLabel">
......@@ -110,10 +117,6 @@
</mat-select>
</mat-form-field>
<div *ngIf="config.linkText">
<a (click)="linkClicked()" class="link">{{ config.linkText | translate }}<mat-icon style="font-size: x-small;">open_in_new</mat-icon></a>
</div>
<div class="margin-for-error">
<ix-form-errors [control]="group.controls[config.name]" [config]="config"></ix-form-errors>
<mat-error *ngIf="config['hasErrors']"><div [innerHTML]="config['errors']"></div></mat-error>
......
......@@ -19,10 +19,24 @@
padding: 8px;
}
.label-container {
align-items: flex-end;
display: flex;
}
.link {
color: var(--primary);
cursor: pointer;
display: block;
font-size: x-small;
margin-left: auto;
mat-icon {
line-height: 40px;
margin-left: 3px;
margin-right: 1px;
width: unset;
}
}
:host-context(ix-system-alert) {
......
......@@ -80,7 +80,7 @@ export class EntityFormComponent implements OnInit, OnDestroy, OnChanges, AfterV
templateTop: TemplateRef<unknown>;
@ContentChildren(EntityTemplateDirective)
templates: QueryList<EntityTemplateDirective>;
templates: QueryList<EntityTemplateDirective>;
sub: Subscription;
error: string;
......
......@@ -8,12 +8,13 @@
<ix-tooltip *ngIf="tooltip" [header]="label" class="tooltip" [message]="tooltip"></ix-tooltip>
</div>
<div class="input-container" [class.disabled]="isDisabled">
<div class="input-container" [class.disabled]="isDisabled" [class.readonly]="readonly">
<textarea
matInput
[rows]="rows"
[required]="required"
[disabled]="isDisabled"
[readonly]="readonly"
[(ngModel)]="value"
(ngModelChange)="onChange($event)"
(blur)="onTouch()"
......
......@@ -39,6 +39,14 @@
opacity: 1;
}
}
&.readonly {
background: var(--bg2);
input {
cursor: copy;
}
}
}
.label-container {
......
......@@ -18,6 +18,7 @@ export class IxTextareaComponent implements ControlValueAccessor {
@Input() tooltip: string;
@Input() required: boolean;
@Input() rows = 4;
@Input() readonly: boolean;
formControl = new UntypedFormControl(this).value as UntypedFormControl;
......
......@@ -3,6 +3,7 @@ import { IxCheckboxHarness } from 'app/modules/ix-forms/components/ix-checkbox/i
import { IxChipsHarness } from 'app/modules/ix-forms/components/ix-chips/ix-chips.harness';
import { IxComboboxHarness } from 'app/modules/ix-forms/components/ix-combobox/ix-combobox.harness';
import { IxExplorerHarness } from 'app/modules/ix-forms/components/ix-explorer/ix-explorer.harness';
import { IxFileInputHarness } from 'app/modules/ix-forms/components/ix-file-input/ix-file-input.harness';
import { IxInputHarness } from 'app/modules/ix-forms/components/ix-input/ix-input.harness';
import {
IxIpInputWithNetmaskHarness,
......@@ -31,6 +32,7 @@ export const supportedFormControlSelectors = [
JiraOauthHarness,
SchedulerHarness,
IxIpInputWithNetmaskHarness,
IxFileInputHarness,
] as const;
export type SupportedFormControlHarness = InstanceType<(typeof supportedFormControlSelectors)[number]>;
......
......@@ -11,6 +11,9 @@ import {
KeychainSshKeyPair,
} from 'app/interfaces/keychain-credential.interface';
import { AppTableAction, AppTableConfig } from 'app/modules/entity/table/table.component';
import {
CloudCredentialsFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/cloud-credentials-form.component';
import {
SshConnectionFormComponent,
} from 'app/pages/credentials/backup-credentials/ssh-connection-form/ssh-connection-form.component';
......@@ -19,8 +22,6 @@ import {
KeychainCredentialService, ReplicationService, StorageService, CloudCredentialService,
} from 'app/services';
import { IxSlideInService } from 'app/services/ix-slide-in.service';
import { ModalService } from 'app/services/modal.service';
import { CloudCredentialsFormComponent } from './forms/cloud-credentials-form.component';
@UntilDestroy()
@Component({
......@@ -32,12 +33,12 @@ export class BackupCredentialsComponent implements OnInit {
private navigation: Navigation;
protected providers: CloudsyncProvider[];
private isFirstCredentialsLoad = true;
constructor(
private router: Router,
private storage: StorageService,
private cloudCredentialsService: CloudCredentialService,
private modalService: ModalService,
private slideInService: IxSlideInService,
private translate: TranslateService,
) {
......@@ -45,10 +46,6 @@ export class BackupCredentialsComponent implements OnInit {
}
ngOnInit(): void {
this.modalService.refreshTable$.pipe(untilDestroyed(this)).subscribe(() => {
this.getCards();
});
this.slideInService.onClose$.pipe(untilDestroyed(this)).subscribe(() => {
this.getCards();
});
......@@ -72,24 +69,32 @@ export class BackupCredentialsComponent implements OnInit {
name: 'cloudCreds',
columns: [
{ name: this.translate.instant('Name'), prop: 'name' },
{ name: this.translate.instant('Provider'), prop: 'provider' },
{ name: this.translate.instant('Provider'), prop: 'providerTitle' },
],
hideHeader: false,
parent: this,
add: () => {
this.modalService.openInSlideIn(CloudCredentialsFormComponent);
this.slideInService.open(CloudCredentialsFormComponent);
},
edit: (row: CloudsyncCredential) => {
this.modalService.openInSlideIn(CloudCredentialsFormComponent, row.id);
edit: (credential: CloudsyncCredential) => {
const form = this.slideInService.open(CloudCredentialsFormComponent);
form.setCredentialsForEdit(credential);
},
dataSourceHelper: this.cloudCredentialsDataSourceHelper.bind(this),
afterGetData: () => {
afterGetData: (credentials: CloudsyncCredential[]) => {
const state = this.navigation.extras.state as { editCredential: string; id: string };
if (state && state.editCredential) {
if (state.editCredential === 'cloudcredentials') {
this.modalService.openInSlideIn(CloudCredentialsFormComponent, state.id);
}
if (!state || state.editCredential !== 'cloudcredentials' || !this.isFirstCredentialsLoad) {
return;
}
const credentialToEdit = credentials.find((credential) => credential.id === Number(state.id));
if (!credentialToEdit) {
return;
}
const form = this.slideInService.open(CloudCredentialsFormComponent);
form.setCredentialsForEdit(credentialToEdit);
this.isFirstCredentialsLoad = false;
},
},
}, {
......@@ -139,12 +144,15 @@ export class BackupCredentialsComponent implements OnInit {
];
}
cloudCredentialsDataSourceHelper(res: CloudsyncCredential[]): CloudsyncCredential[] {
cloudCredentialsDataSourceHelper(res: CloudsyncCredential[]): (CloudsyncCredential & { providerTitle?: string })[] {
return res.map((item) => {
if (this.providers) {
const credentialProvider = this.providers.find((provider) => provider.name === item.provider);
if (credentialProvider) {
item.provider = credentialProvider.title;
return {
...item,
providerTitle: credentialProvider.title,
};
}
}
return item;
......
<ix-modal-header [title]="'Cloud Credentials' | translate" [loading]="isLoading"></ix-modal-header>
<mat-card>
<mat-card-content>
<form class="ix-form-container" (submit)="onSubmit()">
<ix-fieldset [title]="'Name and Provider' | translate" [formGroup]="commonForm">
<ix-input
formControlName="name"
[label]="'Name' | translate"
[required]="true"
[tooltip]="helptext.name.tooltip | translate"
></ix-input>
<ix-select
formControlName="provider"
[label]="'Provider' | translate"
[required]="true"
[options]="providerOptions"
[tooltip]="helptext.provider.tooltip | translate"
></ix-select>
</ix-fieldset>
<ng-container #providerFormContainer></ng-container>
<div class="form-actions">
<button
mat-button
type="button"
[disabled]="areActionsDisabled"
(click)="onVerify()"
>{{ 'Verify Credential' | translate }}</button>
<button
mat-button
type="submit"
[disabled]="areActionsDisabled"
color="primary"
>{{ 'Save' | translate }}</button>
</div>
</form>
</mat-card-content>
</mat-card>
.form-actions {
margin: 8px 10px;
button {
margin-right: 8px;
}
}
// eslint-disable-next-line max-classes-per-file
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { Component } from '@angular/core';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatButtonHarness } from '@angular/material/button/testing';
import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest';
import { of } from 'rxjs';
import { MockWebsocketService } from 'app/core/testing/classes/mock-websocket.service';
import { mockCall, mockWebsocket } from 'app/core/testing/utils/mock-websocket.utils';
import { CloudsyncProviderName } from 'app/enums/cloudsync-provider.enum';
import { CloudsyncCredential } from 'app/interfaces/cloudsync-credential.interface';
import { CloudsyncProvider } from 'app/interfaces/cloudsync-provider.interface';
import { IxSelectHarness } from 'app/modules/ix-forms/components/ix-select/ix-select.harness';
import { IxFormsModule } from 'app/modules/ix-forms/ix-forms.module';
import { IxFormHarness } from 'app/modules/ix-forms/testing/ix-form.harness';
import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service';
import {
BaseProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/base-provider-form';
import {
S3ProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/s3-provider-form/s3-provider-form.component';
import {
TokenProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/token-provider-form/token-provider-form.component';
import { DialogService, WebSocketService } from 'app/services';
import { IxSlideInService } from 'app/services/ix-slide-in.service';
import { CloudCredentialsFormComponent } from './cloud-credentials-form.component';
jest.mock('./provider-forms/s3-provider-form/s3-provider-form.component', () => {
return {
S3ProviderFormComponent: Component({
template: '',
})(class {
provider: CloudsyncProvider;
setValues = jest.fn() as BaseProviderFormComponent['setValues'];
getSubmitAttributes = jest.fn(() => ({
s3attribute: 's3 value',
})) as BaseProviderFormComponent['getSubmitAttributes'];
beforeSubmit = jest.fn(() => of(undefined)) as BaseProviderFormComponent['beforeSubmit'];
form = {
get invalid(): boolean {
return false;
},
} as FormGroup;
}),
};
});
jest.mock('./provider-forms/token-provider-form/token-provider-form.component', () => {
return {
TokenProviderFormComponent: Component({
template: '',
})(class {
provider: CloudsyncProvider;
}),
};
});
describe('CloudCredentialsFormComponent', () => {
let spectator: Spectator<CloudCredentialsFormComponent>;
let loader: HarnessLoader;
let form: IxFormHarness;
const s3Provider = {
name: CloudsyncProviderName.AmazonS3,
title: 'Amazon S3',
} as CloudsyncProvider;
const boxProvider = {
name: CloudsyncProviderName.Box,
title: 'Box',
} as CloudsyncProvider;
const createComponent = createComponentFactory({
component: CloudCredentialsFormComponent,
imports: [
ReactiveFormsModule,
IxFormsModule,
],
declarations: [
TokenProviderFormComponent,
S3ProviderFormComponent,
],
providers: [
mockProvider(IxSlideInService),
mockProvider(SnackbarService),
mockProvider(DialogService),
mockWebsocket([
mockCall('cloudsync.credentials.create'),
mockCall('cloudsync.credentials.update'),
mockCall('cloudsync.credentials.verify', {
valid: true,
}),
mockCall('cloudsync.providers', [s3Provider, boxProvider]),
]),
],
});
beforeEach(async () => {
spectator = createComponent();
loader = TestbedHarnessEnvironment.loader(spectator.fixture);
form = await loader.getHarness(IxFormHarness);
});
describe('rendering', () => {
it('loads a list of providers and shows them in Provider select', async () => {
expect(spectator.inject(WebSocketService).call).toHaveBeenCalledWith('cloudsync.providers');
const providersSelect = await form.getControl('Provider') as IxSelectHarness;
expect(await providersSelect.getOptionLabels()).toEqual(['Amazon S3', 'Box']);
});
it('renders dynamic provider specific form when Provider is selected', async () => {
const providersSelect = await form.getControl('Provider') as IxSelectHarness;
await providersSelect.setValue('Amazon S3');
const providerForm = spectator.query(S3ProviderFormComponent);
expect(providerForm).toBeTruthy();
expect(providerForm.provider).toBe(s3Provider);
});
it('renders a token only form for some providers', async () => {
const providersSelect = await form.getControl('Provider') as IxSelectHarness;
await providersSelect.setValue('Box');
const providerForm = spectator.query(TokenProviderFormComponent);
expect(providerForm).toBeTruthy();
expect(providerForm.provider).toBe(boxProvider);
});
});
describe('verification', () => {
it('verifies entered values when user presses Verify', async () => {
await form.fillForm({
Name: 'New sync',
Provider: 'Amazon S3',
});
const verifyButton = await loader.getHarness(MatButtonHarness.with({ text: 'Verify Credential' }));
await verifyButton.click();
expect(spectator.inject(WebSocketService).call).toHaveBeenCalledWith('cloudsync.credentials.verify', [{
provider: 'S3',
attributes: {
s3attribute: 's3 value',
},
}]);
});
it('calls beforeSubmit before verifying entered values', async () => {
await form.fillForm({
Name: 'New sync',
Provider: 'Amazon S3',
});
const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Verify Credential' }));
await saveButton.click();
const providerForm = spectator.query(S3ProviderFormComponent);
expect(providerForm.beforeSubmit).toHaveBeenCalled();
});
it('shows an error when verification fails', async () => {
const mockWebsocket = spectator.inject(MockWebsocketService);
mockWebsocket.mockCall('cloudsync.credentials.verify', {
valid: false,
excerpt: 'Missing some important field',
error: 'Some error',
});
await form.fillForm({
Name: 'New sync',
Provider: 'Amazon S3',
});
const verifyButton = await loader.getHarness(MatButtonHarness.with({ text: 'Verify Credential' }));
await verifyButton.click();
expect(spectator.inject(DialogService).errorReport).toHaveBeenCalledWith(
'Error',
'Missing some important field',
expect.anything(),
);
});
});
describe('saving', () => {
it('shows existing values when form is opened for edit', async () => {
spectator.component.setCredentialsForEdit({
id: 233,
name: 'My backup server',
provider: CloudsyncProviderName.AmazonS3,
attributes: {
hostname: 'backup.com',
},
} as CloudsyncCredential);
const commonFormValues = await form.getValues();
expect(commonFormValues).toEqual({
Name: 'My backup server',
Provider: 'Amazon S3',
});
const providerForm = spectator.query(S3ProviderFormComponent);
expect(providerForm).toBeTruthy();
expect(providerForm.setValues).toHaveBeenCalledWith({
hostname: 'backup.com',
});
});
it('calls beforeSubmit before saving form', async () => {
await form.fillForm({
Name: 'New sync',
Provider: 'Amazon S3',
});
const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' }));
await saveButton.click();
const providerForm = spectator.query(S3ProviderFormComponent);
expect(providerForm.beforeSubmit).toHaveBeenCalled();
});
it('saves new credentials when new form is saved', async () => {
await form.fillForm({
Name: 'New sync',
Provider: 'Amazon S3',
});
const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' }));
await saveButton.click();
expect(spectator.inject(WebSocketService).call).toHaveBeenCalledWith('cloudsync.credentials.create', [{
name: 'New sync',
provider: CloudsyncProviderName.AmazonS3,
attributes: {
s3attribute: 's3 value',
},
}]);
expect(spectator.inject(IxSlideInService).close).toHaveBeenCalledWith();
expect(spectator.inject(SnackbarService).success).toHaveBeenCalled();
});
it('updates existing credentials when edit form is saved', async () => {
spectator.component.setCredentialsForEdit({
id: 233,
name: 'My backup server',
provider: CloudsyncProviderName.AmazonS3,
attributes: {
hostname: 'backup.com',
},
} as CloudsyncCredential);
await form.fillForm({
Name: 'My updated server',
});
const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' }));
await saveButton.click();
expect(spectator.inject(WebSocketService).call).toHaveBeenCalledWith('cloudsync.credentials.update', [
233,
{
name: 'My updated server',
provider: CloudsyncProviderName.AmazonS3,
attributes: {
s3attribute: 's3 value',
},
},
]);
expect(spectator.inject(IxSlideInService).close).toHaveBeenCalledWith();
expect(spectator.inject(SnackbarService).success).toHaveBeenCalled();
});
});
});
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnInit,
Type,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateService } from '@ngx-translate/core';
import { Observable, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { CloudsyncProviderName } from 'app/enums/cloudsync-provider.enum';
import { helptextSystemCloudcredentials as helptext } from 'app/helptext/system/cloud-credentials';
import { CloudsyncCredential, CloudsyncCredentialUpdate } from 'app/interfaces/cloudsync-credential.interface';
import { CloudsyncProvider } from 'app/interfaces/cloudsync-provider.interface';
import { Option } from 'app/interfaces/option.interface';
import { EntityUtils } from 'app/modules/entity/utils';
import { FormErrorHandlerService } from 'app/modules/ix-forms/services/form-error-handler.service';
import { SnackbarService } from 'app/modules/snackbar/services/snackbar.service';
import {
AzureProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/azure-provider-form/azure-provider-form.component';
import {
BackblazeB2ProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/backblaze-b2-provider-form/backblaze-b2-provider-form.component';
import {
BaseProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/base-provider-form';
import {
FtpProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/ftp-provider-form/ftp-provider-form.component';
import {
GoogleCloudProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/google-cloud-provider-form/google-cloud-provider-form.component';
import {
GoogleDriveProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/google-drive-provider-form/google-drive-provider-form.component';
import {
HttpProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/http-provider-form/http-provider-form.component';
import {
MegaProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/mega-provider-form/mega-provider-form.component';
import {
OneDriveProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/one-drive-provider-form/one-drive-provider-form.component';
import {
OpenstackSwiftProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/openstack-swift-provider-form/openstack-swift-provider-form.component';
import {
PcloudProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/pcloud-provider-form/pcloud-provider-form.component';
import {
S3ProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/s3-provider-form/s3-provider-form.component';
import {
SftpProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/sftp-provider-form/sftp-provider-form.component';
import {
TokenProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/token-provider-form/token-provider-form.component';
import {
WebdavProviderFormComponent,
} from 'app/pages/credentials/backup-credentials/cloud-credentials-form/provider-forms/webdav-provider-form/webdav-provider-form.component';
import { DialogService, WebSocketService } from 'app/services';
import { IxSlideInService } from 'app/services/ix-slide-in.service';
// TODO: Form is partially backend driven and partially hardcoded on the frontend.
@UntilDestroy()
@Component({
templateUrl: './cloud-credentials-form.component.html',
styleUrls: ['./cloud-credentials-form.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CloudCredentialsFormComponent implements OnInit {
commonForm = this.formBuilder.group({
name: ['', Validators.required],
provider: [null as CloudsyncProviderName],
});
isLoading = false;
existingCredential: CloudsyncCredential;
providers: CloudsyncProvider[] = [];
providerOptions: Observable<Option[]> = of([]);
providerForm: BaseProviderFormComponent;
@ViewChild('providerFormContainer', { static: true, read: ViewContainerRef }) providerFormContainer: ViewContainerRef;
readonly helptext = helptext;
constructor(
private ws: WebSocketService,
private formBuilder: FormBuilder,
private cdr: ChangeDetectorRef,
private slideInService: IxSlideInService,
private dialogService: DialogService,
private errorHandler: FormErrorHandlerService,
private translate: TranslateService,
private snackbarService: SnackbarService,
) {
// Has to be earlier than potential `setCredentialsForEdit` call
this.setFormEvents();
}
get isNew(): boolean {
return !this.existingCredential;
}
get selectedProvider(): CloudsyncProvider {
return this.providers?.find((provider) => {
return provider.name === this.commonForm.controls.provider.value;
});
}
get areActionsDisabled(): boolean {
return this.isLoading
|| this.commonForm.invalid
|| this.providerForm?.form?.invalid;
}
ngOnInit(): void {
this.loadProviders();
}
setCredentialsForEdit(credential: CloudsyncCredential): void {
this.existingCredential = credential;
this.commonForm.patchValue(credential);
if (this.providerForm) {
this.providerForm.setValues(this.existingCredential.attributes);
}
}
onSubmit(): boolean {
this.isLoading = true;
const beforeSubmit$ = this.providerForm.beforeSubmit();
beforeSubmit$
.pipe(
switchMap(() => {
const payload = this.preparePayload();
return this.isNew
? this.ws.call('cloudsync.credentials.create', [payload])
: this.ws.call('cloudsync.credentials.update', [this.existingCredential.id, payload]);
}),
untilDestroyed(this),
)
.subscribe(
() => {
this.isLoading = false;
this.snackbarService.success(
this.isNew
? this.translate.instant('Cloud credential added.')
: this.translate.instant('Cloud credential updated.'),
);
this.slideInService.close();
this.cdr.markForCheck();
},
(error) => {
// TODO: Errors for nested provider form will be shown in a modal. Can be improved.
this.isLoading = false;
this.errorHandler.handleWsFormError(error, this.commonForm);
this.cdr.markForCheck();
},
);
return false;
}
onVerify(): void {
this.isLoading = true;
const beforeSubmit$ = this.providerForm.beforeSubmit();
beforeSubmit$
.pipe(
switchMap(() => {
const { name, ...payload } = this.preparePayload();
return this.ws.call('cloudsync.credentials.verify', [payload]);
}),
untilDestroyed(this),
)
.subscribe(
(response) => {
if (response.valid) {
this.dialogService.info(
this.translate.instant('Valid'),
this.translate.instant('The credentials are valid.'),
);
} else {
this.dialogService.errorReport('Error', response.excerpt, response.error);
}
this.isLoading = false;
this.cdr.markForCheck();
},
(error) => {
this.isLoading = false;
this.errorHandler.handleWsFormError(error, this.commonForm);
this.cdr.markForCheck();
},
);
}
private preparePayload(): CloudsyncCredentialUpdate {
const commonValues = this.commonForm.value;
return {
name: commonValues.name,
provider: commonValues.provider,
attributes: this.providerForm.getSubmitAttributes(),
};
}
private loadProviders(): void {
this.isLoading = true;
this.ws.call('cloudsync.providers')
.pipe(untilDestroyed(this))
.subscribe(
(providers) => {
this.providers = providers;
this.providerOptions = of(
providers.map((provider) => ({
label: provider.title,
value: provider.name,
})),
);
this.renderProviderForm();
if (this.existingCredential) {
this.providerForm.setValues(this.existingCredential.attributes);
} else {
this.commonForm.patchValue({
provider: providers[0].name,
});
}
this.isLoading = false;
this.cdr.markForCheck();
},
(error) => {
new EntityUtils().handleWsError(null, error, this.dialogService);
this.slideInService.close();
},
);
}
private setFormEvents(): void {
this.commonForm.controls.provider.valueChanges
.pipe(untilDestroyed(this))
.subscribe(() => {
this.renderProviderForm();
});
}
private renderProviderForm(): void {
this.providerFormContainer?.clear();
if (!this.selectedProvider) {
return;
}
const formClass = this.getProviderFormClass();
const formRef = this.providerFormContainer.createComponent(formClass);
formRef.instance.provider = this.selectedProvider;
this.providerForm = formRef.instance;
}
private getProviderFormClass(): Type<BaseProviderFormComponent> {
const tokenOnlyProviders = [
CloudsyncProviderName.Box,
CloudsyncProviderName.Dropbox,
CloudsyncProviderName.GooglePhotos,
CloudsyncProviderName.Hubic,
CloudsyncProviderName.Yandex,
];
if (tokenOnlyProviders.includes(this.selectedProvider.name)) {
return TokenProviderFormComponent;
}
const formMapping = new Map<CloudsyncProviderName, Type<BaseProviderFormComponent>>([
[CloudsyncProviderName.MicrosoftAzure, AzureProviderFormComponent],
[CloudsyncProviderName.BackblazeB2, BackblazeB2ProviderFormComponent],
[CloudsyncProviderName.Ftp, FtpProviderFormComponent],
[CloudsyncProviderName.GoogleCloudStorage, GoogleCloudProviderFormComponent],
[CloudsyncProviderName.GoogleDrive, GoogleDriveProviderFormComponent],
[CloudsyncProviderName.Http, HttpProviderFormComponent],
[CloudsyncProviderName.Mega, MegaProviderFormComponent],
[CloudsyncProviderName.MicrosoftOnedrive, OneDriveProviderFormComponent],
[CloudsyncProviderName.OpenstackSwift, OpenstackSwiftProviderFormComponent],
[CloudsyncProviderName.Pcloud, PcloudProviderFormComponent],
[CloudsyncProviderName.AmazonS3, S3ProviderFormComponent],
[CloudsyncProviderName.Sftp, SftpProviderFormComponent],
[CloudsyncProviderName.Webdav, WebdavProviderFormComponent],
]);
return formMapping.get(this.selectedProvider.name);
}
}
<ix-fieldset [title]="'OAuth Authentication' | translate" [formGroup]="form">
<button
class="login-button"
mat-button
type="button"
(click)="onLoginPressed()"
>{{ 'Log In To Provider' | translate }}</button>
<ix-input
formControlName="client_id"
[label]="'OAuth Client ID' | translate"
></ix-input>
<ix-input
formControlName="client_secret"
[label]="'OAuth Client Secret' | translate"
type="password"
></ix-input>
</ix-fieldset>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment