diff --git a/backend/src/entities/company-info/company-info.controller.ts b/backend/src/entities/company-info/company-info.controller.ts index 4a5a6ece5..1b4e19afd 100644 --- a/backend/src/entities/company-info/company-info.controller.ts +++ b/backend/src/entities/company-info/company-info.controller.ts @@ -81,8 +81,8 @@ import { IRemoveUserFromCompany, IRevokeUserInvitationInCompany, ISuspendUsersInCompany, - IUnsuspendUsersInCompany, IToggleCompanyTestConnectionsMode, + IUnsuspendUsersInCompany, IUpdateCompanyName, IUpdateUsers2faStatusInCompany, IUpdateUsersCompanyRoles, @@ -173,6 +173,7 @@ export class CompanyInfoController { description: 'Get company name by id.', type: FoundCompanyNameDs, }) + @Throttle({ default: { limit: isTest() ? 200 : 10, ttl: 60000 } }) @Get('name/:companyId') async getCompanyNameById(@Param('companyId') companyId: string): Promise { return await this.getCompanyNameUseCase.execute(companyId); diff --git a/backend/src/entities/email/email-verification.entity.ts b/backend/src/entities/email/email-verification.entity.ts index 072506dbb..cd9b6474b 100644 --- a/backend/src/entities/email/email-verification.entity.ts +++ b/backend/src/entities/email/email-verification.entity.ts @@ -9,6 +9,9 @@ export class EmailVerificationEntity { @Column({ default: null }) verification_string: string; + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + createdAt: Date; + @OneToOne( () => UserEntity, (user) => user.email_verification, diff --git a/backend/src/entities/email/repository/email-verification-custom-repository-extension.ts b/backend/src/entities/email/repository/email-verification-custom-repository-extension.ts index 639cb481a..9731f6d0c 100644 --- a/backend/src/entities/email/repository/email-verification-custom-repository-extension.ts +++ b/backend/src/entities/email/repository/email-verification-custom-repository-extension.ts @@ -1,3 +1,4 @@ +import { Constants } from '../../../helpers/constants/constants.js'; import { Encryptor } from '../../../helpers/encryption/encryptor.js'; import { UserEntity } from '../../user/user.entity.js'; import { EmailVerificationEntity } from '../email-verification.entity.js'; @@ -8,6 +9,9 @@ export const emailVerificationRepositoryExtension = { .leftJoinAndSelect('email_verification.user', 'user') .where('email_verification.verification_string = :ver_string', { ver_string: verificationString, + }) + .andWhere('email_verification.createdAt >= :valid_after', { + valid_after: Constants.ONE_DAY_AGO(), }); return await qb.getOne(); }, diff --git a/backend/src/entities/user/user-password/password-reset.entity.ts b/backend/src/entities/user/user-password/password-reset.entity.ts index b6e9d6a69..2ba990b51 100644 --- a/backend/src/entities/user/user-password/password-reset.entity.ts +++ b/backend/src/entities/user/user-password/password-reset.entity.ts @@ -9,6 +9,9 @@ export class PasswordResetEntity { @Column({ default: null }) verification_string: string; + @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) + createdAt: Date; + @OneToOne( () => UserEntity, (user) => user.password_reset, diff --git a/backend/src/entities/user/user-password/repository/user-password-custom-repository-extension.ts b/backend/src/entities/user/user-password/repository/user-password-custom-repository-extension.ts index af0c03b5f..b508ea3b0 100644 --- a/backend/src/entities/user/user-password/repository/user-password-custom-repository-extension.ts +++ b/backend/src/entities/user/user-password/repository/user-password-custom-repository-extension.ts @@ -1,3 +1,4 @@ +import { Constants } from '../../../../helpers/constants/constants.js'; import { Encryptor } from '../../../../helpers/encryption/encryptor.js'; import { UserEntity } from '../../user.entity.js'; import { PasswordResetEntity } from '../password-reset.entity.js'; @@ -8,6 +9,9 @@ export const userPasswordResetCustomRepositoryExtension = { .leftJoinAndSelect('password_reset.user', 'user') .where('password_reset.verification_string = :ver_string', { ver_string: verificationString, + }) + .andWhere('password_reset.createdAt >= :valid_after', { + valid_after: Constants.ONE_DAY_AGO(), }); return await qb.getOne(); }, diff --git a/backend/src/entities/visualizations/panel/utils/check-query-is-safe.util.ts b/backend/src/entities/visualizations/panel/utils/check-query-is-safe.util.ts index 50157dc6f..a86bbfacd 100644 --- a/backend/src/entities/visualizations/panel/utils/check-query-is-safe.util.ts +++ b/backend/src/entities/visualizations/panel/utils/check-query-is-safe.util.ts @@ -1,5 +1,6 @@ import { BadRequestException } from '@nestjs/common'; import { ConnectionTypesEnum } from '@rocketadmin/shared-code/dist/src/shared/enums/connection-types-enum.js'; +import { isReadOnlyMongoAggregationPipeline } from '../../../../ai-core/tools/query-validators.js'; import { slackPostMessage } from '../../../../helpers/slack/slack-post-message.js'; const FORBIDDEN_SQL_KEYWORDS = [ @@ -175,6 +176,14 @@ export function checkMongoQueryIsSafe(query: string): QuerySafetyResult { } } + if (!isReadOnlyMongoAggregationPipeline(query)) { + return { + isSafe: false, + reason: + 'Query must be a read-only aggregation pipeline (write stages or server-side JavaScript operators such as $out, $merge, $function, $accumulator, $where are not allowed)', + }; + } + return { isSafe: true }; } diff --git a/backend/src/migrations/1780411916141-AddCreatedAtColumnsIntoPasswordResetAndEmailVerificationEntities.ts b/backend/src/migrations/1780411916141-AddCreatedAtColumnsIntoPasswordResetAndEmailVerificationEntities.ts new file mode 100644 index 000000000..028820025 --- /dev/null +++ b/backend/src/migrations/1780411916141-AddCreatedAtColumnsIntoPasswordResetAndEmailVerificationEntities.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCreatedAtColumnsIntoPasswordResetAndEmailVerificationEntities1780411916141 + implements MigrationInterface +{ + name = 'AddCreatedAtColumnsIntoPasswordResetAndEmailVerificationEntities1780411916141'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "email_verification" ADD "createdAt" TIMESTAMP NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "password_reset" ADD "createdAt" TIMESTAMP NOT NULL DEFAULT now()`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "password_reset" DROP COLUMN "createdAt"`); + await queryRunner.query(`ALTER TABLE "email_verification" DROP COLUMN "createdAt"`); + } +} diff --git a/frontend/angular.json b/frontend/angular.json index e5aff6974..409eeea7a 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -75,8 +75,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "10kb", - "maximumError": "20kb" + "maximumWarning": "20kb", + "maximumError": "32kb" } ] }, @@ -129,8 +129,8 @@ }, { "type": "anyComponentStyle", - "maximumWarning": "10kb", - "maximumError": "20kb" + "maximumWarning": "20kb", + "maximumError": "32kb" } ] } diff --git a/frontend/src/app/app.component.css b/frontend/src/app/app.component.css index 3c436c2c5..47eda27f3 100644 --- a/frontend/src/app/app.component.css +++ b/frontend/src/app/app.component.css @@ -22,6 +22,34 @@ width: 60vw; } +.main-menu-sidenav .account-section-item { + --mdc-list-list-item-label-text-size: 13px; + --mat-list-list-item-leading-icon-size: 18px; + --mdc-list-list-item-one-line-container-height: 40px; + --mdc-list-list-item-two-line-container-height: 52px; +} + +.main-menu-sidenav .account-section-item .mat-icon { + font-size: 18px; + width: 18px; + height: 18px; +} + +@media (width <= 600px) { + .main-menu-sidenav.mat-drawer { + top: 44px !important; + height: calc(100vh - 44px) !important; + } + + .main-menu-container ::ng-deep .mat-drawer-backdrop.mat-drawer-shown { + top: 44px !important; + } + + .main-menu-sidenav mat-toolbar { + display: none !important; + } +} + .nav-bar { position: sticky; top: 0; @@ -218,8 +246,6 @@ display: flex; flex-direction: column; align-items: flex-start; - border-top: var(--mat-table-row-item-outline-width, 1px) solid - var(--mat-table-row-item-outline-color, rgba(0, 0, 0, 0.12)); border-bottom: var(--mat-table-row-item-outline-width, 1px) solid var(--mat-table-row-item-outline-color, rgba(0, 0, 0, 0.12)); margin-left: 8px; @@ -243,7 +269,7 @@ } .connection-navigation__upgrade-button { - margin-top: 8px; + margin-top: -12px; margin-left: 8px; width: calc(100% - 16px); } diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 1ff5688b2..299d561e0 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -5,8 +5,8 @@ class="main-menu-sidenav" > Rocketadmin - - + @@ -21,7 +21,7 @@ {{navigationTabs[tab].caption}} - + - + - + + + database + +
Hosted databases
+
+ - + - + - + + routerLinkActive="nav-bar__button_active" + (click)="drawer.close()"> Upgrade @@ -88,7 +97,7 @@ class="logo__image"> - + Rocketadmin logo @@ -125,6 +134,11 @@ [ngClass]="{'connection_active': connectionID === connection.connection.id}"> {{connection.displayTitle}} + + + database + Hosted databases + demo diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 8d08e92af..c0f2240b7 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectorRef, Component } from '@angular/core'; import { MatBadgeModule } from '@angular/material/badge'; import { MatButtonModule } from '@angular/material/button'; +import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule, MatIconRegistry } from '@angular/material/icon'; import { MatListModule } from '@angular/material/list'; import { MatMenuModule } from '@angular/material/menu'; @@ -50,6 +51,7 @@ amplitude.getInstance().init('9afd282be91f94da735c11418d5ff4f5'); MatButtonModule, MatBadgeModule, MatMenuModule, + MatDividerModule, MatTooltipModule, Angulartics2OnModule, FeatureNotificationComponent, diff --git a/frontend/src/app/components/audit/audit.component.css b/frontend/src/app/components/audit/audit.component.css index bf7327dfa..d5a7162dd 100644 --- a/frontend/src/app/components/audit/audit.component.css +++ b/frontend/src/app/components/audit/audit.component.css @@ -20,6 +20,21 @@ header { margin: 0; } +.title-block { + display: flex; + align-items: center; + gap: 8px; +} + +.title-block h1 { + margin: 0; + line-height: 1; +} + +.back-button { + display: none !important; +} + .filters { display: flex; /* grid-template-columns: 1fr 1fr; */ @@ -215,3 +230,447 @@ td.mat-cell { color: rgba(255, 255, 255, 0.6); } } + +@media (width <= 600px) { + .wrapper { + width: 100%; + max-width: 100%; + min-width: 0; + padding: 0 16px; + box-sizing: border-box; + overflow-x: clip; + overflow-y: visible; + } + + header { + flex-direction: column; + align-items: stretch; + gap: 12px; + margin: 16px 0 12px; + } + + .back-button { + display: inline-flex !important; + align-items: center; + justify-content: center; + margin-left: -12px; + } + + .title-block { + gap: 0; + align-items: center; + } + + .title-block h1 { + font-size: 22px; + font-weight: 600; + line-height: 1; + } + + .filters { + flex-direction: row; + gap: 8px; + width: 100%; + max-width: 100%; + min-width: 0; + } + + .filters mat-form-field { + flex: 1 1 0; + min-width: 0; + max-width: 100%; + width: auto; + } + + .filters mat-form-field ::ng-deep .mat-mdc-text-field-wrapper, + .filters mat-form-field ::ng-deep .mat-mdc-form-field-flex { + min-width: 0 !important; + width: 100% !important; + } + + .filters mat-form-field ::ng-deep .mat-mdc-form-field-infix { + min-width: 0 !important; + flex: 1 1 auto !important; + width: auto !important; + } + + .filters mat-form-field ::ng-deep .mat-mdc-select { + width: 100%; + } + + .filters mat-form-field ::ng-deep .mat-mdc-select-arrow-wrapper { + padding-left: 4px; + } + + .audit-banner { + margin: 0 -16px 12px; + width: calc(100% + 32px); + } + + .table-wrapper { + width: 100%; + max-width: 100%; + min-width: 0; + background: transparent !important; + box-shadow: none !important; + box-sizing: border-box; + } + + .table-wrapper table.mat-mdc-table, + .table-wrapper table.mat-mdc-table thead, + .table-wrapper table.mat-mdc-table tbody, + .table-wrapper table.mat-mdc-table tr { + display: block; + width: 100%; + } + + .table-wrapper table.mat-mdc-table thead, + .table-wrapper table.mat-mdc-table tr.mat-mdc-header-row, + .table-wrapper table.mat-mdc-table th.mat-mdc-header-cell { + display: none !important; + } + + .table-wrapper table.mat-mdc-table tbody { + display: flex; + flex-direction: column; + gap: 10px; + } + + .table-wrapper tr.mat-mdc-row { + background: var(--mat-table-background-color, #fff); + border-radius: 12px; + padding: 10px 14px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04); + display: grid !important; + grid-template-columns: auto 1fr; + grid-column-gap: 12px; + grid-row-gap: 4px; + height: auto !important; + width: calc(100vw - 32px) !important; + min-width: 0 !important; + max-width: calc(100vw - 32px) !important; + box-sizing: border-box; + margin: 0 !important; + } + + .table-wrapper table.mat-mdc-table { + min-width: 0 !important; + max-width: 100% !important; + width: 100% !important; + background: transparent !important; + } + + .table-wrapper table.mat-mdc-table tbody { + width: 100% !important; + } + + .table-wrapper td.mat-mdc-cell { + display: block; + border: 0 !important; + padding: 0 !important; + font-size: 13px; + } + + .table-wrapper td.mat-mdc-cell .table-cell-content { + padding: 0; + } + + /* columns order: User(1) Table(2) Action(3) Status(4) Date(5) Changes(6) + Card layout: + row 1: User (name + email) full width + row 2: Action + Table (inline) + row 3: Status + row 4: Date + Details */ + .table-wrapper td.mat-mdc-cell:nth-of-type(1) { + grid-column: 1 / -1; + grid-row: 1; + } + + .table-wrapper td.mat-mdc-cell:nth-of-type(3) { + grid-column: 1 / 2; + grid-row: 2; + font-weight: 500; + } + + .table-wrapper td.mat-mdc-cell:nth-of-type(2) { + grid-column: 2 / 3; + grid-row: 2; + font-weight: 600; + font-size: 14px; + justify-self: start; + } + + .table-wrapper td.mat-mdc-cell:nth-of-type(4) { + grid-column: 1 / -1; + grid-row: 3; + } + + .table-wrapper td.mat-mdc-cell:nth-of-type(5) { + grid-column: 1 / 2; + grid-row: 4; + opacity: 0.75; + font-size: 12px; + } + + .table-wrapper td.mat-mdc-cell:nth-of-type(6) { + grid-column: 2 / 3; + grid-row: 4; + justify-self: end; + } + + .status-badge { + padding: 2px 8px; + font-size: 12px; + } + + .mat-h1 { + font-size: 22px; + } + + .table-wrapper mat-paginator ::ng-deep .mat-mdc-paginator-container { + flex-wrap: nowrap !important; + justify-content: space-between !important; + padding: 0 8px !important; + min-height: 48px; + } + + .table-wrapper mat-paginator ::ng-deep .mat-mdc-paginator-page-size { + margin: 0 !important; + margin-right: auto !important; + } + + .table-wrapper mat-paginator ::ng-deep .mat-mdc-paginator-range-label { + margin: 0 4px !important; + } + + .table-wrapper mat-paginator { + margin-top: 8px; + background: transparent !important; + } +} + +@media (prefers-color-scheme: dark) and (width <= 600px) { + .table-wrapper tr.mat-mdc-row { + background: var(--surface-dark-color); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2); + } +} + +@media (width <= 600px) { + .audit-feed { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + max-width: 100%; + min-width: 0; + padding-bottom: 16px; + } + + .audit-feed.hidden { + display: none; + } + + .audit-feed__date-header { + position: sticky; + top: 44px; + z-index: 2; + background: var(--mat-sidenav-content-background-color, #fff); + padding: 8px 0 4px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(0, 0, 0, 0.54); + } + + .audit-card { + display: flex; + align-items: flex-start; + gap: 12px; + background: var(--mat-table-background-color, #fff); + border-radius: 12px; + padding: 10px 14px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04); + cursor: pointer; + transition: opacity 200ms ease; + } + + .audit-card_fade { + opacity: 0.6; + } + + .audit-card__avatar { + flex: 0 0 36px; + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--color-accentedPalette-100); + color: var(--color-accentedPalette-700); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 600; + } + + .audit-card__body { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + } + + .audit-card__row { + display: flex; + align-items: center; + gap: 6px; + } + + .audit-card__row_top { + justify-content: space-between; + } + + .audit-card__user { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; + font-size: 14px; + } + + .audit-card__user-email { + font-weight: 400; + color: rgba(0, 0, 0, 0.72); + } + + .audit-card__time { + flex: 0 0 auto; + font-size: 12px; + color: rgba(0, 0, 0, 0.54); + } + + .audit-card__row_middle { + font-size: 13px; + } + + .audit-card__action-icon { + font-size: 16px; + width: 16px; + height: 16px; + } + + .audit-card__action { + font-weight: 500; + } + + .audit-card__sep { + color: rgba(0, 0, 0, 0.3); + } + + .audit-card__table { + font-family: "IBM Plex Mono", monospace; + font-size: 12px; + color: rgba(0, 0, 0, 0.72); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .audit-card__row_bottom { + justify-content: space-between; + margin-top: 2px; + } + + .audit-card__details-link { + color: var(--color-accentedPalette-500); + font-size: 13px; + font-weight: 500; + } + + .audit-feed__paginator { + margin-top: 12px; + background: transparent !important; + } + + .audit-feed__paginator ::ng-deep .mat-mdc-paginator-outer-container, + .audit-feed__paginator ::ng-deep .mat-mdc-paginator-container { + justify-content: space-between !important; + flex-wrap: nowrap !important; + min-height: 48px; + padding: 0 !important; + } + + .audit-feed__paginator ::ng-deep .mat-mdc-paginator-page-size { + margin: 0 !important; + padding: 0 !important; + margin-right: auto !important; + flex: 0 0 auto; + } + + .audit-feed__paginator ::ng-deep .mat-mdc-paginator-page-size-label { + margin: 0 4px 0 0 !important; + } + + .audit-feed__paginator ::ng-deep .mat-mdc-paginator-page-size-select { + margin: 0 8px 0 0 !important; + width: auto !important; + } + + .audit-feed__paginator ::ng-deep .mat-mdc-paginator-page-size-select .mat-mdc-form-field-infix { + width: auto !important; + min-width: 36px !important; + padding-right: 4px !important; + } + + .audit-feed__paginator ::ng-deep .mat-mdc-paginator-page-size-select .mat-mdc-select { + width: auto !important; + } + + .audit-feed__paginator ::ng-deep .mat-mdc-paginator-page-size-select .mat-mdc-select-arrow-wrapper { + padding-left: 6px !important; + } + + .audit-feed__paginator ::ng-deep .mat-mdc-paginator-page-size-select .mat-mdc-text-field-wrapper { + padding: 0 10px !important; + } + + .audit-feed__paginator ::ng-deep .mat-mdc-paginator-range-actions { + flex: 0 0 auto; + margin: 0 !important; + } + + .audit-feed__paginator ::ng-deep .mat-mdc-paginator-range-label { + margin: 0 4px !important; + } +} + +@media (prefers-color-scheme: dark) and (width <= 600px) { + .audit-feed__date-header { + background: var(--surface-dark-color); + color: rgba(255, 255, 255, 0.6); + } + + .audit-card { + background: var(--surface-dark-color); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2); + } + + .audit-card__user-email { + color: rgba(255, 255, 255, 0.7); + } + + .audit-card__time { + color: rgba(255, 255, 255, 0.54); + } + + .audit-card__sep { + color: rgba(255, 255, 255, 0.3); + } + + .audit-card__table { + color: rgba(255, 255, 255, 0.72); + } +} diff --git a/frontend/src/app/components/audit/audit.component.html b/frontend/src/app/components/audit/audit.component.html index 7e6c2ca45..fc1926423 100644 --- a/frontend/src/app/components/audit/audit.component.html +++ b/frontend/src/app/components/audit/audit.component.html @@ -1,6 +1,11 @@
-

Audit

+
+ + arrow_back + +

Audit

+
Tables @@ -67,7 +72,49 @@

Rocketadmin can not find any tables

-
+ +
{{ group.date }}
+
+
{{ getInitials(entry.UserEmail) }}
+
+
+ + {{ getUserName(entry.UserEmail) }} + {{ entry.UserEmail }} + + {{ entry.TimeOnly }} +
+
+ {{ entry.ActionIcon }} + {{ entry.Action || '—' }} + · + {{ entry.Table }} +
+
+ + {{ entry.Status === 'successfully' ? 'Success' : (entry.Status || '—') }} + + Details › +
+
+
+
+ + +
+ +
diff --git a/frontend/src/app/components/audit/audit.component.ts b/frontend/src/app/components/audit/audit.component.ts index 2969e8f5a..51b8d5850 100644 --- a/frontend/src/app/components/audit/audit.component.ts +++ b/frontend/src/app/components/audit/audit.component.ts @@ -5,7 +5,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; -import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator'; +import { MatPaginator, MatPaginatorIntl, MatPaginatorModule } from '@angular/material/paginator'; import { MatSelectModule } from '@angular/material/select'; import { MatTableModule } from '@angular/material/table'; import { Title } from '@angular/platform-browser'; @@ -69,6 +69,12 @@ export class AuditComponent implements OnInit { public dataSource: AuditDataSource = null; + public mobileGroupedLogs: { date: string; entries: any[] }[] = []; + + get isMobileView(): boolean { + return typeof window !== 'undefined' && window.innerWidth <= 600; + } + @ViewChild(MatPaginator) paginator: MatPaginator; constructor( @@ -78,7 +84,11 @@ export class AuditComponent implements OnInit { private _companyService: CompanyService, public dialog: MatDialog, private title: Title, - ) {} + private paginatorIntl: MatPaginatorIntl, + ) { + this.paginatorIntl.itemsPerPageLabel = 'Per page:'; + this.paginatorIntl.changes.next(); + } ngAfterViewInit() { this.dataSource.paginator = this.paginator; @@ -100,6 +110,9 @@ export class AuditComponent implements OnInit { this.columns = ['User', 'Table', 'Action', 'Status', 'Date', 'Changes']; this.dataColumns = ['User', 'Table', 'Action', 'Status', 'Date']; this.dataSource = new AuditDataSource(this._connections); + this.dataSource.connect(null as any).subscribe((rows) => { + this.mobileGroupedLogs = this.groupByDate(rows); + }); this.loadLogsPage(); this._tables.fetchTables(this.connectionID).subscribe( @@ -149,4 +162,36 @@ export class AuditComponent implements OnInit { const user = this.usersList.find((u) => u.email === email); return user?.name || null; } + + getInitials(email: string): string { + if (!email) return '?'; + const name = this.getUserName(email); + if (name) { + const parts = name.trim().split(/\s+/); + return parts + .slice(0, 2) + .map((p) => p[0]?.toUpperCase() ?? '') + .join(''); + } + const local = email.split('@')[0]; + const parts = local.split(/[._-]/); + return parts + .slice(0, 2) + .map((p) => p[0]?.toUpperCase() ?? '') + .join(''); + } + + trackByDate(_index: number, group: { date: string }): string { + return group.date; + } + + private groupByDate(rows: any[]): { date: string; entries: any[] }[] { + const groups = new Map(); + for (const row of rows) { + const date = row.DateOnly; + if (!groups.has(date)) groups.set(date, []); + groups.get(date)!.push(row); + } + return Array.from(groups, ([date, entries]) => ({ date, entries })); + } } diff --git a/frontend/src/app/components/auto-configure/auto-configure.component.css b/frontend/src/app/components/auto-configure/auto-configure.component.css index aa84d8a0b..79a592682 100644 --- a/frontend/src/app/components/auto-configure/auto-configure.component.css +++ b/frontend/src/app/components/auto-configure/auto-configure.component.css @@ -46,8 +46,8 @@ display: flex; flex-direction: column; align-items: center; - padding: 48px 32px 36px; - gap: 16px; + padding: 40px 32px 24px; + gap: 10px; } .card__title { @@ -64,6 +64,16 @@ } } +.card__head-detail { + width: 100%; + min-height: 64px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; +} + .card__subtitle { margin: 0; font-size: 14px; @@ -160,7 +170,6 @@ position: relative; width: 72px; height: 72px; - margin-bottom: 8px; } .spinner__ring { @@ -191,33 +200,195 @@ to { transform: rotate(-360deg); } } -/* ── Indeterminate progress bar ── */ +/* ── Success checkmark (replaces spinner on complete) ── */ + +.success-mark { + width: 72px; + height: 72px; +} -.progress-bar { +.success-mark svg { width: 100%; - max-width: 320px; - height: 4px; - border-radius: 2px; - background: var(--color-primaryPalette-200); - overflow: hidden; + height: 100%; + overflow: visible; +} + +.success-mark__circle { + fill: none; + stroke: var(--color-successPalette-500); + stroke-width: 2; + stroke-linecap: round; + stroke-dasharray: 158; + stroke-dashoffset: 158; + animation: success-circle 500ms ease-out forwards; + transform-origin: 50% 50%; +} + +.success-mark__check { + fill: none; + stroke: var(--color-successPalette-500); + stroke-width: 3.5; + stroke-linecap: round; + stroke-linejoin: round; + stroke-dasharray: 36; + stroke-dashoffset: 36; + animation: success-check 350ms 400ms ease-out forwards; +} + +@keyframes success-circle { + to { stroke-dashoffset: 0; } +} + +@keyframes success-check { + to { stroke-dashoffset: 0; } +} + +/* ── Summary chips (shown on complete) ── */ + +.summary-chips { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; margin-top: 4px; } +.summary-chip { + display: inline-flex; + align-items: baseline; + gap: 4px; + padding: 4px 10px; + border-radius: 999px; + background: var(--color-successPalette-50); + color: var(--color-successPalette-700); + font-size: 12px; + font-weight: 500; + line-height: 1.4; + white-space: nowrap; + animation: chip-enter 280ms ease-out; +} + +.summary-chip strong { + font-size: 13px; + font-weight: 700; +} + @media (prefers-color-scheme: dark) { - .progress-bar { - background: var(--color-primaryPalette-800); + .summary-chip { + background: var(--color-successPalette-900); + color: var(--color-successPalette-100); } } -.progress-bar__fill { - width: 40%; - height: 100%; - border-radius: 2px; - background: var(--color-accentedPalette-500); - animation: indeterminate 1.5s ease-in-out infinite; +@keyframes chip-enter { + from { transform: scale(0.85); opacity: 0; } + to { transform: scale(1); opacity: 1; } } -@keyframes indeterminate { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(350%); } +/* ── Live status log ── */ + +.status-log { + list-style: none; + margin: 0; + padding: 0; + width: 100%; + max-width: 360px; + height: 78px; + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 8px; + overflow: hidden; + mask-image: linear-gradient(to bottom, transparent 0, black 28px); + -webkit-mask-image: linear-gradient(to bottom, transparent 0, black 28px); +} + +.status-log__item { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--color-primaryPalette-400); + text-align: left; + line-height: 1.35; + animation: status-log-enter 240ms ease-out; +} + +.status-log__item_done { + color: var(--color-primaryPalette-200); + font-size: 12px; +} + +.status-log__item_current { + color: var(--color-primaryPalette-800); + font-weight: 500; +} + +@media (prefers-color-scheme: dark) { + .status-log__item_done { + color: var(--color-primaryPalette-500); + } + .status-log__item_current { + color: var(--color-primaryPalette-50); + } +} + +.status-log__icon { + flex-shrink: 0; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.status-log__check { + color: var(--color-primaryPalette-200); + font-size: 13px; + font-weight: 700; + line-height: 1; +} + +@media (prefers-color-scheme: dark) { + .status-log__check { + color: var(--color-primaryPalette-500); + } +} + +.status-log__spinner { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid var(--color-accentedPalette-200); + border-top-color: var(--color-accentedPalette-500); + animation: tiny-spin 0.8s linear infinite; +} + +@keyframes tiny-spin { + to { transform: rotate(360deg); } +} + +@keyframes status-log-enter { + from { transform: translateY(-6px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.card__status { + margin: 0; + min-height: 20px; + font-size: 13px; + color: var(--color-primaryPalette-500); + text-align: center; + line-height: 1.4; + max-width: 360px; +} + +.card__status_error { + color: var(--color-warnPalette-500); +} + +@media (prefers-color-scheme: dark) { + .card__status { + color: var(--color-primaryPalette-200); + } } diff --git a/frontend/src/app/components/auto-configure/auto-configure.component.html b/frontend/src/app/components/auto-configure/auto-configure.component.html index 7e818bf08..2c07d23f5 100644 --- a/frontend/src/app/components/auto-configure/auto-configure.component.html +++ b/frontend/src/app/components/auto-configure/auto-configure.component.html @@ -1,15 +1,42 @@
-
+
-

Configuring your database

-

We're analyzing your structure and applying the best settings. This is running in the background.

-
-
+
+ + + +
+

{{ done() ? 'Your database is ready' : 'Configuring your database' }}

+
+

We're analyzing your structure and applying the best settings. This is running in the background.

+
+ {{ tablesConfigured().size }} {{ tablesConfigured().size === 1 ? 'table' : 'tables' }} + {{ widgetsCount() }} {{ widgetsCount() === 1 ? 'widget' : 'widgets' }} + {{ settingsCount() }} {{ settingsCount() === 1 ? 'setting' : 'settings' }} +
+

All set — opening your dashboard…

+
+ +
    +
  • + + {{ m }} +
  • +
+ +

{{ errorText() }}

+