diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 12966d378..c42c9d6c5 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -118,6 +118,14 @@ const routes: Routes = [ path: 'change-password', loadChildren: () => import('./routes/password-change.routes').then((m) => m.PASSWORD_CHANGE_ROUTES), }, + { + path: 'hosted-databases', + pathMatch: 'full', + loadComponent: () => + import('./components/hosted-databases/hosted-databases.component').then((m) => m.HostedDatabasesComponent), + canActivate: [AuthGuard], + title: 'Hosted Databases | Rocketadmin', + }, { path: 'upgrade', loadComponent: () => import('./components/upgrade/upgrade.component').then((m) => m.UpgradeComponent), diff --git a/frontend/src/app/components/hosted-databases/hosted-database-delete-dialog/hosted-database-delete-dialog.component.css b/frontend/src/app/components/hosted-databases/hosted-database-delete-dialog/hosted-database-delete-dialog.component.css new file mode 100644 index 000000000..c91080bcf --- /dev/null +++ b/frontend/src/app/components/hosted-databases/hosted-database-delete-dialog/hosted-database-delete-dialog.component.css @@ -0,0 +1,3 @@ +.mat-mdc-dialog-content { + margin-bottom: -20px; +} diff --git a/frontend/src/app/components/hosted-databases/hosted-database-delete-dialog/hosted-database-delete-dialog.component.html b/frontend/src/app/components/hosted-databases/hosted-database-delete-dialog/hosted-database-delete-dialog.component.html new file mode 100644 index 000000000..e2a874d09 --- /dev/null +++ b/frontend/src/app/components/hosted-databases/hosted-database-delete-dialog/hosted-database-delete-dialog.component.html @@ -0,0 +1,15 @@ +

Delete {{ data.databaseName }}

+ +

+ This will permanently delete the hosted database and all its data. + This action cannot be undone. +

+
+ + + + diff --git a/frontend/src/app/components/hosted-databases/hosted-database-delete-dialog/hosted-database-delete-dialog.component.ts b/frontend/src/app/components/hosted-databases/hosted-database-delete-dialog/hosted-database-delete-dialog.component.ts new file mode 100644 index 000000000..ca0253212 --- /dev/null +++ b/frontend/src/app/components/hosted-databases/hosted-database-delete-dialog/hosted-database-delete-dialog.component.ts @@ -0,0 +1,38 @@ +import { Component, inject, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import posthog from 'posthog-js'; +import { FoundHostedDatabase } from 'src/app/models/hosted-database'; +import { HostedDatabaseService } from 'src/app/services/hosted-database.service'; +import { NotificationsService } from 'src/app/services/notifications.service'; + +@Component({ + selector: 'app-hosted-database-delete-dialog', + templateUrl: './hosted-database-delete-dialog.component.html', + styleUrls: ['./hosted-database-delete-dialog.component.css'], + imports: [MatDialogModule, MatButtonModule], +}) +export class HostedDatabaseDeleteDialogComponent { + private _hostedDatabaseService = inject(HostedDatabaseService); + private _notifications = inject(NotificationsService); + private _dialogRef = inject(MatDialogRef); + + protected data: FoundHostedDatabase = inject(MAT_DIALOG_DATA); + protected submitting = signal(false); + + async deleteDatabase(): Promise { + this.submitting.set(true); + + try { + const result = await this._hostedDatabaseService.deleteHostedDatabase(this.data.companyId, this.data.id); + + if (result?.success) { + posthog.capture('Hosted Databases: database deleted', { databaseName: this.data.databaseName }); + this._notifications.showSuccessSnackbar('Hosted database deleted successfully.'); + this._dialogRef.close('delete'); + } + } finally { + this.submitting.set(false); + } + } +} diff --git a/frontend/src/app/components/hosted-databases/hosted-database-reset-password-dialog/hosted-database-reset-password-dialog.component.css b/frontend/src/app/components/hosted-databases/hosted-database-reset-password-dialog/hosted-database-reset-password-dialog.component.css new file mode 100644 index 000000000..48b944db6 --- /dev/null +++ b/frontend/src/app/components/hosted-databases/hosted-database-reset-password-dialog/hosted-database-reset-password-dialog.component.css @@ -0,0 +1,73 @@ +.reset-dialog__content { + display: flex; + flex-direction: column; + gap: 16px; + min-width: min(100%, 34rem); +} + +.reset-dialog__credentials { + display: grid; + gap: 10px; + border: 1px solid var(--color-accentedPalette-100); + border-radius: 12px; + padding: 14px; +} + +.reset-dialog__row { + display: grid; + grid-template-columns: minmax(96px, 120px) minmax(0, 1fr); + align-items: start; + gap: 12px; +} + +.reset-dialog__label { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.reset-dialog__credentials code { + background: var(--color-accentedPalette-50); + border-radius: 8px; + font-family: "IBM Plex Mono", monospace; + padding: 8px 10px; + overflow-wrap: anywhere; +} + +.reset-dialog__hint { + color: rgba(0, 0, 0, 0.64); + font-size: 13px; + margin: 0; +} + +.reset-dialog__actions { + gap: 8px; + padding-top: 8px; +} + +@media (prefers-color-scheme: dark) { + .reset-dialog__credentials { + background: transparent; + border-color: var(--color-accentedPalette-400); + } + + .reset-dialog__credentials code { + background: var(--color-accentedPalette-600); + } + + .reset-dialog__hint { + color: rgba(255, 255, 255, 0.64); + } +} + +@media (width <= 600px) { + .reset-dialog__content { + min-width: auto; + } + + .reset-dialog__row { + grid-template-columns: 1fr; + gap: 6px; + } +} diff --git a/frontend/src/app/components/hosted-databases/hosted-database-reset-password-dialog/hosted-database-reset-password-dialog.component.html b/frontend/src/app/components/hosted-databases/hosted-database-reset-password-dialog/hosted-database-reset-password-dialog.component.html new file mode 100644 index 000000000..f26c04ebe --- /dev/null +++ b/frontend/src/app/components/hosted-databases/hosted-database-reset-password-dialog/hosted-database-reset-password-dialog.component.html @@ -0,0 +1,50 @@ +@if (phase() === 'confirm') { +

Reset password for {{ data.databaseName }}

+ +

+ This will generate a new password for the database. + Any existing connections using the current password will stop working. +

+
+ + + + +} @else { +

New password for {{ result()!.databaseName }}

+ +
+
+ Host + {{ result()!.hostname }} +
+
+ Port + {{ result()!.port }} +
+
+ Username + {{ result()!.username }} +
+
+ Password + {{ result()!.password }} +
+
+

+ Save the new password now. It cannot be recovered from this screen later. +

+
+ + + + +} diff --git a/frontend/src/app/components/hosted-databases/hosted-database-reset-password-dialog/hosted-database-reset-password-dialog.component.ts b/frontend/src/app/components/hosted-databases/hosted-database-reset-password-dialog/hosted-database-reset-password-dialog.component.ts new file mode 100644 index 000000000..fdf865699 --- /dev/null +++ b/frontend/src/app/components/hosted-databases/hosted-database-reset-password-dialog/hosted-database-reset-password-dialog.component.ts @@ -0,0 +1,49 @@ +import { CdkCopyToClipboard } from '@angular/cdk/clipboard'; +import { Component, computed, inject, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import posthog from 'posthog-js'; +import { CreatedHostedDatabase, FoundHostedDatabase } from 'src/app/models/hosted-database'; +import { HostedDatabaseService } from 'src/app/services/hosted-database.service'; +import { NotificationsService } from 'src/app/services/notifications.service'; + +@Component({ + selector: 'app-hosted-database-reset-password-dialog', + templateUrl: './hosted-database-reset-password-dialog.component.html', + styleUrls: ['./hosted-database-reset-password-dialog.component.css'], + imports: [MatDialogModule, MatButtonModule, CdkCopyToClipboard], +}) +export class HostedDatabaseResetPasswordDialogComponent { + private _hostedDatabaseService = inject(HostedDatabaseService); + private _notifications = inject(NotificationsService); + + protected data: FoundHostedDatabase = inject(MAT_DIALOG_DATA); + protected submitting = signal(false); + protected result = signal(null); + protected phase = computed(() => (this.result() ? 'result' : 'confirm')); + + get credentialsText(): string { + const r = this.result(); + if (!r) return ''; + return `postgres://${r.username}:${r.password}@${r.hostname}:${r.port}/${r.databaseName}`; + } + + async resetPassword(): Promise { + this.submitting.set(true); + + try { + const res = await this._hostedDatabaseService.resetHostedDatabasePassword(this.data.companyId, this.data.id); + + if (res) { + posthog.capture('Hosted Databases: password reset', { databaseName: this.data.databaseName }); + this.result.set(res); + } + } finally { + this.submitting.set(false); + } + } + + handleCredentialsCopied(): void { + this._notifications.showSuccessSnackbar('New credentials were copied to clipboard.'); + } +} diff --git a/frontend/src/app/components/hosted-databases/hosted-databases.component.css b/frontend/src/app/components/hosted-databases/hosted-databases.component.css new file mode 100644 index 000000000..3c9495df1 --- /dev/null +++ b/frontend/src/app/components/hosted-databases/hosted-databases.component.css @@ -0,0 +1,171 @@ +.profile-layout { + display: flex; + height: calc(100vh - 64px); +} + +.profile-main { + flex: 1; + background-color: var(--mat-sidenav-content-background-color); + overflow-y: auto; +} + +::ng-deep .profile-main > app-alert { + position: relative; + top: 0; + margin: 24px; +} + +.hosted-databases-page { + margin: var(--top-margin) auto; + padding: 0 clamp(40px, 5vw, 100px); + max-width: 800px; +} + +@media (width <= 600px) { + .hosted-databases-page { + padding: 0 16px; + margin: 1.5em auto; + } +} + +.hosted-databases-description { + color: rgba(0, 0, 0, 0.64); + margin-top: 8px; + margin-bottom: 32px; +} + +@media (prefers-color-scheme: dark) { + .hosted-databases-description { + color: rgba(255, 255, 255, 0.7); + } +} + +.hosted-databases-content { + display: flex; + flex-direction: column; + gap: 24px; +} + +.hosted-databases-loading { + display: flex; + justify-content: center; + padding: 48px 24px; + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .hosted-databases-loading { + color: rgba(255, 255, 255, 0.54); + } +} + +.hosted-databases-empty { + display: flex; + flex-direction: column; + align-items: center; + padding: 48px 24px; + text-align: center; + background-color: rgba(0, 0, 0, 0.02); + border-radius: 8px; +} + +@media (prefers-color-scheme: dark) { + .hosted-databases-empty { + background-color: rgba(255, 255, 255, 0.05); + } +} + +.hosted-databases-empty__icon { + font-size: 48px; + width: 48px; + height: 48px; + color: rgba(0, 0, 0, 0.26); + margin-bottom: 16px; +} + +@media (prefers-color-scheme: dark) { + .hosted-databases-empty__icon { + color: rgba(255, 255, 255, 0.3); + } +} + +.hosted-databases-empty p { + margin: 0; + color: rgba(0, 0, 0, 0.54); +} + +@media (prefers-color-scheme: dark) { + .hosted-databases-empty p { + color: rgba(255, 255, 255, 0.54); + } +} + +.hosted-databases-empty__hint { + font-size: 13px; + margin-top: 4px; +} + +.hosted-databases-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.db-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border: 1px solid var(--color-accentedPalette-100); + border-radius: 12px; + gap: 16px; +} + +@media (prefers-color-scheme: dark) { + .db-card { + border-color: var(--color-accentedPalette-400); + } +} + +@media (width <= 600px) { + .db-card { + flex-direction: column; + align-items: flex-start; + } +} + +.db-card__info { + flex: 1; + min-width: 0; +} + +.db-card__name { + font-weight: 600; + font-size: 16px; + margin-bottom: 6px; + overflow-wrap: anywhere; +} + +.db-card__details { + display: flex; + flex-wrap: wrap; + gap: 16px; + color: rgba(0, 0, 0, 0.64); + font-size: 13px; +} + +@media (prefers-color-scheme: dark) { + .db-card__details { + color: rgba(255, 255, 255, 0.64); + } +} + +.db-card__detail-label { + font-weight: 600; +} + +.db-card__actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} diff --git a/frontend/src/app/components/hosted-databases/hosted-databases.component.html b/frontend/src/app/components/hosted-databases/hosted-databases.component.html new file mode 100644 index 000000000..717ff9961 --- /dev/null +++ b/frontend/src/app/components/hosted-databases/hosted-databases.component.html @@ -0,0 +1,65 @@ +
+ + +
+ + +
+

Hosted Databases

+

+ Manage your hosted PostgreSQL database instances. +

+ +
+ @if (loading()) { +
+

Loading databases...

+
+ } @else if (databases()?.length === 0) { +
+ dns +

No hosted databases yet.

+

Create one from the connections page.

+
+ } @else { +
+ @for (db of databases(); track db.id) { +
+
+
{{ db.databaseName }}
+
+ + Host: + {{ db.hostname }} + + + Port: + {{ db.port }} + + + User: + {{ db.username }} + +
+
+
+ + +
+
+ } +
+ } +
+
+
+
diff --git a/frontend/src/app/components/hosted-databases/hosted-databases.component.ts b/frontend/src/app/components/hosted-databases/hosted-databases.component.ts new file mode 100644 index 000000000..5f500344d --- /dev/null +++ b/frontend/src/app/components/hosted-databases/hosted-databases.component.ts @@ -0,0 +1,84 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, OnInit, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Title } from '@angular/platform-browser'; +import posthog from 'posthog-js'; +import { FoundHostedDatabase } from 'src/app/models/hosted-database'; +import { CompanyService } from 'src/app/services/company.service'; +import { HostedDatabaseService } from 'src/app/services/hosted-database.service'; + +import { UserService } from 'src/app/services/user.service'; +import { ProfileSidebarComponent } from '../profile/profile-sidebar/profile-sidebar.component'; +import { AlertComponent } from '../ui-components/alert/alert.component'; +import { HostedDatabaseDeleteDialogComponent } from './hosted-database-delete-dialog/hosted-database-delete-dialog.component'; +import { HostedDatabaseResetPasswordDialogComponent } from './hosted-database-reset-password-dialog/hosted-database-reset-password-dialog.component'; + +@Component({ + selector: 'app-hosted-databases', + templateUrl: './hosted-databases.component.html', + styleUrls: ['./hosted-databases.component.css'], + imports: [CommonModule, MatButtonModule, MatIconModule, MatTooltipModule, AlertComponent, ProfileSidebarComponent], +}) +export class HostedDatabasesComponent implements OnInit { + private _hostedDatabaseService = inject(HostedDatabaseService); + private _userService = inject(UserService); + private _company = inject(CompanyService); + private _dialog = inject(MatDialog); + private _title = inject(Title); + + protected databases = signal(null); + protected loading = signal(true); + + private _companyId: string | null = null; + + ngOnInit(): void { + this._company.getCurrentTabTitle().subscribe((tabTitle) => { + this._title.setTitle(`Hosted Databases | ${tabTitle || 'Rocketadmin'}`); + }); + + this._userService.cast.subscribe((user) => { + if (user?.company?.id && user.company.id !== this._companyId) { + this._companyId = user.company.id; + this._loadDatabases(); + } + }); + } + + deleteDatabase(db: FoundHostedDatabase): void { + const dialogRef = this._dialog.open(HostedDatabaseDeleteDialogComponent, { + width: '25em', + data: db, + }); + + dialogRef.afterClosed().subscribe(async (action) => { + if (action === 'delete') { + posthog.capture('Hosted Databases: database deleted successfully'); + await this._loadDatabases(); + } + }); + } + + resetPassword(db: FoundHostedDatabase): void { + this._dialog.open(HostedDatabaseResetPasswordDialogComponent, { + width: '32em', + maxWidth: '95vw', + data: db, + }); + } + + private async _loadDatabases(): Promise { + if (!this._companyId) return; + + this.loading.set(true); + + try { + const result = await this._hostedDatabaseService.listHostedDatabases(this._companyId); + this.databases.set(result ?? []); + } finally { + this.loading.set(false); + } + } +} diff --git a/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.html b/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.html index 9c722a22e..5161a4e5c 100644 --- a/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.html +++ b/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.html @@ -50,6 +50,17 @@ payments Subscription + + dns + Hosted Databases + Customization diff --git a/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.ts b/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.ts index ecffef5d6..04dae9e56 100644 --- a/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.ts +++ b/frontend/src/app/components/profile/profile-sidebar/profile-sidebar.component.ts @@ -15,9 +15,9 @@ import { UiSettingsService } from '../../../services/ui-settings.service'; imports: [CommonModule, RouterModule, MatListModule, MatIconModule, MatButtonModule, MatTooltipModule], }) export class ProfileSidebarComponent implements OnInit, AfterViewInit { - activeTab = input<'account' | 'company' | 'subscription' | 'branding' | 'saml' | 'api' | 'secrets' | 'zapier'>( - 'account', - ); + activeTab = input< + 'account' | 'company' | 'subscription' | 'hosted-databases' | 'branding' | 'saml' | 'api' | 'secrets' | 'zapier' + >('account'); collapsed = signal(false); initialized = signal(false); diff --git a/frontend/src/app/models/hosted-database.ts b/frontend/src/app/models/hosted-database.ts index 5cf3785ec..4c018d405 100644 --- a/frontend/src/app/models/hosted-database.ts +++ b/frontend/src/app/models/hosted-database.ts @@ -8,3 +8,13 @@ export interface CreatedHostedDatabase { password: string; createdAt: string; } + +export interface FoundHostedDatabase { + id: string; + companyId: string; + databaseName: string; + hostname: string; + port: number; + username: string; + createdAt: string; +} diff --git a/frontend/src/app/services/hosted-database.service.ts b/frontend/src/app/services/hosted-database.service.ts index cd081cc0e..f6bc7f9e3 100644 --- a/frontend/src/app/services/hosted-database.service.ts +++ b/frontend/src/app/services/hosted-database.service.ts @@ -1,5 +1,5 @@ import { Injectable, inject } from '@angular/core'; -import { CreatedHostedDatabase } from '../models/hosted-database'; +import { CreatedHostedDatabase, FoundHostedDatabase } from '../models/hosted-database'; import { ApiService } from './api.service'; @Injectable({ @@ -11,4 +11,19 @@ export class HostedDatabaseService { createHostedDatabase(companyId: string): Promise { return this._api.post(`/saas/hosted-database/create/${companyId}`, {}); } + + listHostedDatabases(companyId: string): Promise { + return this._api.get(`/saas/hosted-database/${companyId}`); + } + + deleteHostedDatabase(companyId: string, hostedDatabaseId: string): Promise<{ success: boolean } | null> { + return this._api.delete<{ success: boolean }>(`/saas/hosted-database/delete/${companyId}/${hostedDatabaseId}`); + } + + resetHostedDatabasePassword(companyId: string, hostedDatabaseId: string): Promise { + return this._api.post( + `/saas/hosted-database/reset-password/${companyId}/${hostedDatabaseId}`, + {}, + ); + } }