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..d1ffbe50b --- /dev/null +++ b/frontend/src/app/components/connections-list/hosted-database-plan-dialog/hosted-database-plan-dialog.component.css @@ -0,0 +1,72 @@ +.plan-dialog__content { + display: flex; + flex-direction: column; + gap: 12px; + min-width: min(100%, 30rem); + margin-top: 16px; +} + +.plan-dialog__option { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + border: 1px solid rgba(0, 0, 0, 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: var(--color-accentedPalette-200); + background: var(--color-accentedPalette-50); +} + +.plan-dialog__option-header { + display: flex; + align-items: center; + gap: 8px; + color: var(--mat-sidenav-content-text-color); +} + +.plan-dialog__option-title { + font-size: 16px; + font-weight: 600; +} + +.plan-dialog__option-description { + margin: 0; + color: rgba(0, 0, 0, 0.64); + font-size: 14px; +} + +.plan-dialog__option-price { + font-size: 13px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: rgba(0, 0, 0, 0.48); +} + +@media (prefers-color-scheme: dark) { + .plan-dialog__option { + border-color: rgba(255, 255, 255, 0.08); + } + + .plan-dialog__option:hover { + border-color: var(--color-accentedPalette-500); + background: var(--color-accentedPalette-700); + } + + .plan-dialog__option-description { + color: rgba(255, 255, 255, 0.64); + } + + .plan-dialog__option-price { + color: rgba(255, 255, 255, 0.48); + } +} 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..5cf3b4bae --- /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/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..9096e2c22 --- /dev/null +++ b/frontend/src/app/components/connections-list/hosted-database-success-dialog/hosted-database-success-dialog.component.css @@ -0,0 +1,86 @@ +.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: var(--color-error-background); + border: 1px solid var(--color-error); + border-radius: 8px; + color: var(--color-error); + padding: 12px 14px; +} + +.hosted-dialog__credentials { + display: grid; + gap: 10px; + border: 1px solid var(--color-accentedPalette-100); + 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: var(--color-accentedPalette-50); + border-radius: 8px; + font-family: "IBM Plex Mono", monospace; + padding: 8px 10px; + overflow-wrap: anywhere; +} + +.hosted-dialog__hint { + color: rgba(0, 0, 0, 0.64); + font-size: 13px; +} + +.hosted-dialog__actions { + gap: 8px; + padding-top: 8px; +} + +@media (prefers-color-scheme: dark) { + .hosted-dialog__credentials { + background: transparent; + border-color: var(--color-accentedPalette-400); + } + + .hosted-dialog__credentials code { + background: var(--color-accentedPalette-600); + } + + .hosted-dialog__hint { + color: rgba(255, 255, 255, 0.64); + } +} + +@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..ee458317c --- /dev/null +++ b/frontend/src/app/components/connections-list/hosted-database-success-dialog/hosted-database-success-dialog.component.html @@ -0,0 +1,82 @@ +

+ {{ 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..3e40316b5 --- /dev/null +++ b/frontend/src/app/components/connections-list/hosted-database-success-dialog/hosted-database-success-dialog.component.ts @@ -0,0 +1,45 @@ +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 { + const { username, password, hostname, port, databaseName } = this.data.hostedDatabase; + return `postgres://${username}:${password}@${hostname}:${port}/${databaseName}`; + } + + 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..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 @@ -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..dd4440fa1 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,141 @@ 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 { 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 { OwnConnectionsComponent } from './own-connections.component'; describe('OwnConnectionsComponent', () => { let component: OwnConnectionsComponent; let fixture: ComponentFixture; + let hostedDatabaseService: { + createHostedDatabase: ReturnType; + }; + let connectionsService: { + fetchConnections: ReturnType; + }; + let dialog: { + open: ReturnType; + }; beforeEach(async () => { + hostedDatabaseService = { + createHostedDatabase: 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', 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' }, + subscriptionLevel: SubscriptionPlans.team, + } as User; + + await component.createHostedDatabase(); + + expect(hostedDatabaseService.createHostedDatabase).toHaveBeenCalledWith('company-id'); + 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..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,15 +1,31 @@ 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 { User } from '@sentry/angular'; +import { Router, RouterModule } from '@angular/router'; import posthog from 'posthog-js'; +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 { UiSettings } from 'src/app/models/ui-settings'; +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, +} from '../hosted-database-success-dialog/hosted-database-success-dialog.component'; @Component({ selector: 'app-own-connections', @@ -24,18 +40,33 @@ 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 = signal(false); constructor( private _uiSettings: UiSettingsService, private _companyService: CompanyService, + private _connectionsService: ConnectionsService, + private _hostedDatabaseService: HostedDatabaseService, + private _notifications: NotificationsService, + private _dialog: MatDialog, + private _router: Router, ) {} + 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 +77,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 +105,86 @@ export class OwnConnectionsComponent implements OnInit, OnChanges { const match = title.match(/(\([^)]+\))/); return match ? match[1] : ''; } + + 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; + + this.creatingHostedDatabase.set(true); + posthog.capture('Connections: hosted PostgreSQL creation started'); + + 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 { + 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/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 -