diff --git a/frontend/package.json b/frontend/package.json index 66db77136..4e471624b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,6 +46,7 @@ "chart.js": "^4.5.1", "chartjs-adapter-date-fns": "^3.0.0", "color-string": "^2.0.1", + "connection-string-parser": "^1.0.4", "convert": "^5.12.0", "date-fns": "^4.1.0", "ipaddr.js": "^2.2.0", diff --git a/frontend/src/app/components/connect-db/connect-db.component.css b/frontend/src/app/components/connect-db/connect-db.component.css index 359d9d312..47be30a50 100644 --- a/frontend/src/app/components/connect-db/connect-db.component.css +++ b/frontend/src/app/components/connect-db/connect-db.component.css @@ -24,22 +24,28 @@ } } -.credentials-fieldset { +:host ::ng-deep .credentials-fieldset { display: grid; grid-template-columns: subgrid; grid-template-rows: subgrid; grid-column: 1 / span 4; - grid-row: 5 / span 4; + grid-row: 6 / span 4; } +:host ::ng-deep .connectForm__typeSwitch + ndc-dynamic + .credentials-fieldset { + grid-row: 4 / span 4; +} + + + @media (width <= 600px) { - .credentials-fieldset { + :host ::ng-deep .credentials-fieldset { display: flex; flex-direction: column; } } -.credentials-fieldset-no-warning { +/* :host ::ng-deep .credentials-fieldset-no-warning { display: grid; grid-template-columns: subgrid; grid-template-rows: subgrid; @@ -48,11 +54,11 @@ } @media (width <= 600px) { - .credentials-fieldset-no-warning { + :host ::ng-deep .credentials-fieldset-no-warning { display: flex; flex-direction: column; } -} +} */ .mat-h1 { margin-top: 2vw; @@ -87,7 +93,8 @@ } .connectForm__ipAlert { - --alert-margin: 0; + --alert-margin: 0 !important; + margin-bottom: 12px; } .connectForm__title { @@ -103,11 +110,11 @@ grid-column: 1 / span 4; } -.connectForm__typeSwitch { +.connectForm__toggle { margin-bottom: 12px; } -.connectForm__typeSwitch ::ng-deep .mat-button-toggle-checked { +.connectForm__toggle ::ng-deep .mat-button-toggle-checked { background-color: rgba(0, 0, 0, 0.08) !important; } @@ -200,6 +207,21 @@ padding: 8px 12px; } +.connectForm__connectionString { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.connectForm__connectionString ::ng-deep .mat-mdc-text-field-wrapper { + padding-right: 80px; +} + +.connectForm__connectionString button { + margin-top: 4px; + margin-left: -80px; +} + .agent-token { display: flex; margin-top: 32px; diff --git a/frontend/src/app/components/connect-db/connect-db.component.html b/frontend/src/app/components/connect-db/connect-db.component.html index 38729bb20..554d27df9 100644 --- a/frontend/src/app/components/connect-db/connect-db.component.html +++ b/frontend/src/app/components/connect-db/connect-db.component.html @@ -38,10 +38,9 @@

-
+
Direct connection @@ -51,7 +50,7 @@

+ class="connectForm__fullLine connectForm__warningMessage">
warning_amber
@@ -72,156 +71,53 @@

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @if (db.connectionType === 'direct' && !db.id) { +
+ + Connection string + + Paste your database connection URI to auto-fill credentials + @if (connectionStringInput.errors?.invalidConnectionStringFormat) { + Invalid format. Expected: scheme://user:password@host:port/database + } + @if (connectionStringInput.errors?.unsupportedScheme) { + Unsupported scheme "{{ connectionStringInput.errors?.unsupportedScheme }}" + } + @if (connectionStringInput.errors?.invalidConnectionString) { + Failed to parse connection string + } + + +
+ } - - + @if (db.connectionType === 'direct') { + + } +
diff --git a/frontend/src/app/components/connect-db/connect-db.component.ts b/frontend/src/app/components/connect-db/connect-db.component.ts index b225ac880..0fa290dc2 100644 --- a/frontend/src/app/components/connect-db/connect-db.component.ts +++ b/frontend/src/app/components/connect-db/connect-db.component.ts @@ -1,6 +1,6 @@ import { CdkCopyToClipboard } from '@angular/cdk/clipboard'; import { CommonModule } from '@angular/common'; -import { Component, NgZone, OnInit } from '@angular/core'; +import { Component, NgZone, OnInit, Type } from '@angular/core'; import { FormsModule, NgForm } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; @@ -13,6 +13,7 @@ import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; import { Title } from '@angular/platform-browser'; import { Router, RouterModule } from '@angular/router'; +import { DynamicAttributesDirective, DynamicModule } from 'ng-dynamic-component'; import { Angulartics2, Angulartics2Module } from 'angulartics2'; import * as ipaddr from 'ipaddr.js'; import posthog from 'posthog-js'; @@ -28,6 +29,9 @@ import { NotificationsService } from 'src/app/services/notifications.service'; import { UserService } from 'src/app/services/user.service'; import { environment } from 'src/environments/environment'; import isIP from 'validator/lib/isIP'; +import { ConnectionStringValidatorDirective } from '../../directives/connection-string-validator.directive'; +import { parseConnectionString } from '../../validators/connection-string.validator'; +import { BaseCredentialsFormComponent } from './db-credentials-forms/base-credentials-form/base-credentials-form.component'; import { AlertComponent } from '../ui-components/alert/alert.component'; import { IpAddressButtonComponent } from '../ui-components/ip-address-button/ip-address-button.component'; import { DbConnectionConfirmDialogComponent } from './db-connection-confirm-dialog/db-connection-confirm-dialog.component'; @@ -63,20 +67,12 @@ import { RedisCredentialsFormComponent } from './db-credentials-forms/redis-cred MatDialogModule, MatCheckboxModule, MatSlideToggleModule, - Db2CredentialsFormComponent, - DynamodbCredentialsFormComponent, - CassandraCredentialsFormComponent, - MongodbCredentialsFormComponent, - MssqlCredentialsFormComponent, - MysqlCredentialsFormComponent, - OracledbCredentialsFormComponent, - PostgresCredentialsFormComponent, - RedisCredentialsFormComponent, - ElasticCredentialsFormComponent, - ClickhouseCredentialsFormComponent, IpAddressButtonComponent, AlertComponent, Angulartics2Module, + ConnectionStringValidatorDirective, + DynamicModule, + DynamicAttributesDirective, ], }) export class ConnectDBComponent implements OnInit { @@ -94,6 +90,22 @@ export class ConnectDBComponent implements OnInit { message: null, }; + public connectionString: string = ''; + + public credentialsFormMap: Record> = { + [DBtype.MySQL]: MysqlCredentialsFormComponent, + [DBtype.Postgres]: PostgresCredentialsFormComponent, + [DBtype.Mongo]: MongodbCredentialsFormComponent, + [DBtype.Dynamo]: DynamodbCredentialsFormComponent, + [DBtype.Cassandra]: CassandraCredentialsFormComponent, + [DBtype.Oracle]: OracledbCredentialsFormComponent, + [DBtype.MSSQL]: MssqlCredentialsFormComponent, + [DBtype.Redis]: RedisCredentialsFormComponent, + [DBtype.Elasticsearch]: ElasticCredentialsFormComponent, + [DBtype.ClickHouse]: ClickhouseCredentialsFormComponent, + [DBtype.DB2]: Db2CredentialsFormComponent, + }; + public supportedOrderedDatabases = supportedOrderedDatabases; public supportedDatabasesTitles = supportedDatabasesTitles; public ports = { @@ -119,6 +131,14 @@ export class ConnectDBComponent implements OnInit { "This is a DEMO SESSION! It will disappear after you log out. Don't use databases you're actively using or that contain information you wish to retain.", }; + public credentialsFormComponent: Type | null = null; + public credentialsFormInputs: Record = {}; + public credentialsFormOutputs: Record = { + switchToAgent: () => this.switchToAgent(), + masterKeyChange: (key: string) => this.handleMasterKeyChange(key), + }; + public credentialsFormAttributes: Record = { class: 'credentials-fieldset' }; + constructor( private _connections: ConnectionsService, private _notifications: NotificationsService, @@ -145,6 +165,8 @@ export class ConnectDBComponent implements OnInit { this.db.port = this.ports[databaseType]; } + this.credentialsFormComponent = this.credentialsFormMap[this.db.type] || null; + this._connections .getCurrentConnectionTitle() .pipe(take(1)) @@ -177,6 +199,7 @@ export class ConnectDBComponent implements OnInit { dbTypeChange() { this.db.port = this.ports[this.db.type]; + this.credentialsFormComponent = this.credentialsFormMap[this.db.type] || null; } testConnection() { @@ -445,6 +468,38 @@ export class ConnectDBComponent implements OnInit { this.masterKey = newMasterKey; } + applyConnectionString() { + if (!this.connectionString.trim()) { + return; + } + + try { + const parsed = parseConnectionString(this.connectionString); + + this.db.type = parsed.dbType; + this.db.host = parsed.host; + this.db.port = parsed.port; + this.db.username = parsed.username; + this.db.password = parsed.password; + this.db.database = parsed.database; + + if (parsed.authSource) { + this.db.authSource = parsed.authSource; + } + if (parsed.schema) { + this.db.schema = parsed.schema; + } + if (parsed.ssl) { + this.db.ssl = true; + } + + this.connectionString = ''; + this._notifications.showSuccessSnackbar('Connection string parsed successfully'); + } catch (_e) { + // Validation directive handles error display + } + } + getProvider() { let provider: string = null; if (this.db.host.endsWith('.amazonaws.com')) provider = 'amazon'; diff --git a/frontend/src/app/directives/connection-string-validator.directive.ts b/frontend/src/app/directives/connection-string-validator.directive.ts new file mode 100644 index 000000000..02bb48a8c --- /dev/null +++ b/frontend/src/app/directives/connection-string-validator.directive.ts @@ -0,0 +1,19 @@ +import { Directive } from '@angular/core'; +import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms'; +import { connectionStringValidation } from '../validators/connection-string.validator'; + +@Directive({ + selector: '[connectionStringValidator][ngModel]', + providers: [ + { + provide: NG_VALIDATORS, + useExisting: ConnectionStringValidatorDirective, + multi: true, + }, + ], +}) +export class ConnectionStringValidatorDirective implements Validator { + validate(control: AbstractControl): ValidationErrors | null { + return connectionStringValidation()(control); + } +} diff --git a/frontend/src/app/validators/connection-string.validator.ts b/frontend/src/app/validators/connection-string.validator.ts new file mode 100644 index 000000000..37c24ad7d --- /dev/null +++ b/frontend/src/app/validators/connection-string.validator.ts @@ -0,0 +1,130 @@ +import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; +import { ConnectionStringParser } from 'connection-string-parser'; +import { DBtype } from '../models/connection'; + +export interface ParsedConnectionString { + dbType: DBtype; + host: string; + port: string; + username: string; + password: string; + database: string; + authSource?: string; + schema?: string; + ssl?: boolean; +} + +const schemeToDbType: Record = { + mysql: DBtype.MySQL, + mariadb: DBtype.MySQL, + postgres: DBtype.Postgres, + postgresql: DBtype.Postgres, + mongodb: DBtype.Mongo, + 'mongodb+srv': DBtype.Mongo, + mssql: DBtype.MSSQL, + sqlserver: DBtype.MSSQL, + oracle: DBtype.Oracle, + oracledb: DBtype.Oracle, + cassandra: DBtype.Cassandra, + redis: DBtype.Redis, + rediss: DBtype.Redis, + elasticsearch: DBtype.Elasticsearch, + clickhouse: DBtype.ClickHouse, + ibmdb2: DBtype.DB2, + db2: DBtype.DB2, +}; + +const defaultPorts: Record = { + [DBtype.MySQL]: '3306', + [DBtype.Postgres]: '5432', + [DBtype.Oracle]: '1521', + [DBtype.MSSQL]: '1433', + [DBtype.Mongo]: '27017', + [DBtype.Dynamo]: '', + [DBtype.Cassandra]: '9042', + [DBtype.Redis]: '6379', + [DBtype.Elasticsearch]: '9200', + [DBtype.ClickHouse]: '8443', + [DBtype.DB2]: '50000', +}; + +export function parseConnectionString(connectionString: string): ParsedConnectionString { + const schemeMatch = connectionString.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//); + if (!schemeMatch) { + throw new Error('invalidFormat'); + } + + const scheme = schemeMatch[1].toLowerCase(); + const dbType = schemeToDbType[scheme]; + if (!dbType) { + throw new Error('unsupportedScheme'); + } + + const parser = new ConnectionStringParser({ scheme, hosts: [] }); + const parsed = parser.parse(connectionString); + + const result: ParsedConnectionString = { + dbType, + host: '', + port: defaultPorts[dbType], + username: '', + password: '', + database: '', + }; + + if (parsed.hosts?.length > 0) { + result.host = parsed.hosts[0].host || ''; + if (parsed.hosts[0].port) { + result.port = String(parsed.hosts[0].port); + } + } + + if (parsed.username) { + result.username = decodeURIComponent(parsed.username); + } + + if (parsed.password) { + result.password = decodeURIComponent(parsed.password); + } + + if (parsed.endpoint) { + result.database = parsed.endpoint; + } + + if (parsed.options) { + if (parsed.options.authSource) { + result.authSource = parsed.options.authSource; + } + if (parsed.options.schema) { + result.schema = parsed.options.schema; + } + if (parsed.options.ssl === 'true' || parsed.options.sslmode === 'require' || parsed.options.sslmode === 'verify-full') { + result.ssl = true; + } + } + + return result; +} + +export function connectionStringValidation(): ValidatorFn { + return (control: AbstractControl): ValidationErrors | null => { + const value = control.value as string; + if (!value || !value.trim()) { + return null; + } + + try { + parseConnectionString(value); + return null; + } catch (e) { + if (e.message === 'invalidFormat') { + return { invalidConnectionStringFormat: true }; + } + if (e.message === 'unsupportedScheme') { + const schemeMatch = value.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/); + return { unsupportedScheme: schemeMatch?.[1] || true }; + } + return { invalidConnectionString: true }; + } + }; +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index ecccd40e2..dde84d26b 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -7255,6 +7255,15 @@ __metadata: languageName: node linkType: hard +"connection-string-parser@npm:^1.0.4": + version: 1.0.4 + resolution: "connection-string-parser@npm:1.0.4" + dependencies: + tslib: ^2.0.0 + checksum: 9424a8b58c1f8eb68c51c7c8254a666f20b405659e3c02c07c2b6b975da643c4b1842704a600bb0448e8f78ad8b6c133a2aadad42493462e382af04ef6900267 + languageName: node + linkType: hard + "content-disposition@npm:^1.0.0": version: 1.0.1 resolution: "content-disposition@npm:1.0.1" @@ -12778,6 +12787,7 @@ __metadata: chart.js: ^4.5.1 chartjs-adapter-date-fns: ^3.0.0 color-string: ^2.0.1 + connection-string-parser: ^1.0.4 convert: ^5.12.0 date-fns: ^4.1.0 ipaddr.js: ^2.2.0