From ec55222173ee72a71f35f3605460ee94c44198e4 Mon Sep 17 00:00:00 2001 From: Kevin McGahey Date: Sat, 3 Oct 2026 23:13:51 +0000 Subject: [PATCH 01/15] feat(trial): self-service Docker trial banner, lockout page, guard and interceptor Implements TRIAL-DESIGN.md section 5 for the on-prem 30-day Docker trial. Dormant unless /api/v2/system/environment carries the df-trial block. - Types: TrialInfo (camelCase of the 4.4 wire block) on platform.trial and top-level trial (pre-login); platform.installType. - DfTrialService: trial$ from environment$, lock$/expired$/isLocked with reason + context, markLocked()/clearLock(), remaining$ 1 s countdown from expiresAt corrected by the server-reported secondsRemaining, severity info > warnDays warning > criticalDays critical. - DfTrialBannerComponent: fixed-top + spacer (same pattern as the engagement banner, which now hides on trial instances), section-9 copy with pluralised units, mailto + demo CTAs, --df-accent/--df-warning/--df-danger tokens, visible pre-login, hidden while locked. - DfTrialExpiredComponent (route trial-expired): "Thank you for testing DreamFactory." + ended-on date for TRIAL_EXPIRED, token-required variant with portal CTA for TRIAL_TOKEN_INVALID; tokenized, offline-safe. The shell drops side-nav/toolbar while locked (extends the disableUi branch). - errorInterceptor: 402/403 with context.reason TRIAL_EXPIRED | TRIAL_TOKEN_INVALID -> markLocked + navigate, checked before the 401 branch and for silent requests too; never clears the token. AppError now carries the envelope context. - trialGuard first on every guarded route (+ auth, license-expired): locked -> trial-expired, active on trial-expired -> home. - APP_INITIALIZER resolves on a locked instance (addendum 12.5); no retry of a deterministic lock. Browser-side updates.dreamfactory.com check and the red subscription strip are skipped when trial info is present. - System Info: install type, trial status/started/expires/days/id. - i18n trial.* keys; jest specs (85 tests) added to the CI list. Claude-Session: https://claude.ai/code/session_01FptG8FPhcAoo6yqakZwqUf --- jest.config.ci.js | 12 + .../df-system-info.component.html | 28 ++ .../df-system-info.component.ts | 11 +- src/app/app.component.html | 6 +- src/app/app.component.ts | 20 ++ src/app/routes.ts | 30 +- .../df-engagement-banner.component.spec.ts | 111 +++++++ .../df-engagement-banner.component.ts | 7 +- .../df-side-nav/df-side-nav.component.html | 5 +- .../df-side-nav/df-side-nav.component.ts | 8 +- .../df-trial-banner.component.html | 53 ++++ .../df-trial-banner.component.scss | 155 ++++++++++ .../df-trial-banner.component.spec.ts | 204 +++++++++++++ .../df-trial-banner.component.ts | 69 +++++ .../df-trial-expired.component.html | 74 +++++ .../df-trial-expired.component.scss | 145 +++++++++ .../df-trial-expired.component.spec.ts | 154 ++++++++++ .../df-trial-expired.component.ts | 71 +++++ src/app/shared/guards/license.guard.ts | 12 + src/app/shared/guards/trial.guard.spec.ts | 63 ++++ src/app/shared/guards/trial.guard.ts | 34 +++ .../interceptors/error.interceptor.spec.ts | 183 +++++++++++ .../shared/interceptors/error.interceptor.ts | 29 +- .../df-license-initializer.service.spec.ts | 104 +++++++ .../df-license-initializer.service.ts | 7 + .../services/df-system-config-data.service.ts | 10 +- .../shared/services/df-trial.service.spec.ts | 285 ++++++++++++++++++ src/app/shared/services/df-trial.service.ts | 219 ++++++++++++++ src/app/shared/types/routes.ts | 1 + src/app/shared/types/system.ts | 10 + src/app/shared/types/trial.ts | 78 +++++ src/app/shared/utilities/app-error.spec.ts | 30 ++ src/app/shared/utilities/app-error.ts | 15 + src/app/shared/utilities/route.ts | 1 + src/app/shared/utilities/trial.spec.ts | 230 ++++++++++++++ src/app/shared/utilities/trial.ts | 178 +++++++++++ src/assets/i18n/en.json | 30 ++ src/assets/i18n/systemInfo/en.json | 14 +- src/main.ts | 30 +- 39 files changed, 2703 insertions(+), 23 deletions(-) create mode 100644 src/app/shared/components/df-engagement-banner/df-engagement-banner.component.spec.ts create mode 100644 src/app/shared/components/df-trial-banner/df-trial-banner.component.html create mode 100644 src/app/shared/components/df-trial-banner/df-trial-banner.component.scss create mode 100644 src/app/shared/components/df-trial-banner/df-trial-banner.component.spec.ts create mode 100644 src/app/shared/components/df-trial-banner/df-trial-banner.component.ts create mode 100644 src/app/shared/components/df-trial-expired/df-trial-expired.component.html create mode 100644 src/app/shared/components/df-trial-expired/df-trial-expired.component.scss create mode 100644 src/app/shared/components/df-trial-expired/df-trial-expired.component.spec.ts create mode 100644 src/app/shared/components/df-trial-expired/df-trial-expired.component.ts create mode 100644 src/app/shared/guards/trial.guard.spec.ts create mode 100644 src/app/shared/guards/trial.guard.ts create mode 100644 src/app/shared/interceptors/error.interceptor.spec.ts create mode 100644 src/app/shared/services/df-license-initializer.service.spec.ts create mode 100644 src/app/shared/services/df-trial.service.spec.ts create mode 100644 src/app/shared/services/df-trial.service.ts create mode 100644 src/app/shared/types/trial.ts create mode 100644 src/app/shared/utilities/trial.spec.ts create mode 100644 src/app/shared/utilities/trial.ts diff --git a/jest.config.ci.js b/jest.config.ci.js index df1309a94..f007653be 100644 --- a/jest.config.ci.js +++ b/jest.config.ci.js @@ -48,5 +48,17 @@ module.exports = { '/src/app/adf-ai-usage/utils/task-estimate.spec.ts', '/src/app/shared/components/df-section-landing/df-section-landing.component.spec.ts', '/src/app/adf-home/df-welcome-page/df-generate-api-card/df-generate-api-card.component.spec.ts', + // 2026-10-03 self-service Docker trial (TRIAL-DESIGN.md section 5): + // lock detection, countdown service, guard, interceptor branch, banner, + // expired page, engagement-banner gating, license-check skip. + '/src/app/shared/utilities/trial.spec.ts', + '/src/app/shared/utilities/app-error.spec.ts', + '/src/app/shared/services/df-trial.service.spec.ts', + '/src/app/shared/services/df-license-initializer.service.spec.ts', + '/src/app/shared/guards/trial.guard.spec.ts', + '/src/app/shared/interceptors/error.interceptor.spec.ts', + '/src/app/shared/components/df-trial-banner/df-trial-banner.component.spec.ts', + '/src/app/shared/components/df-trial-expired/df-trial-expired.component.spec.ts', + '/src/app/shared/components/df-engagement-banner/df-engagement-banner.component.spec.ts', ], }; diff --git a/src/app/adf-config/df-system-info/df-system-info.component.html b/src/app/adf-config/df-system-info/df-system-info.component.html index d1c71be86..72b52dc81 100644 --- a/src/app/adf-config/df-system-info/df-system-info.component.html +++ b/src/app/adf-config/df-system-info/df-system-info.component.html @@ -60,6 +60,34 @@

DreamFactory {{ 'systemInfo.instance.instance' | transloco }}

{{ 'systemInfo.instance.demo' | transloco }}: {{ environment.platform?.isTrial }} +
  • + {{ 'systemInfo.instance.installType' | transloco }}: + {{ environment.platform?.installType }} +
  • + +
  • + {{ 'systemInfo.instance.trialStatus' | transloco }}: + {{ 'systemInfo.instance.trialStatusValue.' + t.status | transloco }} +
  • +
  • + {{ 'systemInfo.instance.trialStarted' | transloco }}: + {{ t.startedAt | date: 'medium' }} +
  • +
  • + {{ 'systemInfo.instance.trialExpires' | transloco }}: + {{ t.expiresAt | date: 'medium' }} +
  • +
  • + {{ 'systemInfo.instance.trialDaysRemaining' | transloco }}: + {{ t.daysRemaining }} +
  • +
  • + {{ 'systemInfo.instance.trialId' | transloco }}: + {{ t.trialId }} +
  • +
  • DreamFactory {{ 'systemInfo.instance.instanceId' | transloco }}: diff --git a/src/app/adf-config/df-system-info/df-system-info.component.ts b/src/app/adf-config/df-system-info/df-system-info.component.ts index 2c8ecf8e0..50b57bdf7 100644 --- a/src/app/adf-config/df-system-info/df-system-info.component.ts +++ b/src/app/adf-config/df-system-info/df-system-info.component.ts @@ -1,4 +1,4 @@ -import { AsyncPipe, NgFor, NgIf } from '@angular/common'; +import { AsyncPipe, DatePipe, NgFor, NgIf } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { DfBreakpointService } from '../../shared/services/df-breakpoint.service'; import { TranslocoPipe } from '@ngneat/transloco'; @@ -6,6 +6,8 @@ import { UntilDestroy } from '@ngneat/until-destroy'; import { DfSystemConfigDataService } from 'src/app/shared/services/df-system-config-data.service'; import { CheckResponse } from 'src/app/shared/types/check'; import { DfLicenseCheckService } from 'src/app/shared/services/df-license-check.service'; +import { TrialInfo } from 'src/app/shared/types/trial'; +import { trialFromEnvironment } from 'src/app/shared/utilities/trial'; @UntilDestroy({ checkProperties: true }) @Component({ @@ -13,7 +15,7 @@ import { DfLicenseCheckService } from 'src/app/shared/services/df-license-check. templateUrl: './df-system-info.component.html', styleUrls: ['./df-system-info.component.scss'], standalone: true, - imports: [AsyncPipe, NgFor, TranslocoPipe, NgIf], + imports: [AsyncPipe, DatePipe, NgFor, TranslocoPipe, NgIf], }) export class DfSystemInfoComponent implements OnInit { environment = this.systemConfigDataService.environment; @@ -25,6 +27,11 @@ export class DfSystemInfoComponent implements OnInit { private licenseCheckService: DfLicenseCheckService ) {} + /** Docker trial block (platform.trial, or top-level pre-login); null otherwise. */ + get trial(): TrialInfo | null { + return trialFromEnvironment(this.environment); + } + ngOnInit() { // Use the existing license check result instead of triggering a new one this.licenseCheckService.licenseCheck$.subscribe(licenseCheck => { diff --git a/src/app/app.component.html b/src/app/app.component.html index 2ab41f272..c944094c7 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,6 +1,10 @@ + + *ngIf=" + (licenseCheck$ | async)?.disableUi === 'true' || (trialLocked$ | async); + else enabled + "> diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 019c25ba4..80eeee3d4 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -12,7 +12,9 @@ import { } from '@angular/router'; import { DfSideNavComponent } from './shared/components/df-side-nav/df-side-nav.component'; import { DfEngagementBannerComponent } from './shared/components/df-engagement-banner/df-engagement-banner.component'; +import { DfTrialBannerComponent } from './shared/components/df-trial-banner/df-trial-banner.component'; import { DfLicenseCheckService } from './shared/services/df-license-check.service'; +import { DfTrialService } from './shared/services/df-trial.service'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { AuthService } from './shared/services/auth.service'; import { LoggingService } from './shared/services/logging.service'; @@ -32,6 +34,7 @@ import { filter } from 'rxjs'; imports: [ DfSideNavComponent, DfEngagementBannerComponent, + DfTrialBannerComponent, RouterOutlet, NgIf, AsyncPipe, @@ -41,10 +44,17 @@ export class AppComponent implements OnInit { title = 'df-admin-interface'; activeSpinner$ = this.loadingSpinnerService.active; licenseCheck$ = this.licenseCheckService.licenseCheck$; + /** + * True once the instance answered 402 TRIAL_EXPIRED / 403 TRIAL_TOKEN_INVALID + * (or environment reports a locked status). The shell drops side-nav and + * toolbar exactly like the license disableUi branch. + */ + trialLocked$ = this.trialService.expired$; constructor( private loadingSpinnerService: DfLoadingSpinnerService, private licenseCheckService: DfLicenseCheckService, + private trialService: DfTrialService, private authService: AuthService, private router: Router, private route: ActivatedRoute, @@ -101,6 +111,16 @@ export class AppComponent implements OnInit { } } }); + + // Docker trial lockout (402 TRIAL_EXPIRED / 403 TRIAL_TOKEN_INVALID): + // errorInterceptor and trialGuard already route there; this covers the + // fresh-load case where the lock was recorded during APP_INITIALIZER, + // before the router had anything to navigate. + this.trialLocked$.pipe(untilDestroyed(this)).subscribe(locked => { + if (locked && !this.router.url.includes(ROUTES.TRIAL_EXPIRED)) { + this.router.navigate([ROUTES.TRIAL_EXPIRED]); + } + }); } private watchRouteLoading() { diff --git a/src/app/routes.ts b/src/app/routes.ts index cc444d09b..ea5fbb430 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -54,6 +54,7 @@ import { systemEventsResolver } from './adf-services/resolvers/system-events.res import { checkStatusResolver } from './adf-config/resolvers/df-check-status.resolver'; import { licenseGuard } from './shared/guards/license.guard'; import { globalLicenseGuard } from './shared/guards/global-license.guard'; +import { trialGuard } from './shared/guards/trial.guard'; import { errorGuard } from './shared/guards/error.guard'; import { paywallGuard } from './shared/guards/paywall.guard'; import { rootAdminGuard } from './shared/guards/admin.guard'; @@ -76,13 +77,13 @@ export const routes: Routes = [ { path: ROUTES.AUTH, children: AuthRoutes, - canActivate: [notLoggedInGuard], + canActivate: [trialGuard, notLoggedInGuard], providers: [provideTranslocoScope('userManagement')], }, { path: ROUTES.HOME, children: HomeRoutes, - canActivate: [loggedInGuard, licenseGuard, globalLicenseGuard], + canActivate: [trialGuard, loggedInGuard, licenseGuard, globalLicenseGuard], providers: [provideTranslocoScope('home')], }, { @@ -91,7 +92,18 @@ export const routes: Routes = [ import('./shared/components/df-license-expired/df-license-expired.component').then( m => m.DfLicenseExpiredComponent ), - canActivate: [licenseGuard], + canActivate: [trialGuard, licenseGuard], + }, + { + // Self-service Docker trial lockout (402 TRIAL_EXPIRED / 403 + // TRIAL_TOKEN_INVALID). trialGuard sends every other route here while + // locked and sends this one home while active. + path: ROUTES.TRIAL_EXPIRED, + loadComponent: () => + import('./shared/components/df-trial-expired/df-trial-expired.component').then( + m => m.DfTrialExpiredComponent + ), + canActivate: [trialGuard], }, { path: ROUTES.API_BUILDER, @@ -344,7 +356,7 @@ export const routes: Routes = [ providers: [provideTranslocoScope('dataExplorer')], }, ], - canActivate: [loggedInGuard, licenseGuard, globalLicenseGuard], + canActivate: [trialGuard, loggedInGuard, licenseGuard, globalLicenseGuard], }, { path: ROUTES.API_SECURITY, @@ -427,7 +439,7 @@ export const routes: Routes = [ }, }, ], - canActivate: [loggedInGuard, licenseGuard, globalLicenseGuard], + canActivate: [trialGuard, loggedInGuard, licenseGuard, globalLicenseGuard], }, { path: ROUTES.SYSTEM_SETTINGS, @@ -634,7 +646,7 @@ export const routes: Routes = [ providers: [provideTranslocoScope('services')], }, ], - canActivate: [loggedInGuard, licenseGuard, globalLicenseGuard], + canActivate: [trialGuard, loggedInGuard, licenseGuard, globalLicenseGuard], }, { path: ROUTES.ADMIN_SETTINGS, @@ -923,7 +935,7 @@ export const routes: Routes = [ providers: [provideTranslocoScope('files')], }, ], - canActivate: [loggedInGuard, licenseGuard, globalLicenseGuard], + canActivate: [trialGuard, loggedInGuard, licenseGuard, globalLicenseGuard], }, { path: ROUTES.AI, @@ -996,7 +1008,7 @@ export const routes: Routes = [ }, }, ], - canActivate: [loggedInGuard, licenseGuard, globalLicenseGuard], + canActivate: [trialGuard, loggedInGuard, licenseGuard, globalLicenseGuard], providers: [provideTranslocoScope('services')], }, { @@ -1006,7 +1018,7 @@ export const routes: Routes = [ m => m.DfProfileComponent ), resolve: { data: profileResolver }, - canActivate: [loggedInGuard, licenseGuard, globalLicenseGuard], + canActivate: [trialGuard, loggedInGuard, licenseGuard, globalLicenseGuard], providers: [ DfProfileService, DfPasswordService, diff --git a/src/app/shared/components/df-engagement-banner/df-engagement-banner.component.spec.ts b/src/app/shared/components/df-engagement-banner/df-engagement-banner.component.spec.ts new file mode 100644 index 000000000..5e4e40e4f --- /dev/null +++ b/src/app/shared/components/df-engagement-banner/df-engagement-banner.component.spec.ts @@ -0,0 +1,111 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BehaviorSubject } from 'rxjs'; +import { TranslocoService, provideTransloco } from '@ngneat/transloco'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { TranslocoHttpLoader } from 'src/transloco-loader'; +import { DfEngagementBannerComponent } from './df-engagement-banner.component'; +import { DfSystemConfigDataService } from '../../services/df-system-config-data.service'; +import { Environment } from '../../types/system'; +import { TrialInfo } from '../../types/trial'; + +const BASE_ENV: Environment = { + authentication: { + allowOpenRegistration: false, + openRegEmailServiceId: 0, + allowForeverSessions: false, + loginAttribute: 'email', + adldap: [], + oauth: [], + saml: [], + }, + server: { host: '', machine: '', release: '', serverOs: '', version: '' }, +}; + +const TRIAL: TrialInfo = { + status: 'active', + trialId: 'trl_01TEST', + email: null, + plan: 'gold-trial', + startedAt: null, + expiresAt: '2026-11-02T20:00:00+00:00', + daysRemaining: 30, + secondsRemaining: 30 * 86400, + warnDays: 7, + criticalDays: 3, + contactEmail: 'sales@dreamfactory.com', + demoUrl: 'https://www.dreamfactory.com/demo', + portalUrl: 'https://portal.dreamfactory.com', + version: '7.7.1', +}; + +describe('DfEngagementBannerComponent', () => { + let fixture: ComponentFixture; + const environment$ = new BehaviorSubject(BASE_ENV); + + const banner = () => fixture.debugElement.query(By.css('.engagement-banner')); + + beforeEach(() => { + environment$.next(BASE_ENV); + TestBed.configureTestingModule({ + imports: [DfEngagementBannerComponent, HttpClientTestingModule], + providers: [ + provideTransloco({ + config: { defaultLang: 'en', availableLangs: ['en'] }, + loader: TranslocoHttpLoader, + }), + TranslocoService, + { + provide: DfSystemConfigDataService, + useValue: { environment$: environment$.asObservable() }, + }, + ], + }); + fixture = TestBed.createComponent(DfEngagementBannerComponent); + }); + + it('shows for Open Source installs', () => { + environment$.next({ + ...BASE_ENV, + platform: { license: 'OPEN SOURCE' } as Environment['platform'], + }); + fixture.detectChanges(); + expect(banner()).not.toBeNull(); + }); + + it('shows for the legacy hosted isTrial flag without a trial block', () => { + environment$.next({ + ...BASE_ENV, + platform: { license: 'GOLD', isTrial: true } as Environment['platform'], + }); + fixture.detectChanges(); + expect(banner()).not.toBeNull(); + }); + + it('hides on Docker trial instances so it never stacks under ', () => { + environment$.next({ + ...BASE_ENV, + platform: { + license: 'GOLD', + isTrial: true, + trial: TRIAL, + } as Environment['platform'], + }); + fixture.detectChanges(); + expect(banner()).toBeNull(); + + // pre-login shape: top-level trial, no platform + environment$.next({ ...BASE_ENV, trial: TRIAL }); + fixture.detectChanges(); + expect(banner()).toBeNull(); + }); + + it('hides for paid licenses', () => { + environment$.next({ + ...BASE_ENV, + platform: { license: 'GOLD', isTrial: false } as Environment['platform'], + }); + fixture.detectChanges(); + expect(banner()).toBeNull(); + }); +}); diff --git a/src/app/shared/components/df-engagement-banner/df-engagement-banner.component.ts b/src/app/shared/components/df-engagement-banner/df-engagement-banner.component.ts index 65fb2b7e6..786212745 100644 --- a/src/app/shared/components/df-engagement-banner/df-engagement-banner.component.ts +++ b/src/app/shared/components/df-engagement-banner/df-engagement-banner.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { DfSystemConfigDataService } from '../../services/df-system-config-data.service'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; import { TranslocoModule } from '@ngneat/transloco'; +import { trialFromEnvironment } from '../../utilities/trial'; @UntilDestroy({ checkProperties: true }) @Component({ @@ -25,8 +26,12 @@ export class DfEngagementBannerComponent implements OnInit { .subscribe(environment => { const license = environment.platform?.license?.toUpperCase(); const isTrial = environment.platform?.isTrial ?? false; + // Docker trial instances render instead; both are + // position:fixed top:0, so never show the two together. + const hasTrialInfo = trialFromEnvironment(environment) !== null; - this.showBanner = license === 'OPEN SOURCE' || isTrial; + this.showBanner = + (license === 'OPEN SOURCE' || isTrial) && !hasTrialInfo; }); } diff --git a/src/app/shared/components/df-side-nav/df-side-nav.component.html b/src/app/shared/components/df-side-nav/df-side-nav.component.html index ebc8fc219..7ba4ba18d 100644 --- a/src/app/shared/components/df-side-nav/df-side-nav.component.html +++ b/src/app/shared/components/df-side-nav/df-side-nav.component.html @@ -80,8 +80,9 @@
    {{ 'licenseExpired.header' | transloco }} diff --git a/src/app/shared/components/df-side-nav/df-side-nav.component.ts b/src/app/shared/components/df-side-nav/df-side-nav.component.ts index 2db6f6a5b..64a6c24e0 100644 --- a/src/app/shared/components/df-side-nav/df-side-nav.component.ts +++ b/src/app/shared/components/df-side-nav/df-side-nav.component.ts @@ -35,6 +35,7 @@ import { TranslocoPipe, TranslocoService } from '@ngneat/transloco'; import { AsyncPipe, NgFor, NgIf, NgTemplateOutlet } from '@angular/common'; import { DfErrorService } from 'src/app/shared/services/df-error.service'; import { DfLicenseCheckService } from '../../services/df-license-check.service'; +import { DfTrialService } from '../../services/df-trial.service'; import { debounceTime, distinctUntilChanged, map, of, switchMap } from 'rxjs'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { DfSearchDialogComponent } from '../df-search-dialog/df-search-dialog.component'; @@ -87,6 +88,8 @@ export class DfSideNavComponent implements OnInit { nav: Array