From 6c25213b5be7c5734a9dcb18800c76db37ffa55e Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Wed, 18 Mar 2026 13:09:24 +0000 Subject: [PATCH 1/5] Add hosted database creation frontend --- ...sted-database-success-dialog.component.css | 92 ++++++++++++ ...ted-database-success-dialog.component.html | 81 +++++++++++ ...osted-database-success-dialog.component.ts | 50 +++++++ .../own-connections.component.css | 53 ++++++- .../own-connections.component.html | 35 ++++- .../own-connections.component.spec.ts | 132 +++++++++++++++++- .../own-connections.component.ts | 122 +++++++++++++++- frontend/src/app/models/hosted-database.ts | 25 ++++ .../services/hosted-database.service.spec.ts | 70 ++++++++++ .../app/services/hosted-database.service.ts | 46 ++++++ 10 files changed, 699 insertions(+), 7 deletions(-) create mode 100644 frontend/src/app/components/connections-list/hosted-database-success-dialog/hosted-database-success-dialog.component.css create mode 100644 frontend/src/app/components/connections-list/hosted-database-success-dialog/hosted-database-success-dialog.component.html create mode 100644 frontend/src/app/components/connections-list/hosted-database-success-dialog/hosted-database-success-dialog.component.ts create mode 100644 frontend/src/app/models/hosted-database.ts create mode 100644 frontend/src/app/services/hosted-database.service.spec.ts create mode 100644 frontend/src/app/services/hosted-database.service.ts diff --git a/frontend/src/app/components/connections-list/hosted-database-success-dialog/hosted-database-success-dialog.component.css b/frontend/src/app/components/connections-list/hosted-database-success-dialog/hosted-database-success-dialog.component.css new file mode 100644 index 000000000..b07d154ee --- /dev/null +++ b/frontend/src/app/components/connections-list/hosted-database-success-dialog/hosted-database-success-dialog.component.css @@ -0,0 +1,92 @@ +.hosted-dialog__content { + display: flex; + flex-direction: column; + gap: 16px; + min-width: min(100%, 34rem); +} + +.hosted-dialog__description, +.hosted-dialog__hint, +.hosted-dialog__error { + margin: 0; +} + +.hosted-dialog__error { + background: rgba(220, 38, 38, 0.08); + border: 1px solid rgba(220, 38, 38, 0.2); + border-radius: 8px; + color: #b91c1c; + padding: 12px 14px; +} + +.hosted-dialog__credentials { + display: grid; + gap: 10px; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 12px; + padding: 14px; +} + +.hosted-dialog__row { + display: grid; + grid-template-columns: minmax(96px, 120px) minmax(0, 1fr); + align-items: start; + gap: 12px; +} + +.hosted-dialog__label { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.hosted-dialog__credentials code { + background: rgba(37, 99, 235, 0.08); + border-radius: 8px; + font-family: "IBM Plex Mono", monospace; + padding: 8px 10px; + overflow-wrap: anywhere; +} + +.hosted-dialog__hint { + color: rgba(15, 23, 42, 0.72); + font-size: 13px; +} + +.hosted-dialog__actions { + gap: 8px; + padding-top: 8px; +} + +@media (prefers-color-scheme: dark) { + .hosted-dialog__error { + background: rgba(248, 113, 113, 0.12); + border-color: rgba(248, 113, 113, 0.26); + color: #fca5a5; + } + + .hosted-dialog__credentials { + background: #111827; + border-color: rgba(255, 255, 255, 0.08); + } + + .hosted-dialog__credentials code { + background: rgba(59, 130, 246, 0.14); + } + + .hosted-dialog__hint { + color: rgba(255, 255, 255, 0.68); + } +} + +@media (width <= 600px) { + .hosted-dialog__content { + min-width: auto; + } + + .hosted-dialog__row { + grid-template-columns: 1fr; + gap: 6px; + } +} diff --git a/frontend/src/app/components/connections-list/hosted-database-success-dialog/hosted-database-success-dialog.component.html b/frontend/src/app/components/connections-list/hosted-database-success-dialog/hosted-database-success-dialog.component.html new file mode 100644 index 000000000..f66255832 --- /dev/null +++ b/frontend/src/app/components/connections-list/hosted-database-success-dialog/hosted-database-success-dialog.component.html @@ -0,0 +1,81 @@ +

+ {{ data.connectionId ? 'Hosted PostgreSQL is ready' : 'Hosted PostgreSQL created' }} +

+ + + @if (data.connectionId) { +

+ Your hosted PostgreSQL database is provisioned and already connected to RocketAdmin. + Save these credentials now. The password is shown only once. +

+ } @else { +

+ Your hosted PostgreSQL database is provisioned, but RocketAdmin could not finish the automatic connection setup. + Save these credentials now and use them for a manual PostgreSQL connection or support follow-up. +

+ } + + @if (data.errorMessage) { +

+ Automatic connection setup failed: {{ data.errorMessage }} +

+ } + +
+
+ Database + {{ data.hostedDatabase.databaseName }} +
+
+ Host + {{ data.hostedDatabase.hostname }} +
+
+ Port + {{ data.hostedDatabase.port }} +
+
+ Username + {{ data.hostedDatabase.username }} +
+
+ Password + {{ data.hostedDatabase.password }} +
+
+ +

+ The generated password cannot be recovered from this screen later. +

+
+ + + + + @if (data.connectionId) { + + Open tables + + + Set up dashboard + + } + diff --git a/frontend/src/app/components/connections-list/hosted-database-success-dialog/hosted-database-success-dialog.component.ts b/frontend/src/app/components/connections-list/hosted-database-success-dialog/hosted-database-success-dialog.component.ts new file mode 100644 index 000000000..f8d0f73f6 --- /dev/null +++ b/frontend/src/app/components/connections-list/hosted-database-success-dialog/hosted-database-success-dialog.component.ts @@ -0,0 +1,50 @@ +import { CdkCopyToClipboard } from '@angular/cdk/clipboard'; +import { Component, Inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { RouterModule } from '@angular/router'; +import posthog from 'posthog-js'; +import { CreatedHostedDatabase } from 'src/app/models/hosted-database'; +import { NotificationsService } from 'src/app/services/notifications.service'; + +export interface HostedDatabaseSuccessDialogData { + hostedDatabase: CreatedHostedDatabase; + connectionId: string | null; + errorMessage?: string; +} + +@Component({ + selector: 'app-hosted-database-success-dialog', + templateUrl: './hosted-database-success-dialog.component.html', + styleUrl: './hosted-database-success-dialog.component.css', + imports: [MatDialogModule, MatButtonModule, RouterModule, CdkCopyToClipboard], +}) +export class HostedDatabaseSuccessDialogComponent { + constructor( + @Inject(MAT_DIALOG_DATA) public data: HostedDatabaseSuccessDialogData, + private _notifications: NotificationsService, + ) {} + + get credentialsText(): string { + return [ + `Database: ${this.data.hostedDatabase.databaseName}`, + `Host: ${this.data.hostedDatabase.hostname}`, + `Port: ${this.data.hostedDatabase.port}`, + `Username: ${this.data.hostedDatabase.username}`, + `Password: ${this.data.hostedDatabase.password}`, + ].join('\n'); + } + + handleCredentialsCopied(): void { + posthog.capture('Connections: hosted PostgreSQL credentials copied'); + this._notifications.showSuccessSnackbar('Hosted database credentials were copied to clipboard.'); + } + + handlePrimaryActionClick(): void { + posthog.capture('Connections: hosted PostgreSQL setup dashboard opened'); + } + + handleSecondaryActionClick(): void { + posthog.capture('Connections: hosted PostgreSQL tables opened'); + } +} diff --git a/frontend/src/app/components/connections-list/own-connections/own-connections.component.css b/frontend/src/app/components/connections-list/own-connections/own-connections.component.css index 0325461fc..90cf14f84 100644 --- a/frontend/src/app/components/connections-list/own-connections/own-connections.component.css +++ b/frontend/src/app/components/connections-list/own-connections/own-connections.component.css @@ -54,6 +54,10 @@ } .addConnectionLink { + appearance: none; + background: transparent; + border: none; + cursor: pointer; display: flex; flex-direction: column; align-items: center; @@ -99,6 +103,32 @@ } } +.addConnectionLink_hosted { + background: + radial-gradient(circle at top right, rgba(125, 211, 252, 0.32), transparent 42%), + linear-gradient(135deg, #0f172a 0%, #1d4ed8 100%); + border: 1px solid rgba(125, 211, 252, 0.35); + color: #f8fafc; + gap: 10px; + padding: 14px 12px; + text-align: left; +} + +.addConnectionLink_hosted:hover { + border-color: rgba(191, 219, 254, 0.72); + box-shadow: + 0px 8px 24px rgba(29, 78, 216, 0.24), + 0px 1px 5px rgba(15, 23, 42, 0.22); +} + +.addConnectionLink__eyebrow { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + opacity: 0.84; + text-transform: uppercase; +} + .addConnectionLink__iconBox { flex-shrink: 0; display: flex; @@ -108,6 +138,13 @@ width: 32px; } +.addConnectionLink__iconBox_hosted { + background: rgba(255, 255, 255, 0.1); + border-radius: 999px; + height: 42px; + width: 42px; +} + .addConnectionLink__icon { flex-shrink: 0; height: 28px; @@ -169,12 +206,26 @@ width: 178px; } +.fabHostedButton { + border-radius: 24px; + height: 48px !important; +} + @media (width <= 600px) { - .fabAddButton { + .fabActions { position: fixed; + right: 9vw; bottom: max(64px, 10vw); + flex-direction: column; + align-items: flex-end; z-index: 1; } + + .fabAddButton, + .fabHostedButton { + width: auto; + min-width: 178px; + } } .connections { diff --git a/frontend/src/app/components/connections-list/own-connections/own-connections.component.html b/frontend/src/app/components/connections-list/own-connections/own-connections.component.html index 591c3a2a1..415d4a17f 100644 --- a/frontend/src/app/components/connections-list/own-connections/own-connections.component.html +++ b/frontend/src/app/components/connections-list/own-connections/own-connections.component.html @@ -5,6 +5,25 @@

+ @if (showHostedDatabaseEntry) { +
  • + +
  • + }
  • - diff --git a/frontend/src/app/components/connections-list/own-connections/own-connections.component.spec.ts b/frontend/src/app/components/connections-list/own-connections/own-connections.component.spec.ts index a2420789f..281ffdffe 100644 --- a/frontend/src/app/components/connections-list/own-connections/own-connections.component.spec.ts +++ b/frontend/src/app/components/connections-list/own-connections/own-connections.component.spec.ts @@ -1,24 +1,154 @@ import { provideHttpClient } from '@angular/common/http'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { CompanyMemberRole } from 'src/app/models/company'; +import { User } from 'src/app/models/user'; +import { CompanyService } from 'src/app/services/company.service'; +import { ConnectionsService } from 'src/app/services/connections.service'; +import { HostedDatabaseService } from 'src/app/services/hosted-database.service'; +import { NotificationsService } from 'src/app/services/notifications.service'; +import { UiSettingsService } from 'src/app/services/ui-settings.service'; import { OwnConnectionsComponent } from './own-connections.component'; describe('OwnConnectionsComponent', () => { let component: OwnConnectionsComponent; let fixture: ComponentFixture; + let hostedDatabaseService: { + createHostedDatabase: ReturnType; + createConnectionForHostedDatabase: ReturnType; + }; + let connectionsService: { + fetchConnections: ReturnType; + }; + let dialog: { + open: ReturnType; + }; beforeEach(async () => { + hostedDatabaseService = { + createHostedDatabase: vi.fn(), + createConnectionForHostedDatabase: vi.fn(), + }; + connectionsService = { + fetchConnections: vi.fn().mockReturnValue(of([])), + }; + dialog = { + open: vi.fn(), + }; + await TestBed.configureTestingModule({ imports: [OwnConnectionsComponent], - providers: [provideHttpClient(), provideRouter([])], + providers: [ + provideHttpClient(), + provideRouter([]), + { + provide: UiSettingsService, + useValue: { + isDarkMode: false, + getUiSettings: vi.fn().mockReturnValue(of({ globalSettings: { connectionsListCollapsed: true } })), + updateGlobalSetting: vi.fn(), + }, + }, + { + provide: CompanyService, + useValue: { + fetchCompanyMembers: vi.fn().mockReturnValue(of([])), + }, + }, + { + provide: ConnectionsService, + useValue: connectionsService, + }, + { + provide: HostedDatabaseService, + useValue: hostedDatabaseService, + }, + { + provide: NotificationsService, + useValue: { + showSuccessSnackbar: vi.fn(), + showAlert: vi.fn(), + dismissAlert: vi.fn(), + }, + }, + { + provide: MatDialog, + useValue: dialog, + }, + ], }).compileComponents(); fixture = TestBed.createComponent(OwnConnectionsComponent); component = fixture.componentInstance; + component.connections = []; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + + it('should show hosted database CTA for SaaS admins', () => { + component.isSaas = true; + component.currentUser = { + id: 'user-id', + role: CompanyMemberRole.CAO, + company: { id: 'company-id' }, + } as User; + + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('[data-testid="create-hosted-database-empty-button"]')).toBeTruthy(); + }); + + it('should hide hosted database CTA for non-admin users', () => { + component.isSaas = true; + component.currentUser = { + id: 'user-id', + role: CompanyMemberRole.Member, + company: { id: 'company-id' }, + } as User; + + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('[data-testid="create-hosted-database-empty-button"]')).toBeFalsy(); + }); + + it('should provision a hosted database and open the success dialog', () => { + hostedDatabaseService.createHostedDatabase.mockReturnValue( + of({ + id: 'hosted-db-id', + companyId: 'company-id', + databaseName: 'rocketadmin_hosted', + hostname: 'db.rocketadmin.com', + port: 5432, + username: 'postgres', + password: 'secret', + createdAt: '2026-03-18T00:00:00.000Z', + }), + ); + hostedDatabaseService.createConnectionForHostedDatabase.mockReturnValue(of({ id: 'connection-id' })); + component.currentUser = { + id: 'user-id', + role: CompanyMemberRole.CAO, + company: { id: 'company-id' }, + } as User; + + component.createHostedDatabase(); + + expect(hostedDatabaseService.createHostedDatabase).toHaveBeenCalledWith('company-id'); + expect(hostedDatabaseService.createConnectionForHostedDatabase).toHaveBeenCalledWith({ + companyId: 'company-id', + userId: 'user-id', + databaseName: 'rocketadmin_hosted', + hostname: 'db.rocketadmin.com', + port: 5432, + username: 'postgres', + password: 'secret', + }); + expect(connectionsService.fetchConnections).toHaveBeenCalled(); + expect(dialog.open).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/app/components/connections-list/own-connections/own-connections.component.ts b/frontend/src/app/components/connections-list/own-connections/own-connections.component.ts index 7145ea40d..5e1bdf84e 100644 --- a/frontend/src/app/components/connections-list/own-connections/own-connections.component.ts +++ b/frontend/src/app/components/connections-list/own-connections/own-connections.component.ts @@ -1,15 +1,28 @@ import { CommonModule } from '@angular/common'; import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { RouterModule } from '@angular/router'; -import { User } from '@sentry/angular'; import posthog from 'posthog-js'; +import { catchError, EMPTY, finalize, map, switchMap, tap } from 'rxjs'; import { supportedDatabasesTitles, supportedOrderedDatabases } from 'src/app/consts/databases'; +import { AlertActionType, AlertType } from 'src/app/models/alert'; +import { CompanyMember, CompanyMemberRole } from 'src/app/models/company'; import { ConnectionItem } from 'src/app/models/connection'; +import { CreateHostedDatabaseConnectionPayload } from 'src/app/models/hosted-database'; import { UiSettings } from 'src/app/models/ui-settings'; +import { User } from 'src/app/models/user'; import { CompanyService } from 'src/app/services/company.service'; +import { ConnectionsService } from 'src/app/services/connections.service'; +import { HostedDatabaseService } from 'src/app/services/hosted-database.service'; +import { NotificationsService } from 'src/app/services/notifications.service'; import { UiSettingsService } from 'src/app/services/ui-settings.service'; +import { environment } from 'src/environments/environment'; +import { + HostedDatabaseSuccessDialogComponent, + HostedDatabaseSuccessDialogData, +} from '../hosted-database-success-dialog/hosted-database-success-dialog.component'; @Component({ selector: 'app-own-connections', @@ -24,18 +37,32 @@ export class OwnConnectionsComponent implements OnInit, OnChanges { @Input() isDemo: boolean = false; @Input() companyId: string; + public isSaas: boolean = !!environment.saas; public displayedCardCount: number = 3; public connectionsListCollapsed: boolean; public supportedDatabasesTitles = supportedDatabasesTitles; public supportedOrderedDatabases = supportedOrderedDatabases; public hasMultipleMembers: boolean = false; public isDarkMode: boolean = false; + public creatingHostedDatabase: boolean = false; constructor( private _uiSettings: UiSettingsService, private _companyService: CompanyService, + private _connectionsService: ConnectionsService, + private _hostedDatabaseService: HostedDatabaseService, + private _notifications: NotificationsService, + private _dialog: MatDialog, ) {} + get canManageConnections(): boolean { + return this.currentUser?.role === CompanyMemberRole.CAO || this.currentUser?.role === CompanyMemberRole.SystemAdmin; + } + + get showHostedDatabaseEntry(): boolean { + return this.isSaas && !this.isDemo && this.canManageConnections; + } + ngOnInit() { this.isDarkMode = this._uiSettings.isDarkMode; @@ -46,8 +73,8 @@ export class OwnConnectionsComponent implements OnInit, OnChanges { } ngOnChanges(changes: SimpleChanges) { - if (changes['companyId'] && this.companyId) { - this._companyService.fetchCompanyMembers(this.companyId).subscribe((members: any[]) => { + if (changes.companyId && this.companyId) { + this._companyService.fetchCompanyMembers(this.companyId).subscribe((members: CompanyMember[]) => { this.hasMultipleMembers = members && members.length > 1; }); } @@ -74,4 +101,93 @@ export class OwnConnectionsComponent implements OnInit, OnChanges { const match = title.match(/(\([^)]+\))/); return match ? match[1] : ''; } + + createHostedDatabase(): void { + if (!this.currentUser?.company?.id || !this.currentUser?.id || this.creatingHostedDatabase) { + return; + } + + const companyId = this.currentUser.company.id; + const userId = this.currentUser.id; + + this.creatingHostedDatabase = true; + posthog.capture('Connections: hosted PostgreSQL creation started'); + + this._hostedDatabaseService + .createHostedDatabase(companyId) + .pipe( + tap(() => { + posthog.capture('Connections: hosted PostgreSQL provisioned successfully'); + }), + switchMap((hostedDatabase) => { + const payload: CreateHostedDatabaseConnectionPayload = { + companyId, + userId, + databaseName: hostedDatabase.databaseName, + hostname: hostedDatabase.hostname, + port: hostedDatabase.port, + username: hostedDatabase.username, + password: hostedDatabase.password, + }; + + return this._hostedDatabaseService.createConnectionForHostedDatabase(payload).pipe( + map((createdConnection) => ({ + hostedDatabase, + connectionId: createdConnection.id, + })), + catchError((error) => { + const errorMessage = this._getErrorMessage(error); + posthog.capture('Connections: hosted PostgreSQL connection creation failed', { errorMessage }); + this._openHostedDatabaseDialog({ + hostedDatabase, + connectionId: null, + errorMessage, + }); + return EMPTY; + }), + ); + }), + finalize(() => { + this.creatingHostedDatabase = false; + }), + ) + .subscribe({ + next: ({ hostedDatabase, connectionId }) => { + posthog.capture('Connections: hosted PostgreSQL connection created successfully', { connectionId }); + this._connectionsService.fetchConnections().subscribe(); + this._notifications.showSuccessSnackbar('Hosted PostgreSQL database is ready.'); + this._openHostedDatabaseDialog({ + hostedDatabase, + connectionId, + }); + }, + error: (error) => { + const errorMessage = this._getErrorMessage(error); + posthog.capture('Connections: hosted PostgreSQL provisioning failed', { errorMessage }); + this._notifications.showAlert(AlertType.Error, errorMessage, [ + { + type: AlertActionType.Button, + caption: 'Dismiss', + action: (_id: number) => this._notifications.dismissAlert(), + }, + ]); + }, + }); + } + + private _openHostedDatabaseDialog(data: HostedDatabaseSuccessDialogData): void { + this._dialog.open(HostedDatabaseSuccessDialogComponent, { + width: '42em', + maxWidth: '95vw', + data, + }); + } + + private _getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; + } } diff --git a/frontend/src/app/models/hosted-database.ts b/frontend/src/app/models/hosted-database.ts new file mode 100644 index 000000000..b2e932a7a --- /dev/null +++ b/frontend/src/app/models/hosted-database.ts @@ -0,0 +1,25 @@ +export interface CreatedHostedDatabase { + id: string; + companyId: string; + databaseName: string; + hostname: string; + port: number; + username: string; + password: string; + createdAt: string; +} + +export interface CreateHostedDatabaseConnectionPayload { + companyId: string; + userId: string; + databaseName: string; + hostname: string; + port: number; + username: string; + password: string; +} + +export interface CreatedHostedDatabaseConnection { + id: string; + token?: string | null; +} diff --git a/frontend/src/app/services/hosted-database.service.spec.ts b/frontend/src/app/services/hosted-database.service.spec.ts new file mode 100644 index 000000000..18ae04f7b --- /dev/null +++ b/frontend/src/app/services/hosted-database.service.spec.ts @@ -0,0 +1,70 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { CreatedHostedDatabase, CreateHostedDatabaseConnectionPayload } from '../models/hosted-database'; +import { HostedDatabaseService } from './hosted-database.service'; + +describe('HostedDatabaseService', () => { + let httpMock: HttpTestingController; + let service: HostedDatabaseService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), HostedDatabaseService], + }); + + httpMock = TestBed.inject(HttpTestingController); + service = TestBed.inject(HostedDatabaseService); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should create a hosted database for a company', () => { + const response: CreatedHostedDatabase = { + id: 'hosted-db-id', + companyId: 'company-id', + databaseName: 'rocketadmin_hosted', + hostname: 'db.rocketadmin.com', + port: 5432, + username: 'postgres', + password: 'secret', + createdAt: '2026-03-18T00:00:00.000Z', + }; + + service.createHostedDatabase('company-id').subscribe((result) => { + expect(result).toEqual(response); + }); + + const request = httpMock.expectOne('/saas/hosted-database/create/company-id'); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual({}); + request.flush(response); + }); + + it('should create a RocketAdmin connection for a hosted database', () => { + const payload: CreateHostedDatabaseConnectionPayload = { + companyId: 'company-id', + userId: 'user-id', + databaseName: 'rocketadmin_hosted', + hostname: 'db.rocketadmin.com', + port: 5432, + username: 'postgres', + password: 'secret', + }; + + service.createConnectionForHostedDatabase(payload).subscribe((result) => { + expect(result).toEqual({ id: 'connection-id' }); + }); + + const request = httpMock.expectOne('/saas/connection/hosted'); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual(payload); + request.flush({ id: 'connection-id' }); + }); +}); diff --git a/frontend/src/app/services/hosted-database.service.ts b/frontend/src/app/services/hosted-database.service.ts new file mode 100644 index 000000000..5585d116f --- /dev/null +++ b/frontend/src/app/services/hosted-database.service.ts @@ -0,0 +1,46 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { catchError, throwError } from 'rxjs'; +import { + CreatedHostedDatabase, + CreatedHostedDatabaseConnection, + CreateHostedDatabaseConnectionPayload, +} from '../models/hosted-database'; + +@Injectable({ + providedIn: 'root', +}) +export class HostedDatabaseService { + constructor(private _http: HttpClient) {} + + createHostedDatabase(companyId: string) { + return this._http.post(`/saas/hosted-database/create/${companyId}`, {}).pipe( + catchError((error) => { + return throwError(() => new Error(this._getErrorMessage(error))); + }), + ); + } + + createConnectionForHostedDatabase(payload: CreateHostedDatabaseConnectionPayload) { + return this._http.post(`/saas/connection/hosted`, payload).pipe( + catchError((error) => { + return throwError(() => new Error(this._getErrorMessage(error))); + }), + ); + } + + private _getErrorMessage(error: unknown): string { + if (error && typeof error === 'object' && 'error' in error) { + const responseError = (error as { error?: { message?: string } }).error; + if (responseError?.message) { + return responseError.message; + } + } + + if (error instanceof Error && error.message) { + return error.message; + } + + return 'Unknown error'; + } +} From 44450e72b21d6133927439fbf9315881a84100df Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Thu, 19 Mar 2026 10:29:07 +0000 Subject: [PATCH 2/5] Simplify hosted DB creation to single request and add plan selection dialog for free users - Remove redundant /saas/connection/hosted request; use only /saas/hosted-database/create - Switch hosted database service from HttpClient to ApiService - Convert creatingHostedDatabase to Angular signal - Add plan selection dialog (free tiny node vs scalable pay-as-you-go) shown for free/null subscription users Co-Authored-By: Claude Opus 4.6 (1M context) --- .../hosted-database-plan-dialog.component.css | 80 +++++++++++ ...hosted-database-plan-dialog.component.html | 29 ++++ .../hosted-database-plan-dialog.component.ts | 24 ++++ .../own-connections.component.html | 8 +- .../own-connections.component.spec.ts | 38 ++--- .../own-connections.component.ts | 135 +++++++++--------- frontend/src/app/models/hosted-database.ts | 15 -- .../services/hosted-database.service.spec.ts | 51 ++----- .../app/services/hosted-database.service.ts | 44 +----- 9 files changed, 234 insertions(+), 190 deletions(-) create mode 100644 frontend/src/app/components/connections-list/hosted-database-plan-dialog/hosted-database-plan-dialog.component.css create mode 100644 frontend/src/app/components/connections-list/hosted-database-plan-dialog/hosted-database-plan-dialog.component.html create mode 100644 frontend/src/app/components/connections-list/hosted-database-plan-dialog/hosted-database-plan-dialog.component.ts diff --git a/frontend/src/app/components/connections-list/hosted-database-plan-dialog/hosted-database-plan-dialog.component.css b/frontend/src/app/components/connections-list/hosted-database-plan-dialog/hosted-database-plan-dialog.component.css new file mode 100644 index 000000000..3908e3215 --- /dev/null +++ b/frontend/src/app/components/connections-list/hosted-database-plan-dialog/hosted-database-plan-dialog.component.css @@ -0,0 +1,80 @@ +.plan-dialog__content { + display: flex; + flex-direction: column; + gap: 12px; + min-width: min(100%, 30rem); +} + +.plan-dialog__option { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 12px; + background: transparent; + cursor: pointer; + text-align: left; + transition: + border-color 0.15s, + background 0.15s; +} + +.plan-dialog__option:hover { + border-color: rgba(37, 99, 235, 0.5); + background: rgba(37, 99, 235, 0.04); +} + +.plan-dialog__option--paid:hover { + border-color: rgba(124, 58, 237, 0.5); + background: rgba(124, 58, 237, 0.04); +} + +.plan-dialog__option-header { + display: flex; + align-items: center; + gap: 8px; +} + +.plan-dialog__option-title { + font-size: 16px; + font-weight: 600; +} + +.plan-dialog__option-description { + margin: 0; + color: rgba(15, 23, 42, 0.68); + font-size: 14px; +} + +.plan-dialog__option-price { + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgba(15, 23, 42, 0.48); +} + +@media (prefers-color-scheme: dark) { + .plan-dialog__option { + border-color: rgba(255, 255, 255, 0.08); + } + + .plan-dialog__option:hover { + border-color: rgba(96, 165, 250, 0.5); + background: rgba(96, 165, 250, 0.08); + } + + .plan-dialog__option--paid:hover { + border-color: rgba(167, 139, 250, 0.5); + background: rgba(167, 139, 250, 0.08); + } + + .plan-dialog__option-description { + color: rgba(255, 255, 255, 0.62); + } + + .plan-dialog__option-price { + color: rgba(255, 255, 255, 0.42); + } +} diff --git a/frontend/src/app/components/connections-list/hosted-database-plan-dialog/hosted-database-plan-dialog.component.html b/frontend/src/app/components/connections-list/hosted-database-plan-dialog/hosted-database-plan-dialog.component.html new file mode 100644 index 000000000..642372777 --- /dev/null +++ b/frontend/src/app/components/connections-list/hosted-database-plan-dialog/hosted-database-plan-dialog.component.html @@ -0,0 +1,29 @@ +

    Choose your hosted database plan

    + + + + + + + + + + diff --git a/frontend/src/app/components/connections-list/hosted-database-plan-dialog/hosted-database-plan-dialog.component.ts b/frontend/src/app/components/connections-list/hosted-database-plan-dialog/hosted-database-plan-dialog.component.ts new file mode 100644 index 000000000..6194e0aa2 --- /dev/null +++ b/frontend/src/app/components/connections-list/hosted-database-plan-dialog/hosted-database-plan-dialog.component.ts @@ -0,0 +1,24 @@ +import { Component } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; + +export type HostedDatabasePlanChoice = 'free' | 'upgrade'; + +@Component({ + selector: 'app-hosted-database-plan-dialog', + templateUrl: './hosted-database-plan-dialog.component.html', + styleUrl: './hosted-database-plan-dialog.component.css', + imports: [MatDialogModule, MatButtonModule, MatIconModule], +}) +export class HostedDatabasePlanDialogComponent { + constructor(private _dialogRef: MatDialogRef) {} + + chooseFree(): void { + this._dialogRef.close('free'); + } + + chooseUpgrade(): void { + this._dialogRef.close('upgrade'); + } +} diff --git a/frontend/src/app/components/connections-list/own-connections/own-connections.component.html b/frontend/src/app/components/connections-list/own-connections/own-connections.component.html index 415d4a17f..e5eacccc5 100644 --- a/frontend/src/app/components/connections-list/own-connections/own-connections.component.html +++ b/frontend/src/app/components/connections-list/own-connections/own-connections.component.html @@ -11,14 +11,14 @@

    Hosted by RocketAdmin - {{ creatingHostedDatabase ? 'Creating hosted PostgreSQL...' : 'Hosted PostgreSQL' }} + {{ creatingHostedDatabase() ? 'Creating hosted PostgreSQL...' : 'Hosted PostgreSQL' }} Provision a managed database in one click @@ -85,10 +85,10 @@

    {{ connectionItem.displayTitle }} cloud_queue - {{ creatingHostedDatabase ? 'Creating...' : 'Create hosted PostgreSQL' }} + {{ creatingHostedDatabase() ? 'Creating...' : 'Create hosted PostgreSQL' }} } { let fixture: ComponentFixture; let hostedDatabaseService: { createHostedDatabase: ReturnType; - createConnectionForHostedDatabase: ReturnType; }; let connectionsService: { fetchConnections: ReturnType; @@ -29,7 +28,6 @@ describe('OwnConnectionsComponent', () => { beforeEach(async () => { hostedDatabaseService = { createHostedDatabase: vi.fn(), - createConnectionForHostedDatabase: vi.fn(), }; connectionsService = { fetchConnections: vi.fn().mockReturnValue(of([])), @@ -116,38 +114,26 @@ describe('OwnConnectionsComponent', () => { expect(fixture.nativeElement.querySelector('[data-testid="create-hosted-database-empty-button"]')).toBeFalsy(); }); - it('should provision a hosted database and open the success dialog', () => { - hostedDatabaseService.createHostedDatabase.mockReturnValue( - of({ - id: 'hosted-db-id', - companyId: 'company-id', - databaseName: 'rocketadmin_hosted', - hostname: 'db.rocketadmin.com', - port: 5432, - username: 'postgres', - password: 'secret', - createdAt: '2026-03-18T00:00:00.000Z', - }), - ); - hostedDatabaseService.createConnectionForHostedDatabase.mockReturnValue(of({ id: 'connection-id' })); + it('should provision a hosted database and open the success dialog', async () => { + hostedDatabaseService.createHostedDatabase.mockResolvedValue({ + id: 'hosted-db-id', + companyId: 'company-id', + databaseName: 'rocketadmin_hosted', + hostname: 'db.rocketadmin.com', + port: 5432, + username: 'postgres', + password: 'secret', + createdAt: '2026-03-18T00:00:00.000Z', + }); component.currentUser = { id: 'user-id', role: CompanyMemberRole.CAO, company: { id: 'company-id' }, } as User; - component.createHostedDatabase(); + await component.createHostedDatabase(); expect(hostedDatabaseService.createHostedDatabase).toHaveBeenCalledWith('company-id'); - expect(hostedDatabaseService.createConnectionForHostedDatabase).toHaveBeenCalledWith({ - companyId: 'company-id', - userId: 'user-id', - databaseName: 'rocketadmin_hosted', - hostname: 'db.rocketadmin.com', - port: 5432, - username: 'postgres', - password: 'secret', - }); expect(connectionsService.fetchConnections).toHaveBeenCalled(); expect(dialog.open).toHaveBeenCalled(); }); diff --git a/frontend/src/app/components/connections-list/own-connections/own-connections.component.ts b/frontend/src/app/components/connections-list/own-connections/own-connections.component.ts index 5e1bdf84e..d6c5c4509 100644 --- a/frontend/src/app/components/connections-list/own-connections/own-connections.component.ts +++ b/frontend/src/app/components/connections-list/own-connections/own-connections.component.ts @@ -1,24 +1,27 @@ import { CommonModule } from '@angular/common'; -import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { Component, Input, OnChanges, OnInit, SimpleChanges, signal } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; -import { RouterModule } from '@angular/router'; +import { Router, RouterModule } from '@angular/router'; import posthog from 'posthog-js'; -import { catchError, EMPTY, finalize, map, switchMap, tap } from 'rxjs'; +import { firstValueFrom } from 'rxjs'; import { supportedDatabasesTitles, supportedOrderedDatabases } from 'src/app/consts/databases'; import { AlertActionType, AlertType } from 'src/app/models/alert'; import { CompanyMember, CompanyMemberRole } from 'src/app/models/company'; import { ConnectionItem } from 'src/app/models/connection'; -import { CreateHostedDatabaseConnectionPayload } from 'src/app/models/hosted-database'; import { UiSettings } from 'src/app/models/ui-settings'; -import { User } from 'src/app/models/user'; +import { SubscriptionPlans, User } from 'src/app/models/user'; import { CompanyService } from 'src/app/services/company.service'; import { ConnectionsService } from 'src/app/services/connections.service'; import { HostedDatabaseService } from 'src/app/services/hosted-database.service'; import { NotificationsService } from 'src/app/services/notifications.service'; import { UiSettingsService } from 'src/app/services/ui-settings.service'; import { environment } from 'src/environments/environment'; +import { + HostedDatabasePlanChoice, + HostedDatabasePlanDialogComponent, +} from '../hosted-database-plan-dialog/hosted-database-plan-dialog.component'; import { HostedDatabaseSuccessDialogComponent, HostedDatabaseSuccessDialogData, @@ -44,7 +47,7 @@ export class OwnConnectionsComponent implements OnInit, OnChanges { public supportedOrderedDatabases = supportedOrderedDatabases; public hasMultipleMembers: boolean = false; public isDarkMode: boolean = false; - public creatingHostedDatabase: boolean = false; + public creatingHostedDatabase = signal(false); constructor( private _uiSettings: UiSettingsService, @@ -53,6 +56,7 @@ export class OwnConnectionsComponent implements OnInit, OnChanges { private _hostedDatabaseService: HostedDatabaseService, private _notifications: NotificationsService, private _dialog: MatDialog, + private _router: Router, ) {} get canManageConnections(): boolean { @@ -102,77 +106,70 @@ export class OwnConnectionsComponent implements OnInit, OnChanges { return match ? match[1] : ''; } - createHostedDatabase(): void { - if (!this.currentUser?.company?.id || !this.currentUser?.id || this.creatingHostedDatabase) { + async createHostedDatabase(): Promise { + if (!this.currentUser?.company?.id || !this.currentUser?.id || this.creatingHostedDatabase()) { return; } + const subscriptionLevel = this.currentUser.subscriptionLevel; + const isFreePlan = !subscriptionLevel || subscriptionLevel === SubscriptionPlans.free; + console.log('[HostedDB] subscriptionLevel:', subscriptionLevel, 'isFreePlan:', isFreePlan); + + if (isFreePlan) { + const choice = await this._openPlanDialog(); + console.log('[HostedDB] plan dialog choice:', choice); + if (!choice) { + return; + } + if (choice === 'upgrade') { + this._router.navigate(['/upgrade']); + return; + } + } + const companyId = this.currentUser.company.id; - const userId = this.currentUser.id; - this.creatingHostedDatabase = true; + this.creatingHostedDatabase.set(true); posthog.capture('Connections: hosted PostgreSQL creation started'); - this._hostedDatabaseService - .createHostedDatabase(companyId) - .pipe( - tap(() => { - posthog.capture('Connections: hosted PostgreSQL provisioned successfully'); - }), - switchMap((hostedDatabase) => { - const payload: CreateHostedDatabaseConnectionPayload = { - companyId, - userId, - databaseName: hostedDatabase.databaseName, - hostname: hostedDatabase.hostname, - port: hostedDatabase.port, - username: hostedDatabase.username, - password: hostedDatabase.password, - }; - - return this._hostedDatabaseService.createConnectionForHostedDatabase(payload).pipe( - map((createdConnection) => ({ - hostedDatabase, - connectionId: createdConnection.id, - })), - catchError((error) => { - const errorMessage = this._getErrorMessage(error); - posthog.capture('Connections: hosted PostgreSQL connection creation failed', { errorMessage }); - this._openHostedDatabaseDialog({ - hostedDatabase, - connectionId: null, - errorMessage, - }); - return EMPTY; - }), - ); - }), - finalize(() => { - this.creatingHostedDatabase = false; - }), - ) - .subscribe({ - next: ({ hostedDatabase, connectionId }) => { - posthog.capture('Connections: hosted PostgreSQL connection created successfully', { connectionId }); - this._connectionsService.fetchConnections().subscribe(); - this._notifications.showSuccessSnackbar('Hosted PostgreSQL database is ready.'); - this._openHostedDatabaseDialog({ - hostedDatabase, - connectionId, - }); - }, - error: (error) => { - const errorMessage = this._getErrorMessage(error); - posthog.capture('Connections: hosted PostgreSQL provisioning failed', { errorMessage }); - this._notifications.showAlert(AlertType.Error, errorMessage, [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert(), - }, - ]); - }, + try { + const hostedDatabase = await this._hostedDatabaseService.createHostedDatabase(companyId); + + if (!hostedDatabase) { + return; + } + + posthog.capture('Connections: hosted PostgreSQL provisioned successfully'); + this._connectionsService.fetchConnections().subscribe(); + this._notifications.showSuccessSnackbar('Hosted PostgreSQL database is ready.'); + this._openHostedDatabaseDialog({ + hostedDatabase, + connectionId: null, }); + } catch (error) { + const errorMessage = this._getErrorMessage(error); + posthog.capture('Connections: hosted PostgreSQL provisioning failed', { errorMessage }); + this._notifications.showAlert(AlertType.Error, errorMessage, [ + { + type: AlertActionType.Button, + caption: 'Dismiss', + action: (_id: number) => this._notifications.dismissAlert(), + }, + ]); + } finally { + this.creatingHostedDatabase.set(false); + } + } + + private _openPlanDialog(): Promise { + const dialogRef = this._dialog.open( + HostedDatabasePlanDialogComponent, + { + width: '32em', + maxWidth: '95vw', + }, + ); + return firstValueFrom(dialogRef.afterClosed()); } private _openHostedDatabaseDialog(data: HostedDatabaseSuccessDialogData): void { diff --git a/frontend/src/app/models/hosted-database.ts b/frontend/src/app/models/hosted-database.ts index b2e932a7a..5cf3785ec 100644 --- a/frontend/src/app/models/hosted-database.ts +++ b/frontend/src/app/models/hosted-database.ts @@ -8,18 +8,3 @@ export interface CreatedHostedDatabase { password: string; createdAt: string; } - -export interface CreateHostedDatabaseConnectionPayload { - companyId: string; - userId: string; - databaseName: string; - hostname: string; - port: number; - username: string; - password: string; -} - -export interface CreatedHostedDatabaseConnection { - id: string; - token?: string | null; -} diff --git a/frontend/src/app/services/hosted-database.service.spec.ts b/frontend/src/app/services/hosted-database.service.spec.ts index 18ae04f7b..24fedbe99 100644 --- a/frontend/src/app/services/hosted-database.service.spec.ts +++ b/frontend/src/app/services/hosted-database.service.spec.ts @@ -1,31 +1,29 @@ -import { provideHttpClient } from '@angular/common/http'; -import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; -import { CreatedHostedDatabase, CreateHostedDatabaseConnectionPayload } from '../models/hosted-database'; +import { CreatedHostedDatabase } from '../models/hosted-database'; +import { ApiService } from './api.service'; import { HostedDatabaseService } from './hosted-database.service'; describe('HostedDatabaseService', () => { - let httpMock: HttpTestingController; let service: HostedDatabaseService; + let apiService: { post: ReturnType }; beforeEach(() => { + apiService = { + post: vi.fn(), + }; + TestBed.configureTestingModule({ - providers: [provideHttpClient(), provideHttpClientTesting(), HostedDatabaseService], + providers: [HostedDatabaseService, { provide: ApiService, useValue: apiService }], }); - httpMock = TestBed.inject(HttpTestingController); service = TestBed.inject(HostedDatabaseService); }); - afterEach(() => { - httpMock.verify(); - }); - it('should be created', () => { expect(service).toBeTruthy(); }); - it('should create a hosted database for a company', () => { + it('should create a hosted database for a company', async () => { const response: CreatedHostedDatabase = { id: 'hosted-db-id', companyId: 'company-id', @@ -37,34 +35,11 @@ describe('HostedDatabaseService', () => { createdAt: '2026-03-18T00:00:00.000Z', }; - service.createHostedDatabase('company-id').subscribe((result) => { - expect(result).toEqual(response); - }); - - const request = httpMock.expectOne('/saas/hosted-database/create/company-id'); - expect(request.request.method).toBe('POST'); - expect(request.request.body).toEqual({}); - request.flush(response); - }); - - it('should create a RocketAdmin connection for a hosted database', () => { - const payload: CreateHostedDatabaseConnectionPayload = { - companyId: 'company-id', - userId: 'user-id', - databaseName: 'rocketadmin_hosted', - hostname: 'db.rocketadmin.com', - port: 5432, - username: 'postgres', - password: 'secret', - }; + apiService.post.mockResolvedValue(response); - service.createConnectionForHostedDatabase(payload).subscribe((result) => { - expect(result).toEqual({ id: 'connection-id' }); - }); + const result = await service.createHostedDatabase('company-id'); - const request = httpMock.expectOne('/saas/connection/hosted'); - expect(request.request.method).toBe('POST'); - expect(request.request.body).toEqual(payload); - request.flush({ id: 'connection-id' }); + expect(apiService.post).toHaveBeenCalledWith('/saas/hosted-database/create/company-id', {}); + expect(result).toEqual(response); }); }); diff --git a/frontend/src/app/services/hosted-database.service.ts b/frontend/src/app/services/hosted-database.service.ts index 5585d116f..cd081cc0e 100644 --- a/frontend/src/app/services/hosted-database.service.ts +++ b/frontend/src/app/services/hosted-database.service.ts @@ -1,46 +1,14 @@ -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { catchError, throwError } from 'rxjs'; -import { - CreatedHostedDatabase, - CreatedHostedDatabaseConnection, - CreateHostedDatabaseConnectionPayload, -} from '../models/hosted-database'; +import { Injectable, inject } from '@angular/core'; +import { CreatedHostedDatabase } from '../models/hosted-database'; +import { ApiService } from './api.service'; @Injectable({ providedIn: 'root', }) export class HostedDatabaseService { - constructor(private _http: HttpClient) {} + private _api = inject(ApiService); - createHostedDatabase(companyId: string) { - return this._http.post(`/saas/hosted-database/create/${companyId}`, {}).pipe( - catchError((error) => { - return throwError(() => new Error(this._getErrorMessage(error))); - }), - ); - } - - createConnectionForHostedDatabase(payload: CreateHostedDatabaseConnectionPayload) { - return this._http.post(`/saas/connection/hosted`, payload).pipe( - catchError((error) => { - return throwError(() => new Error(this._getErrorMessage(error))); - }), - ); - } - - private _getErrorMessage(error: unknown): string { - if (error && typeof error === 'object' && 'error' in error) { - const responseError = (error as { error?: { message?: string } }).error; - if (responseError?.message) { - return responseError.message; - } - } - - if (error instanceof Error && error.message) { - return error.message; - } - - return 'Unknown error'; + createHostedDatabase(companyId: string): Promise { + return this._api.post(`/saas/hosted-database/create/${companyId}`, {}); } } From acd4ef25536ff1325793d8b3b74763a07081acc7 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Thu, 19 Mar 2026 11:01:50 +0000 Subject: [PATCH 3/5] Add Hosted Instances section to upgrade page Show hosted PostgreSQL tiers (Tiny node for free plan, Scalable for paid plans) in a new comparison table between Databases and Users sections. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/upgrade/upgrade.component.html | 21 ++++++++++++++++++- .../components/upgrade/upgrade.component.ts | 9 ++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/upgrade/upgrade.component.html b/frontend/src/app/components/upgrade/upgrade.component.html index cc638d404..edff0792a 100644 --- a/frontend/src/app/components/upgrade/upgrade.component.html +++ b/frontend/src/app/components/upgrade/upgrade.component.html @@ -40,7 +40,7 @@

    per each 10 users up to 3 users -