From 960ad2a649a5595924ccf651e4b6d973f13a2ae6 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Fri, 16 Jan 2026 19:37:48 +0000 Subject: [PATCH 1/3] Add Cloudflare Turnstile captcha to registration form - Add TurnstileComponent for rendering captcha widget - Integrate captcha into registration form (SaaS builds only) - Add turnstileToken to NewAuthUser interface - Disable submit button until captcha is completed - Reset captcha widget on failed registration attempts Co-Authored-By: Claude Opus 4.5 --- .../registration/registration.component.css | 218 +++++++-------- .../registration/registration.component.html | 9 +- .../registration.component.spec.ts | 52 +++- .../registration/registration.component.ts | 258 ++++++++++-------- .../turnstile/turnstile.component.css | 5 + .../turnstile/turnstile.component.html | 1 + .../turnstile/turnstile.component.spec.ts | 123 +++++++++ .../turnstile/turnstile.component.ts | 95 +++++++ frontend/src/app/models/user.ts | 127 ++++----- frontend/src/app/types/turnstile.d.ts | 22 ++ .../src/environments/environment.saas-prod.ts | 17 +- frontend/src/environments/environment.saas.ts | 15 +- frontend/src/index.saas.html | 3 + 13 files changed, 644 insertions(+), 301 deletions(-) create mode 100644 frontend/src/app/components/ui-components/turnstile/turnstile.component.css create mode 100644 frontend/src/app/components/ui-components/turnstile/turnstile.component.html create mode 100644 frontend/src/app/components/ui-components/turnstile/turnstile.component.spec.ts create mode 100644 frontend/src/app/components/ui-components/turnstile/turnstile.component.ts create mode 100644 frontend/src/app/types/turnstile.d.ts diff --git a/frontend/src/app/components/registration/registration.component.css b/frontend/src/app/components/registration/registration.component.css index e24bc9413..af8e97ef4 100644 --- a/frontend/src/app/components/registration/registration.component.css +++ b/frontend/src/app/components/registration/registration.component.css @@ -1,202 +1,204 @@ .wrapper { - display: grid; - grid-template-columns: auto 62.5%; - grid-column-gap: 24px; - height: 100%; - padding: 40px 24px; + display: grid; + grid-template-columns: auto 62.5%; + grid-column-gap: 24px; + height: 100%; + padding: 40px 24px; } @media (width <= 600px) { - .wrapper { - display: flex; - flex-direction: column; - padding: 0; - } + .wrapper { + display: flex; + flex-direction: column; + padding: 0; + } } .registrationTitle { - font-weight: 700 !important; - margin-top: -4px !important; - margin-bottom: 32px !important; + font-weight: 700 !important; + margin-top: -4px !important; + margin-bottom: 32px !important; } .registrationTitle_mobile { - display: none; + display: none; } @media (width <= 600px) { - .registrationTitle_desktop { - display: none; - } + .registrationTitle_desktop { + display: none; + } - .registrationTitle_mobile { - display: initial; - } + .registrationTitle_mobile { + display: initial; + } } - - - .registrationTitle__emphasis { - color: var(--color-accentedPalette-500) + color: var(--color-accentedPalette-500); } .register-form-box { - display: flex; - flex-direction: column; - /* justify-content: space-between; */ - padding-bottom: 20px; + display: flex; + flex-direction: column; + /* justify-content: space-between; */ + padding-bottom: 20px; } @media (width <= 600px) { - .register-form-box { - height: 100%; - } + .register-form-box { + height: 100%; + } } .register-form { - display: flex; - flex-direction: column; - height: 100%; - margin: auto; - padding: 0 48px; - width: 496px; + display: flex; + flex-direction: column; + height: 100%; + margin: auto; + padding: 0 48px; + width: 496px; } @media (width <= 600px) { - .register-form { - padding: 40px 9vw 0; - width: 100%; - } + .register-form { + padding: 40px 9vw 0; + width: 100%; + } } .mat-h1 { - align-self: center; - margin-top: 20px; - font-weight: 500; - text-align: center; - width: 75%; + align-self: center; + margin-top: 20px; + font-weight: 500; + text-align: center; + width: 75%; } @media screen and (max-width: 600px) { - .mat-h1 { - margin-top: 32px; - margin-bottom: 32px; - width: 100%; - } + .mat-h1 { + margin-top: 32px; + margin-bottom: 32px; + width: 100%; + } } .divider { - position: relative; - background-color: #E8E8E8; - height: 1px; - margin: 24px 0; - width: 100% + position: relative; + background-color: #e8e8e8; + height: 1px; + margin: 24px 0; + width: 100%; } .divider__label { - position: absolute; - top: 50%; left: 50%; - transform: translate(-50%, -50%); - background-color: var(--mat-sidenav-content-background-color); - font-size: 14px; - padding: 0 16px; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: var(--mat-sidenav-content-background-color); + font-size: 14px; + padding: 0 16px; } @media screen and (max-width: 600px) { - #google_registration_button ::ng-deep iframe { - width: 100% !important; - } + #google_registration_button ::ng-deep iframe { + width: 100% !important; + } } .password-field { - margin-top: 8px; + margin-top: 8px; +} + +.turnstile-widget { + margin-top: 24px; } .submit-button { - margin-top: 64px; + margin-top: 40px; } .register-image-box { - display: flex; - justify-content: flex-end; - align-items: flex-end; - background-color: var(--image-box-bg); - border-radius: 12px; - height: calc(100vh - 120px); - overflow: hidden; - padding-top: 3vw; + display: flex; + justify-content: flex-end; + align-items: flex-end; + background-color: var(--image-box-bg); + border-radius: 12px; + height: calc(100vh - 120px); + overflow: hidden; + padding-top: 3vw; } @media (prefers-color-scheme: light) { - .register-image-box { - --image-box-bg: #E8F1EA; - } + .register-image-box { + --image-box-bg: #e8f1ea; + } } @media (prefers-color-scheme: dark) { - .register-image-box { - --image-box-bg: #636363; - } + .register-image-box { + --image-box-bg: #636363; + } } @media screen and (max-width: 600px) { - .register-image-box { - display: none; - } + .register-image-box { + display: none; + } } .password-visibility-button { - cursor: pointer; + cursor: pointer; } .register-image { - height: 85vh; - margin-bottom: -5px; + height: 85vh; + margin-bottom: -5px; } .register-form .agreement { - margin-top: auto !important; + margin-top: auto !important; } .link { - color: var(--color-accentedPalette-500); + color: var(--color-accentedPalette-500); } .register-form__github-button { - background-color: #24292f; - margin-top: 20px; - padding: 6px; - width: 400px; + background-color: #24292f; + margin-top: 20px; + padding: 6px; + width: 400px; } @media (width <= 600px) { - .register-form__github-button { - width: auto; - } + .register-form__github-button { + width: auto; + } } .register-form__github-button ::ng-deep .mdc-button__label { - display: flex; - justify-content: space-between; - width: 100%; + display: flex; + justify-content: space-between; + width: 100%; } .register-form__github-caption { - flex: 1 0 auto; - color: #fff; - margin-top: 2px; - text-align: center; + flex: 1 0 auto; + color: #fff; + margin-top: 2px; + text-align: center; } .register-form__github-icon { - --mat-outlined-button-icon-spacing: 0; - --mat-outlined-button-icon-offset: 0; + --mat-outlined-button-icon-spacing: 0; + --mat-outlined-button-icon-offset: 0; - height: 24px !important; - width: 24px !important; + height: 24px !important; + width: 24px !important; } .register-form__github-icon ::ng-deep svg path { - fill: #fff; + fill: #fff; } diff --git a/frontend/src/app/components/registration/registration.component.html b/frontend/src/app/components/registration/registration.component.html index ffa07c28c..294c98871 100644 --- a/frontend/src/app/components/registration/registration.component.html +++ b/frontend/src/app/components/registration/registration.component.html @@ -44,10 +44,17 @@

(onFieldChange)="updatePasswordField($event)"> + + +

diff --git a/frontend/src/app/components/registration/registration.component.spec.ts b/frontend/src/app/components/registration/registration.component.spec.ts index 41e7be1ec..f36c77485 100644 --- a/frontend/src/app/components/registration/registration.component.spec.ts +++ b/frontend/src/app/components/registration/registration.component.spec.ts @@ -1,5 +1,5 @@ import { provideHttpClient } from '@angular/common/http'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; import { MatIconTestingModule } from '@angular/material/icon/testing'; import { MatSnackBarModule } from '@angular/material/snack-bar'; @@ -36,6 +36,14 @@ describe('RegistrationComponent', () => { // @ts-expect-error global.window.google.accounts = jasmine.createSpyObj(['id']); global.window.google.accounts.id = jasmine.createSpyObj(['initialize', 'renderButton', 'prompt']); + + // Mock Turnstile + window.turnstile = { + render: jasmine.createSpy('render').and.returnValue('mock-widget-id'), + reset: jasmine.createSpy('reset'), + getResponse: jasmine.createSpy('getResponse'), + remove: jasmine.createSpy('remove'), + }; }); beforeEach(() => { @@ -45,11 +53,16 @@ describe('RegistrationComponent', () => { fixture.detectChanges(); }); + afterEach(() => { + delete window.turnstile; + }); + it('should create', () => { expect(component).toBeTruthy(); }); - it('should sign a user in', () => { + it('should sign a user in without turnstile token when not SaaS', () => { + component.isSaas = false; component.user = { email: 'john@smith.com', password: 'kK123456789', @@ -64,4 +77,39 @@ describe('RegistrationComponent', () => { }); expect(component.submitting).toBeFalse(); }); + + it('should include turnstile token in registration request when SaaS', () => { + component.isSaas = true; + component.user = { + email: 'john@smith.com', + password: 'kK123456789', + }; + component.turnstileToken = 'test-turnstile-token'; + + const fakeSignUpUser = spyOn(authService, 'signUpUser').and.returnValue(of()); + + component.registerUser(); + expect(fakeSignUpUser).toHaveBeenCalledOnceWith({ + email: 'john@smith.com', + password: 'kK123456789', + turnstileToken: 'test-turnstile-token', + }); + }); + + it('should set turnstileToken when onTurnstileToken is called', () => { + component.onTurnstileToken('new-token'); + expect(component.turnstileToken).toBe('new-token'); + }); + + it('should clear turnstileToken when onTurnstileError is called', () => { + component.turnstileToken = 'existing-token'; + component.onTurnstileError(); + expect(component.turnstileToken).toBeNull(); + }); + + it('should clear turnstileToken when onTurnstileExpired is called', () => { + component.turnstileToken = 'existing-token'; + component.onTurnstileExpired(); + expect(component.turnstileToken).toBeNull(); + }); }); diff --git a/frontend/src/app/components/registration/registration.component.ts b/frontend/src/app/components/registration/registration.component.ts index 59f94cbc5..fb5863bb6 100644 --- a/frontend/src/app/components/registration/registration.component.ts +++ b/frontend/src/app/components/registration/registration.component.ts @@ -1,127 +1,161 @@ -import { AfterViewInit, CUSTOM_ELEMENTS_SCHEMA, Component, NgZone, OnInit } from '@angular/core'; -import { AlertActionType, AlertType } from 'src/app/models/alert'; - -import { AlertComponent } from '../ui-components/alert/alert.component'; -import { Angulartics2, Angulartics2OnModule } from 'angulartics2'; -import { AuthService } from 'src/app/services/auth.service'; import { CommonModule } from '@angular/common'; -import { EmailValidationDirective } from 'src/app/directives/emailValidator.directive'; +import { AfterViewInit, Component, CUSTOM_ELEMENTS_SCHEMA, NgZone, OnInit, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; +import { Router } from '@angular/router'; +import { Angulartics2, Angulartics2OnModule } from 'angulartics2'; +import { EmailValidationDirective } from 'src/app/directives/emailValidator.directive'; +import { AlertActionType, AlertType } from 'src/app/models/alert'; import { NewAuthUser } from 'src/app/models/user'; +import { AuthService } from 'src/app/services/auth.service'; import { NotificationsService } from 'src/app/services/notifications.service'; -import { Router } from '@angular/router'; -import { UserPasswordComponent } from '../ui-components/user-password/user-password.component'; import { environment } from 'src/environments/environment'; +import { AlertComponent } from '../ui-components/alert/alert.component'; +import { TurnstileComponent } from '../ui-components/turnstile/turnstile.component'; +import { UserPasswordComponent } from '../ui-components/user-password/user-password.component'; @Component({ - selector: 'app-registration', - templateUrl: './registration.component.html', - styleUrls: ['./registration.component.css'], - imports: [ - CommonModule, - FormsModule, - MatFormFieldModule, - MatInputModule, - MatButtonModule, - MatIconModule, - EmailValidationDirective, - AlertComponent, - UserPasswordComponent, - Angulartics2OnModule - ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] + selector: 'app-registration', + templateUrl: './registration.component.html', + styleUrls: ['./registration.component.css'], + imports: [ + CommonModule, + FormsModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatIconModule, + EmailValidationDirective, + AlertComponent, + TurnstileComponent, + UserPasswordComponent, + Angulartics2OnModule, + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], }) export class RegistrationComponent implements OnInit, AfterViewInit { + @ViewChild(TurnstileComponent) turnstileWidget: TurnstileComponent; + + public isSaas = (environment as any).saas; + public user: NewAuthUser = { + email: '', + password: '', + }; + public submitting: boolean; + public turnstileToken: string | null = null; + public errors = { + 'User_with_this_email_is_already_registered.': 'User with this email is already registered', + 'GitHub_registration_failed._Please_contact_our_support_team.': + 'GitHub registration failed. Please contact our support team.', + }; + + constructor( + private ngZone: NgZone, + private angulartics2: Angulartics2, + public router: Router, + private _auth: AuthService, + private _notifications: NotificationsService, + ) {} + + ngOnInit(): void { + this.angulartics2.eventTrack.next({ + action: 'Reg: Registration page (component) is loaded', + }); + + const error = new URLSearchParams(location.search).get('error'); + if (error) + this._notifications.showAlert(AlertType.Error, this.errors[error] || error, [ + { + type: AlertActionType.Button, + caption: 'Dismiss', + action: (_id: number) => this._notifications.dismissAlert(), + }, + ]); + } + + ngAfterViewInit() { + //@ts-expect-error + gtag('event', 'conversion', { send_to: 'AW-419937947/auKoCOvwgoYDEJv9nsgB' }); + + //@ts-expect-error + google.accounts.id.initialize({ + client_id: '681163285738-e4l0lrv5vv7m616ucrfhnhso9r396lum.apps.googleusercontent.com', + callback: (authUser) => { + this.ngZone.run(() => { + this._auth.signUpWithGoogle(authUser.credential).subscribe(() => { + this.angulartics2.eventTrack.next({ + action: 'Reg: google register success', + }); + }); + }); + }, + }); + //@ts-expect-error + google.accounts.id.renderButton(document.getElementById('google_registration_button'), { + theme: 'filled_blue', + size: 'large', + width: 400, + text: 'signup_with', + }); + //@ts-expect-error + google.accounts.id.prompt(); + } + + updatePasswordField(updatedValue: string) { + this.user.password = updatedValue; + } + + registerUser() { + this.submitting = true; + + const userData: NewAuthUser = { + ...this.user, + ...(this.isSaas && this.turnstileToken ? { turnstileToken: this.turnstileToken } : {}), + }; + + this._auth.signUpUser(userData).subscribe( + () => { + this.angulartics2.eventTrack.next({ + action: 'Reg: sing up success', + }); + }, + (_error) => { + this.angulartics2.eventTrack.next({ + action: 'Reg: sing up unsuccessful', + }); + this.submitting = false; + this._resetTurnstile(); + }, + () => (this.submitting = false), + ); + } + + onTurnstileToken(token: string) { + this.turnstileToken = token; + } + + onTurnstileError() { + this.turnstileToken = null; + } + + onTurnstileExpired() { + this.turnstileToken = null; + } + + private _resetTurnstile(): void { + if (this.isSaas && this.turnstileWidget) { + this.turnstileWidget.reset(); + this.turnstileToken = null; + } + } - public isSaas = (environment as any).saas; - public user: NewAuthUser = { - email: '', - password: '' - }; - public submitting: boolean; - public errors = { - 'User_with_this_email_is_already_registered.': 'User with this email is already registered', - 'GitHub_registration_failed._Please_contact_our_support_team.': 'GitHub registration failed. Please contact our support team.', - } - - constructor( - private ngZone: NgZone, - private angulartics2: Angulartics2, - public router: Router, - private _auth: AuthService, - private _notifications: NotificationsService, - ) { - } - - ngOnInit(): void { - this.angulartics2.eventTrack.next({ - action: 'Reg: Registration page (component) is loaded' - }); - - const error = new URLSearchParams(location.search).get('error'); - if (error) this._notifications.showAlert(AlertType.Error, this.errors[error] || error, [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert() - } - ]); - } - - ngAfterViewInit() { - //@ts-expect-error - gtag('event', 'conversion', {'send_to': 'AW-419937947/auKoCOvwgoYDEJv9nsgB'}); - - //@ts-expect-error - google.accounts.id.initialize({ - client_id: "681163285738-e4l0lrv5vv7m616ucrfhnhso9r396lum.apps.googleusercontent.com", - callback: (authUser) => { - this.ngZone.run(() => { - this._auth.signUpWithGoogle(authUser.credential).subscribe(() => { - this.angulartics2.eventTrack.next({ - action: 'Reg: google register success' - }); - }); - }) - } - }); - //@ts-expect-error - google.accounts.id.renderButton( - document.getElementById("google_registration_button"), - { theme: "filled_blue", size: "large", width: 400, text: "signup_with" } - ); - //@ts-expect-error - google.accounts.id.prompt(); - } - - updatePasswordField(updatedValue: string) { - this.user.password = updatedValue; - } - - registerUser() { - this.submitting = true; - - this._auth.signUpUser(this.user) - .subscribe(() => { - this.angulartics2.eventTrack.next({ - action: 'Reg: sing up success' - }); - }, (_error) => { - this.angulartics2.eventTrack.next({ - action: 'Reg: sing up unsuccessful' - }); - this.submitting = false; - }, () => this.submitting = false) - } - - registerWithGithub() { - this._auth.signUpWithGithub(); - this.angulartics2.eventTrack.next({ - action: 'Reg: github register redirect' - }); - } + registerWithGithub() { + this._auth.signUpWithGithub(); + this.angulartics2.eventTrack.next({ + action: 'Reg: github register redirect', + }); + } } diff --git a/frontend/src/app/components/ui-components/turnstile/turnstile.component.css b/frontend/src/app/components/ui-components/turnstile/turnstile.component.css new file mode 100644 index 000000000..46f2ea33b --- /dev/null +++ b/frontend/src/app/components/ui-components/turnstile/turnstile.component.css @@ -0,0 +1,5 @@ +.turnstile-container { + display: flex; + justify-content: center; + margin: 16px 0; +} diff --git a/frontend/src/app/components/ui-components/turnstile/turnstile.component.html b/frontend/src/app/components/ui-components/turnstile/turnstile.component.html new file mode 100644 index 000000000..515fd4bd4 --- /dev/null +++ b/frontend/src/app/components/ui-components/turnstile/turnstile.component.html @@ -0,0 +1 @@ +

diff --git a/frontend/src/app/components/ui-components/turnstile/turnstile.component.spec.ts b/frontend/src/app/components/ui-components/turnstile/turnstile.component.spec.ts new file mode 100644 index 000000000..d3db60592 --- /dev/null +++ b/frontend/src/app/components/ui-components/turnstile/turnstile.component.spec.ts @@ -0,0 +1,123 @@ +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { TurnstileComponent } from './turnstile.component'; + +describe('TurnstileComponent', () => { + let component: TurnstileComponent; + let fixture: ComponentFixture; + let mockWidgetId: string; + + beforeEach(async () => { + mockWidgetId = 'mock-widget-id'; + + window.turnstile = { + render: jasmine.createSpy('render').and.returnValue(mockWidgetId), + reset: jasmine.createSpy('reset'), + getResponse: jasmine.createSpy('getResponse'), + remove: jasmine.createSpy('remove'), + }; + + await TestBed.configureTestingModule({ + imports: [TurnstileComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TurnstileComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + delete window.turnstile; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render turnstile widget on init', fakeAsync(() => { + fixture.detectChanges(); + tick(100); + + expect(window.turnstile?.render).toHaveBeenCalled(); + })); + + it('should emit tokenReceived when callback is triggered', fakeAsync(() => { + let receivedToken: string | null = null; + component.tokenReceived.subscribe((token: string) => { + receivedToken = token; + }); + + (window.turnstile?.render as jasmine.Spy).and.callFake((_container: any, options: any) => { + options.callback('test-token'); + return mockWidgetId; + }); + + fixture.detectChanges(); + tick(100); + + expect(receivedToken).toBe('test-token'); + })); + + it('should emit tokenError when error-callback is triggered', fakeAsync(() => { + let errorEmitted = false; + component.tokenError.subscribe(() => { + errorEmitted = true; + }); + + (window.turnstile?.render as jasmine.Spy).and.callFake((_container: any, options: any) => { + options['error-callback'](); + return mockWidgetId; + }); + + fixture.detectChanges(); + tick(100); + + expect(errorEmitted).toBeTrue(); + })); + + it('should emit tokenExpired when expired-callback is triggered', fakeAsync(() => { + let expiredEmitted = false; + component.tokenExpired.subscribe(() => { + expiredEmitted = true; + }); + + (window.turnstile?.render as jasmine.Spy).and.callFake((_container: any, options: any) => { + options['expired-callback'](); + return mockWidgetId; + }); + + fixture.detectChanges(); + tick(100); + + expect(expiredEmitted).toBeTrue(); + })); + + it('should reset the widget when reset() is called', fakeAsync(() => { + fixture.detectChanges(); + tick(100); + + component.reset(); + + expect(window.turnstile?.reset).toHaveBeenCalledWith(mockWidgetId); + })); + + it('should remove widget on destroy', fakeAsync(() => { + fixture.detectChanges(); + tick(100); + + component.ngOnDestroy(); + + expect(window.turnstile?.remove).toHaveBeenCalledWith(mockWidgetId); + })); + + it('should emit error if turnstile fails to load', fakeAsync(() => { + delete window.turnstile; + let errorEmitted = false; + component.tokenError.subscribe(() => { + errorEmitted = true; + }); + + fixture.detectChanges(); + tick(5100); + + expect(errorEmitted).toBeTrue(); + })); +}); diff --git a/frontend/src/app/components/ui-components/turnstile/turnstile.component.ts b/frontend/src/app/components/ui-components/turnstile/turnstile.component.ts new file mode 100644 index 000000000..73ff0cbef --- /dev/null +++ b/frontend/src/app/components/ui-components/turnstile/turnstile.component.ts @@ -0,0 +1,95 @@ +import { CommonModule } from '@angular/common'; +import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'; +import { environment } from 'src/environments/environment'; + +@Component({ + selector: 'app-turnstile', + templateUrl: './turnstile.component.html', + styleUrls: ['./turnstile.component.css'], + imports: [CommonModule], +}) +export class TurnstileComponent implements OnInit, OnDestroy { + @ViewChild('turnstileContainer', { static: true }) turnstileContainer: ElementRef; + + @Input() siteKey: string = (environment as any).turnstileSiteKey; + @Input() theme: 'light' | 'dark' | 'auto' = 'auto'; + + @Output() tokenReceived = new EventEmitter(); + @Output() tokenError = new EventEmitter(); + @Output() tokenExpired = new EventEmitter(); + + private widgetId: string | null = null; + private pollInterval: ReturnType | null = null; + private readonly MAX_POLL_ATTEMPTS = 50; + private readonly POLL_INTERVAL_MS = 100; + + ngOnInit(): void { + this._waitForTurnstileAndRender(); + } + + ngOnDestroy(): void { + this._clearPollInterval(); + this._removeWidget(); + } + + public reset(): void { + if (window.turnstile && this.widgetId) { + window.turnstile.reset(this.widgetId); + } + } + + private _waitForTurnstileAndRender(): void { + let attempts = 0; + + this.pollInterval = setInterval(() => { + attempts++; + + if (window.turnstile) { + this._clearPollInterval(); + this._renderWidget(); + return; + } + + if (attempts >= this.MAX_POLL_ATTEMPTS) { + this._clearPollInterval(); + console.error('Turnstile script failed to load'); + this.tokenError.emit(); + } + }, this.POLL_INTERVAL_MS); + } + + private _renderWidget(): void { + if (!window.turnstile || !this.turnstileContainer?.nativeElement) { + return; + } + + this.widgetId = window.turnstile.render(this.turnstileContainer.nativeElement, { + sitekey: this.siteKey, + callback: (token: string) => { + this.tokenReceived.emit(token); + }, + 'error-callback': () => { + this.tokenError.emit(); + }, + 'expired-callback': () => { + this.tokenExpired.emit(); + }, + theme: this.theme, + appearance: 'always', + }); + } + + private _removeWidget(): void { + if (window.turnstile && this.widgetId) { + window.turnstile.remove(this.widgetId); + this.widgetId = null; + } + } + + private _clearPollInterval(): void { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + } +} diff --git a/frontend/src/app/models/user.ts b/frontend/src/app/models/user.ts index 9ad3bae67..d64b74b77 100644 --- a/frontend/src/app/models/user.ts +++ b/frontend/src/app/models/user.ts @@ -2,99 +2,100 @@ import { CompanyMemberRole } from './company'; import { TablePermissions } from './table'; export interface NewAuthUser { - email: string, - password: string, + email: string; + password: string; + turnstileToken?: string; } export interface ExistingAuthUser { - email: string, - password: string, - companyId: string + email: string; + password: string; + companyId: string; } export interface UserGroup { - id: string, - title: string, - isMain: boolean, - users?: { - id: string, - isActive: boolean, - email: string, - createdAt?: string, - name: string, - is_2fa_enabled: boolean, - role: CompanyMemberRole - }[] + id: string; + title: string; + isMain: boolean; + users?: { + id: string; + isActive: boolean; + email: string; + createdAt?: string; + name: string; + is_2fa_enabled: boolean; + role: CompanyMemberRole; + }[]; } export interface UserGroupInfo { - group: UserGroup, - accessLevel: string + group: UserGroup; + accessLevel: string; } export interface GroupUser { - id: string, - createdAt: string, - gclid: string | null, - isActive: boolean, - stripeId: string, - email: string, + id: string; + createdAt: string; + gclid: string | null; + isActive: boolean; + stripeId: string; + email: string; } export enum SubscriptionPlans { - free = 'FREE_PLAN', - team = 'TEAM_PLAN', - enterprise = 'ENTERPRISE_PLAN', - teamAnnual = 'ANNUAL_TEAM_PLAN', - enterpriseAnnual = 'ANNUAL_ENTERPRISE_PLAN', + free = 'FREE_PLAN', + team = 'TEAM_PLAN', + enterprise = 'ENTERPRISE_PLAN', + teamAnnual = 'ANNUAL_TEAM_PLAN', + enterpriseAnnual = 'ANNUAL_ENTERPRISE_PLAN', } export enum RegistrationProvider { - Google = 'GOOGLE', - Github = 'GITHUB' + Google = 'GOOGLE', + Github = 'GITHUB', } export interface User { - id: string, - isActive: boolean, - email: string, - name?: string, - createdAt?: string, - portal_link: string, - subscriptionLevel: SubscriptionPlans, - is_2fa_enabled: boolean, - role: CompanyMemberRole, - externalRegistrationProvider: RegistrationProvider | null, - company: { - id: string, - } + id: string; + isActive: boolean; + email: string; + name?: string; + createdAt?: string; + portal_link: string; + subscriptionLevel: SubscriptionPlans; + is_2fa_enabled: boolean; + role: CompanyMemberRole; + externalRegistrationProvider: RegistrationProvider | null; + company: { + id: string; + }; } export enum AccessLevel { - None = 'none', - Readonly = 'readonly', - Edit = 'edit' + None = 'none', + Readonly = 'readonly', + Edit = 'edit', } export interface TablePermission { - tableName: string, - display_name: string, - accessLevel: TablePermissions + tableName: string; + display_name: string; + accessLevel: TablePermissions; } export interface Permissions { - connection: { - connectionId: string, - accessLevel: AccessLevel - }, - group: { - groupId: string, - accessLevel: AccessLevel - }, - tables: TablePermission[] + connection: { + connectionId: string; + accessLevel: AccessLevel; + }; + group: { + groupId: string; + accessLevel: AccessLevel; + }; + tables: TablePermission[]; } export interface ApiKey { - title: string, - id: string -} \ No newline at end of file + title: string; + id: string; +} diff --git a/frontend/src/app/types/turnstile.d.ts b/frontend/src/app/types/turnstile.d.ts new file mode 100644 index 000000000..15e4a2a50 --- /dev/null +++ b/frontend/src/app/types/turnstile.d.ts @@ -0,0 +1,22 @@ +export interface TurnstileOptions { + sitekey: string; + callback?: (token: string) => void; + 'error-callback'?: () => void; + 'expired-callback'?: () => void; + theme?: 'light' | 'dark' | 'auto'; + appearance?: 'always' | 'execute' | 'interaction-only'; + size?: 'normal' | 'compact'; +} + +export interface TurnstileInstance { + render: (container: string | HTMLElement, options: TurnstileOptions) => string; + reset: (widgetId?: string) => void; + getResponse: (widgetId?: string) => string | undefined; + remove: (widgetId?: string) => void; +} + +declare global { + interface Window { + turnstile?: TurnstileInstance; + } +} diff --git a/frontend/src/environments/environment.saas-prod.ts b/frontend/src/environments/environment.saas-prod.ts index b01db2531..3810d785f 100644 --- a/frontend/src/environments/environment.saas-prod.ts +++ b/frontend/src/environments/environment.saas-prod.ts @@ -1,9 +1,10 @@ export const environment = { - production: true, - saas: true, - apiRoot: "/api", - saasURL: "", - saasHostnames: ['app.rocketadmin.com', 'localhost', 'rocketadmin-dev.tail9f8b2.ts.net'], - stagingHost: "rocketadmin-dev.tail9f8b2.ts.net", // Tailscale host - version: '0.0.0' - }; + production: true, + saas: true, + apiRoot: '/api', + saasURL: '', + saasHostnames: ['app.rocketadmin.com', 'localhost', 'rocketadmin-dev.tail9f8b2.ts.net'], + stagingHost: 'rocketadmin-dev.tail9f8b2.ts.net', // Tailscale host + version: '0.0.0', + turnstileSiteKey: '0x4AAAAAACM2ZuNYhGhncig_', +}; diff --git a/frontend/src/environments/environment.saas.ts b/frontend/src/environments/environment.saas.ts index c1b1dd0f8..31925c35d 100644 --- a/frontend/src/environments/environment.saas.ts +++ b/frontend/src/environments/environment.saas.ts @@ -1,9 +1,10 @@ export const environment = { - saas: true, - production: false, - apiRoot: "/api", - saasURL: "", - saasHostnames: ['app.rocketadmin.com', 'localhost', 'rocketadmin-dev.tail9f8b2.ts.net'], - stagingHost: "rocketadmin-dev.tail9f8b2.ts.net", // Tailscale host - version: '0.0.0' + saas: true, + production: false, + apiRoot: '/api', + saasURL: '', + saasHostnames: ['app.rocketadmin.com', 'localhost', 'rocketadmin-dev.tail9f8b2.ts.net'], + stagingHost: 'rocketadmin-dev.tail9f8b2.ts.net', // Tailscale host + version: '0.0.0', + turnstileSiteKey: '1x00000000000000000000AA', // Test key - always passes }; diff --git a/frontend/src/index.saas.html b/frontend/src/index.saas.html index 58a8d8b85..294c7f2d0 100644 --- a/frontend/src/index.saas.html +++ b/frontend/src/index.saas.html @@ -152,6 +152,9 @@ } } + + +
Date: Fri, 16 Jan 2026 20:40:06 +0000 Subject: [PATCH 2/3] Configure SaaS environment for Tailscale dev server Co-Authored-By: Claude Opus 4.5 --- frontend/angular.json | 383 +++++++++--------- frontend/src/environments/environment.saas.ts | 4 +- 2 files changed, 185 insertions(+), 202 deletions(-) diff --git a/frontend/angular.json b/frontend/angular.json index 740de4c9a..4077ba201 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -1,202 +1,185 @@ { - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", - "version": 1, - "newProjectRoot": "projects", - "projects": { - "dissendium-v0": { - "projectType": "application", - "schematics": {}, - "root": "", - "sourceRoot": "src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular-devkit/build-angular:browser", - "options": { - "outputPath": "dist/dissendium-v0", - "index": "src/index.html", - "main": "src/main.ts", - "polyfills": "src/polyfills.ts", - "tsConfig": "tsconfig.app.json", - "assets": [ - "src/favicon.ico", - "src/assets", - "src/config.json", - { - "glob": "**/*", - "input": "./node_modules/monaco-editor/min", - "output": "./assets/monaco" - } - ], - "styles": [ - "src/custom-theme.scss", - "src/styles.scss" - ], - "stylePreprocessorOptions": { - "includePaths": ["node_modules/@brumeilde/ngx-theme/presets/material"] - }, - "scripts": [ - ], - "vendorChunk": true, - "extractLicenses": false, - "buildOptimizer": false, - "sourceMap": true, - "optimization": false, - "namedChunks": true - }, - "configurations": { - "production": { - "fileReplacements": [ - { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.prod.ts" - } - ], - "optimization": true, - "outputHashing": "all", - "sourceMap": true, - "namedChunks": false, - "extractLicenses": true, - "vendorChunk": false, - "buildOptimizer": true, - "budgets": [ - { - "type": "initial", - "maximumWarning": "2mb", - "maximumError": "5mb" - }, - { - "type": "anyComponentStyle", - "maximumWarning": "10kb", - "maximumError": "20kb" - } - ] - }, - "development": { - "fileReplacements": [ - { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.dev.ts" - } - ], - "optimization": false, - "outputHashing": "all", - "sourceMap": true, - "namedChunks": false, - "extractLicenses": true, - "vendorChunk": false, - "buildOptimizer": false - }, - "saas": { - "index": { - "input": "src/index.saas.html", - "output": "index.html" - }, - "fileReplacements": [ - { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.saas-prod.ts" - } - ] - }, - "saas-production": { - "index": { - "input": "src/index.saas.html", - "output": "index.html" - }, - "fileReplacements": [ - { - "replace": "src/environments/environment.ts", - "with": "src/environments/environment.saas-prod.ts" - } - ], - "optimization": true, - "outputHashing": "all", - "sourceMap": true, - "namedChunks": false, - "extractLicenses": true, - "vendorChunk": false, - "buildOptimizer": true, - "budgets": [ - { - "type": "initial", - "maximumWarning": "2mb", - "maximumError": "5mb" - }, - { - "type": "anyComponentStyle", - "maximumWarning": "10kb", - "maximumError": "20kb" - } - ] - } - }, - "defaultConfiguration": "" - }, - "serve": { - "builder": "@angular-devkit/build-angular:dev-server", - "options": { - "buildTarget": "dissendium-v0:build", - "host": "127.0.0.1", - "proxyConfig": "src/proxy.conf.json" - }, - "configurations": { - "production": { - "buildTarget": "dissendium-v0:build:production" - }, - "saas": { - "buildTarget": "dissendium-v0:build:saas" - }, - "development": { - "buildTarget": "dissendium-v0:build:development" - } - } - }, - "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", - "options": { - "buildTarget": "dissendium-v0:build" - } - }, - "test": { - "builder": "@angular-devkit/build-angular:karma", - "options": { - "main": "src/test.ts", - "polyfills": "src/polyfills.ts", - "tsConfig": "tsconfig.spec.json", - "karmaConfig": "karma.conf.js", - "assets": [ - "src/favicon.ico", - "src/assets", - "src/config.json" - ], - "styles": [ - "src/custom-theme.scss", - "src/styles.scss" - ], - "stylePreprocessorOptions": { - "includePaths": ["node_modules/@brumeilde/ngx-theme/presets/material"] - }, - "scripts": [] - } - }, - "lint": { - "builder": "@angular-devkit/build-angular:tslint", - "options": { - "tsConfig": [ - "tsconfig.app.json", - "tsconfig.spec.json", - "e2e/tsconfig.json" - ], - "exclude": [ - "**/node_modules/**" - ] - } - } - } - } - }, - "cli": { - "analytics": false - } + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "newProjectRoot": "projects", + "projects": { + "dissendium-v0": { + "projectType": "application", + "schematics": {}, + "root": "", + "sourceRoot": "src", + "prefix": "app", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist/dissendium-v0", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "tsconfig.app.json", + "assets": [ + "src/favicon.ico", + "src/assets", + "src/config.json", + { + "glob": "**/*", + "input": "./node_modules/monaco-editor/min", + "output": "./assets/monaco" + } + ], + "styles": ["src/custom-theme.scss", "src/styles.scss"], + "stylePreprocessorOptions": { + "includePaths": ["node_modules/@brumeilde/ngx-theme/presets/material"] + }, + "scripts": [], + "vendorChunk": true, + "extractLicenses": false, + "buildOptimizer": false, + "sourceMap": true, + "optimization": false, + "namedChunks": true + }, + "configurations": { + "production": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": true, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "10kb", + "maximumError": "20kb" + } + ] + }, + "development": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.dev.ts" + } + ], + "optimization": false, + "outputHashing": "all", + "sourceMap": true, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": false + }, + "saas": { + "index": { + "input": "src/index.saas.html", + "output": "index.html" + }, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.saas.ts" + } + ] + }, + "saas-production": { + "index": { + "input": "src/index.saas.html", + "output": "index.html" + }, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.saas-prod.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": true, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "10kb", + "maximumError": "20kb" + } + ] + } + }, + "defaultConfiguration": "" + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "buildTarget": "dissendium-v0:build", + "host": "127.0.0.1", + "proxyConfig": "src/proxy.conf.json" + }, + "configurations": { + "production": { + "buildTarget": "dissendium-v0:build:production" + }, + "saas": { + "buildTarget": "dissendium-v0:build:saas" + }, + "development": { + "buildTarget": "dissendium-v0:build:development" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "dissendium-v0:build" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "src/test.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "tsconfig.spec.json", + "karmaConfig": "karma.conf.js", + "assets": ["src/favicon.ico", "src/assets", "src/config.json"], + "styles": ["src/custom-theme.scss", "src/styles.scss"], + "stylePreprocessorOptions": { + "includePaths": ["node_modules/@brumeilde/ngx-theme/presets/material"] + }, + "scripts": [] + } + }, + "lint": { + "builder": "@angular-devkit/build-angular:tslint", + "options": { + "tsConfig": ["tsconfig.app.json", "tsconfig.spec.json", "e2e/tsconfig.json"], + "exclude": ["**/node_modules/**"] + } + } + } + } + }, + "cli": { + "analytics": false + } } diff --git a/frontend/src/environments/environment.saas.ts b/frontend/src/environments/environment.saas.ts index 31925c35d..0c61886f2 100644 --- a/frontend/src/environments/environment.saas.ts +++ b/frontend/src/environments/environment.saas.ts @@ -1,8 +1,8 @@ export const environment = { saas: true, production: false, - apiRoot: '/api', - saasURL: '', + apiRoot: 'https://rocketadmin-dev.tail9f8b2.ts.net/api', + saasURL: 'https://rocketadmin-dev.tail9f8b2.ts.net', saasHostnames: ['app.rocketadmin.com', 'localhost', 'rocketadmin-dev.tail9f8b2.ts.net'], stagingHost: 'rocketadmin-dev.tail9f8b2.ts.net', // Tailscale host version: '0.0.0', From 14dc773418302e001bf2a877269b08ddd30d78c6 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Fri, 16 Jan 2026 21:42:09 +0000 Subject: [PATCH 3/3] Add Cloudflare Turnstile captcha to invite member dialog Integrates Turnstile verification into the company member invitation flow for SaaS environments. The captcha widget only appears when running in SaaS mode and the token is passed to the backend which already supports verification. Co-Authored-By: Claude Opus 4.5 --- .../invite-member-dialog.component.html | 8 +- .../invite-member-dialog.component.ts | 163 ++- frontend/src/app/services/company.service.ts | 1045 +++++++++-------- 3 files changed, 640 insertions(+), 576 deletions(-) diff --git a/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.html b/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.html index e3d465600..299490e5e 100644 --- a/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.html +++ b/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.html @@ -70,10 +70,16 @@

Add member to {{company.name}} company

+ + diff --git a/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.ts b/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.ts index 5e295085a..04f75f9c0 100644 --- a/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.ts +++ b/frontend/src/app/components/company/invite-member-dialog/invite-member-dialog.component.ts @@ -1,78 +1,117 @@ -import { CompanyMemberRole } from 'src/app/models/company'; -import { CompanyService } from 'src/app/services/company.service'; -import { Component, Inject } from '@angular/core'; -import { Angulartics2 } from 'angulartics2'; +import { NgForOf, NgIf } from '@angular/common'; +import { Component, Inject, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; -import { MatButtonModule } from '@angular/material/button'; +import { MatMenuModule } from '@angular/material/menu'; import { MatSelectModule } from '@angular/material/select'; -import { NgForOf, NgIf } from '@angular/common'; +import { Angulartics2 } from 'angulartics2'; import { EmailValidationDirective } from 'src/app/directives/emailValidator.directive'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatIconModule } from '@angular/material/icon'; +import { CompanyMemberRole } from 'src/app/models/company'; +import { CompanyService } from 'src/app/services/company.service'; +import { environment } from 'src/environments/environment'; +import { TurnstileComponent } from '../../ui-components/turnstile/turnstile.component'; @Component({ - selector: 'app-invite-member-dialog', - templateUrl: './invite-member-dialog.component.html', - styleUrls: ['./invite-member-dialog.component.css'], - standalone: true, - imports: [ - NgIf, - NgForOf, - FormsModule, - MatDialogModule, - MatFormFieldModule, - MatInputModule, - MatSelectModule, - MatButtonModule, - MatMenuModule, - MatIconModule, - EmailValidationDirective - ] + selector: 'app-invite-member-dialog', + templateUrl: './invite-member-dialog.component.html', + styleUrls: ['./invite-member-dialog.component.css'], + standalone: true, + imports: [ + NgIf, + NgForOf, + FormsModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatButtonModule, + MatMenuModule, + MatIconModule, + EmailValidationDirective, + TurnstileComponent, + ], }) export class InviteMemberDialogComponent { - CompanyMemberRole = CompanyMemberRole; + @ViewChild(TurnstileComponent) turnstileWidget: TurnstileComponent; + + CompanyMemberRole = CompanyMemberRole; + + public isSaas = (environment as any).saas; + public turnstileToken: string | null = null; + public companyMemberEmail: string; + public companyMemberRole: CompanyMemberRole = CompanyMemberRole.Member; + public submitting: boolean = false; + public companyUsersGroup: string = null; + public groups: { + title: string; + groups: object[]; + }[] = []; + + public companyRolesName = { + ADMIN: 'Account Owner', + DB_ADMIN: 'System Admin', + USER: 'Member', + }; + + constructor( + @Inject(MAT_DIALOG_DATA) public company: any, + public dialogRef: MatDialogRef, + private _company: CompanyService, + private angulartics2: Angulartics2, + ) {} + + ngOnInit(): void { + this.groups = this.company.connections.sort((a, b) => a.isTestConnection - b.isTestConnection); + } - public companyMemberEmail: string; - public companyMemberRole: CompanyMemberRole = CompanyMemberRole.Member; - public submitting: boolean = false; - public companyUsersGroup: string = null; - public groups: { - title: string, - groups: object[] - }[] = []; + addCompanyMember() { + this.submitting = true; + this._company + .inviteCompanyMember( + this.company.id, + this.companyUsersGroup, + this.companyMemberEmail, + this.companyMemberRole, + this.isSaas ? this.turnstileToken : null, + ) + .subscribe( + () => { + this.angulartics2.eventTrack.next({ + action: 'Company: member is invited successfully', + }); - public companyRolesName = { - 'ADMIN': 'Account Owner', - 'DB_ADMIN': 'System Admin', - 'USER': 'Member' - } + this.submitting = false; + this.dialogRef.close(); + }, + () => { + this._resetTurnstile(); + }, + () => { + this.submitting = false; + }, + ); + } - constructor( - @Inject(MAT_DIALOG_DATA) public company: any, - public dialogRef: MatDialogRef, - private _company: CompanyService, - private angulartics2: Angulartics2, - ) { } + onTurnstileToken(token: string) { + this.turnstileToken = token; + } - ngOnInit(): void { - this.groups = this.company.connections.sort((a, b) => a.isTestConnection - b.isTestConnection); - } + onTurnstileError() { + this.turnstileToken = null; + } - addCompanyMember() { - this.submitting = true; - this._company.inviteCompanyMember(this.company.id, this.companyUsersGroup, this.companyMemberEmail, this.companyMemberRole) - .subscribe(() => { - this.angulartics2.eventTrack.next({ - action: 'Company: member is invited successfully', - }); + onTurnstileExpired() { + this.turnstileToken = null; + } - this.submitting = false; - this.dialogRef.close(); - }, - () => {}, - () => {this.submitting = false}); - } + private _resetTurnstile(): void { + if (this.isSaas && this.turnstileWidget) { + this.turnstileWidget.reset(); + this.turnstileToken = null; + } + } } diff --git a/frontend/src/app/services/company.service.ts b/frontend/src/app/services/company.service.ts index b41249550..9b68ffcc3 100644 --- a/frontend/src/app/services/company.service.ts +++ b/frontend/src/app/services/company.service.ts @@ -1,523 +1,542 @@ -import { AlertActionType, AlertType } from '../models/alert'; +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; import { BehaviorSubject, EMPTY } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; - +import { environment } from 'src/environments/environment'; +import { AlertActionType, AlertType } from '../models/alert'; import { CompanyMemberRole, SamlConfig } from '../models/company'; import { ConfigurationService } from './configuration.service'; -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; import { NotificationsService } from './notifications.service'; -import { environment } from 'src/environments/environment'; @Injectable({ - providedIn: 'root' + providedIn: 'root', }) export class CompanyService { - - public saasHostnames = (environment as any).saasHostnames; - - private company = new BehaviorSubject(''); - public cast = this.company.asObservable(); - - private companyTabTitleSubject: BehaviorSubject = new BehaviorSubject('Rocketadmin'); - - private companyLogo: string; - private companyFavicon: string; - public companyTabTitle: string; - - constructor( - private _http: HttpClient, - private _notifications: NotificationsService, - private _configuration: ConfigurationService - ) { } - - get whiteLabelSettings() { - return { - logo: this.companyLogo, - favicon: this.companyFavicon, - tabTitle: this.companyTabTitle, - }; - } - - getCurrentTabTitle() { - return this.companyTabTitleSubject.asObservable(); - } - - isCustomDomain() { - const domain = window.location.hostname; - return !this.saasHostnames?.includes(domain); - } - - fetchCompany() { - return this._http.get(`/company/my/full`) - .pipe( - map(res => res), - catchError((err) => { - console.log(err); - this._notifications.showAlert(AlertType.Error, {abstract: err.error.message, details: err.error.originalMessage}, [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert() - } - ]); - return EMPTY; - }) - ); - }; - - fetchCompanyMembers(companyId: string) { - return this._http.get(`/company/users/${companyId}`) - .pipe( - map(res => res), - catchError((err) => { - console.log(err); - this._notifications.showAlert(AlertType.Error, {abstract: err.error.message, details: err.error.originalMessage}, [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert() - } - ]); - return EMPTY; - }) - ); - }; - - fetchCompanyName(companyId: string) { - return this._http.get(`/company/name/${companyId}`) - .pipe( - map(res => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - updateCompanyName(companyId: string, name: string) { - return this._http.put(`/company/name/${companyId}`, {name}) - .pipe( - map(_res => this._notifications.showSuccessSnackbar('Company name has been updated.')), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - inviteCompanyMember(companyId: string, groupId: string, email: string, role: CompanyMemberRole) { - return this._http.put(`/company/user/${companyId}`, { - groupId, - email, - role - }) - .pipe( - map(() => { - this._notifications.showSuccessSnackbar(`Invitation link has been sent to ${email}.`); - this.company.next('invited'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - revokeInvitation(companyId: string, email: string) { - return this._http.put(`/company/invitation/revoke/${companyId}`, { email }) - .pipe( - map(() => { - this._notifications.showSuccessSnackbar(`Invitation has been revoked for ${email}.`); - this.company.next('revoked'); - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - removeCompanyMemder(companyId: string, userId:string, email: string, userName: string) { - return this._http.delete(`/company/${companyId}/user/${userId}`) - .pipe( - map(res => { - this._notifications.showSuccessSnackbar(`${userName || email} has been removed from company.`); - this.company.next('deleted'); - return res - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert(AlertType.Error, {abstract: err.error.message, details: err.error.originalMessage}, [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert() - } - ]); - return EMPTY; - }) - ); - } - - updateCompanyMemberRole(companyId: string, userId: string, role: CompanyMemberRole) { - return this._http.put(`/company/users/roles/${companyId}`, { users: [{userId, role}] }) - .pipe( - map(res => { - this._notifications.showSuccessSnackbar(`Company member role has been updated.`); - this.company.next('role'); - return res - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert(AlertType.Error, {abstract: err.error.message, details: err.error.originalMessage}, [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert() - } - ]); - return EMPTY; - }) - ); - } - - suspendCompanyMember(companyId: string, usersEmails: string[]) { - return this._http.put(`/company/users/suspend/${companyId}`, { usersEmails }) - .pipe( - map(res => { - this._notifications.showSuccessSnackbar(`Company member has been suspended.`); - this.company.next('suspended'); - return res - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert(AlertType.Error, {abstract: err.error.message, details: err.error.originalMessage}, [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert() - } - ]); - return EMPTY; - }) - ); - } - - restoreCompanyMember(companyId: string, usersEmails: string[]) { - return this._http.put(`/company/users/unsuspend/${companyId}`, { usersEmails }) - .pipe( - map(res => { - this._notifications.showSuccessSnackbar(`Company member has been restored.`); - this.company.next('unsuspended'); - return res - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert(AlertType.Error, {abstract: err.error.message, details: err.error.originalMessage}, [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert() - } - ]); - return EMPTY; - }) - ); - } - - updateShowTestConnections(displayMode: 'on' | 'off') { - return this._http.put(`/company/connections/display`, undefined, { params: { displayMode }}) - .pipe( - map(res => { - if (displayMode === 'on') { - this._notifications.showSuccessSnackbar('Test connections now are displayed to your company members.'); - } else { - this._notifications.showSuccessSnackbar('Test connections now are hidden from your company members.'); - } - this.company.next(''); - return res - }), - catchError((err) => { - console.log(err); - this._notifications.showAlert(AlertType.Error, {abstract: err.error.message, details: err.error.originalMessage}, [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert() - } - ]); - return EMPTY; - }) - ); - } - - getCustomDomain(companyId: string) { - const config = this._configuration.getConfig(); - - return this._http.get(config.saasURL + `/saas/custom-domain/${companyId}`) - .pipe( - map(res => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - createCustomDomain(companyId: string, hostname: string) { - const config = this._configuration.getConfig(); - - return this._http.post(config.saasURL + `/saas/custom-domain/register/${companyId}`, { hostname }) - .pipe( - map(res => { - // this._notifications.showAlert('Custom domain has been added.'); - this._notifications.showAlert(AlertType.Success, - { - abstract: `Now your admin panel is live on your own domain: ${hostname}`, - details: 'Check it out! If you have any issues or need help setting up your domain or CNAME record, please reach out to our support team.' - }, - [ - { - type: AlertActionType.Anchor, - caption: 'Open', - to: `https://${hostname}` - }, - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: (_id: number) => this._notifications.dismissAlert() - } - ] - ); - this.company.next('domain'); - return res - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - updateCustomDomain(companyId: string, hostname: string) { - const config = this._configuration.getConfig(); - - return this._http.put(config.saasURL + `/saas/custom-domain/update/${companyId}`, { hostname }) - .pipe( - map(res => { - this._notifications.showSuccessSnackbar('Custom domain has been updated.'); - this.company.next('domain'); - return res - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - deleteCustomDomain(companyId: string) { - const config = this._configuration.getConfig(); - - return this._http.delete(config.saasURL + `/saas/custom-domain/delete/${companyId}`) - .pipe( - map(res => { - this._notifications.showSuccessSnackbar('Custom domain has been removed.'); - this.company.next('domain'); - return res - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - uploadLogo(companyId: string, file: File) { - const formData = new FormData(); - formData.append('file', file); - - return this._http.post(`/company/logo/${companyId}`, formData) - .pipe( - map(res => { - this._notifications.showSuccessSnackbar('Logo has been updated. Please rerefresh the page to see the changes.'); - this.company.next('updated-white-label-settings'); - return res - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - removeLogo(companyId: string) { - return this._http.delete(`/company/logo/${companyId}`) - .pipe( - map(res => { - this._notifications.showSuccessSnackbar('Logo has been removed. Please rerefresh the page to see the changes.'); - this.company.next('updated-white-label-settings'); - return res - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - uploadFavicon(companyId: string, file: File) { - const formData = new FormData(); - formData.append('file', file); - - return this._http.post(`/company/favicon/${companyId}`, formData) - .pipe( - map(res => { - this._notifications.showSuccessSnackbar('Favicon has been updated. Please rerefresh the page to see the changes.'); - this.company.next('updated-white-label-settings'); - return res - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - removeFavicon(companyId: string) { - return this._http.delete(`/company/favicon/${companyId}`) - .pipe( - map(res => { - this._notifications.showSuccessSnackbar('Favicon has been removed. Please rerefresh the page to see the changes.'); - this.company.next('updated-white-label-settings'); - return res - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - updateTabTitle(companyId: string, tab_title: string) { - return this._http.post(`/company/tab-title/${companyId}`, {tab_title}) - .pipe( - map(res => { - this._notifications.showSuccessSnackbar('Tab title has been saved. Please rerefresh the page to see the changes.'); - this.company.next('updated-white-label-settings'); - return res - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - removeTabTitle(companyId: string) { - return this._http.delete(`/company/tab-title/${companyId}`) - .pipe( - map(res => { - this._notifications.showSuccessSnackbar('Tab title has been removed. Please rerefresh the page to see the changes.'); - this.company.next('updated-white-label-settings'); - return res - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - - getWhiteLabelProperties(companyId: string) { - return this._http.get(`/company/white-label-properties/${companyId}`) - .pipe( - map(res => { - if (res.logo?.image && res.logo.mimeType) { - this.companyLogo = `data:${res.logo.mimeType};base64,${res.logo.image}`; - } else { - this.companyLogo = null; - } - - if (res.favicon?.image && res.favicon.mimeType) { - this.companyFavicon = `data:${res.favicon.mimeType};base64,${res.favicon.image}`; - } else { - this.companyFavicon = null; - } - - if (res.tab_title) { - this.companyTabTitle = res.tab_title; - } else { - this.companyTabTitle = null; - } - - this.companyTabTitleSubject.next(res.tab_title); - - this.company.next(''); - - return { - logo: this.companyLogo, - favicon: this.companyFavicon, - tab_title: res.tab_title, - } - }), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - fetchSamlConfiguration(companyId: string) { - return this._http.get(`/saas/saml/company/full/${companyId}`) - .pipe( - map(res => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - createSamlConfiguration(companyId: string, config: SamlConfig) { - return this._http.post(`/saas/saml/company/${companyId}`, config) - .pipe( - map(res => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } - - updateSamlConfiguration(config: SamlConfig) { - return this._http.put(`/saas/saml/${config.id}`, config) - .pipe( - map(res => res), - catchError((err) => { - console.log(err); - this._notifications.showErrorSnackbar(err.error.message || err.message); - return EMPTY; - }) - ); - } + public saasHostnames = (environment as any).saasHostnames; + + private company = new BehaviorSubject(''); + public cast = this.company.asObservable(); + + private companyTabTitleSubject: BehaviorSubject = new BehaviorSubject('Rocketadmin'); + + private companyLogo: string; + private companyFavicon: string; + public companyTabTitle: string; + + constructor( + private _http: HttpClient, + private _notifications: NotificationsService, + private _configuration: ConfigurationService, + ) {} + + get whiteLabelSettings() { + return { + logo: this.companyLogo, + favicon: this.companyFavicon, + tabTitle: this.companyTabTitle, + }; + } + + getCurrentTabTitle() { + return this.companyTabTitleSubject.asObservable(); + } + + isCustomDomain() { + const domain = window.location.hostname; + return !this.saasHostnames?.includes(domain); + } + + fetchCompany() { + return this._http.get(`/company/my/full`).pipe( + map((res) => res), + catchError((err) => { + console.log(err); + this._notifications.showAlert( + AlertType.Error, + { abstract: err.error.message, details: err.error.originalMessage }, + [ + { + type: AlertActionType.Button, + caption: 'Dismiss', + action: (_id: number) => this._notifications.dismissAlert(), + }, + ], + ); + return EMPTY; + }), + ); + } + + fetchCompanyMembers(companyId: string) { + return this._http.get(`/company/users/${companyId}`).pipe( + map((res) => res), + catchError((err) => { + console.log(err); + this._notifications.showAlert( + AlertType.Error, + { abstract: err.error.message, details: err.error.originalMessage }, + [ + { + type: AlertActionType.Button, + caption: 'Dismiss', + action: (_id: number) => this._notifications.dismissAlert(), + }, + ], + ); + return EMPTY; + }), + ); + } + + fetchCompanyName(companyId: string) { + return this._http.get(`/company/name/${companyId}`).pipe( + map((res) => res), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + updateCompanyName(companyId: string, name: string) { + return this._http.put(`/company/name/${companyId}`, { name }).pipe( + map((_res) => this._notifications.showSuccessSnackbar('Company name has been updated.')), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + inviteCompanyMember( + companyId: string, + groupId: string, + email: string, + role: CompanyMemberRole, + turnstileToken?: string, + ) { + return this._http + .put(`/company/user/${companyId}`, { + groupId, + email, + role, + ...(turnstileToken ? { turnstileToken } : {}), + }) + .pipe( + map(() => { + this._notifications.showSuccessSnackbar(`Invitation link has been sent to ${email}.`); + this.company.next('invited'); + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + revokeInvitation(companyId: string, email: string) { + return this._http.put(`/company/invitation/revoke/${companyId}`, { email }).pipe( + map(() => { + this._notifications.showSuccessSnackbar(`Invitation has been revoked for ${email}.`); + this.company.next('revoked'); + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + removeCompanyMemder(companyId: string, userId: string, email: string, userName: string) { + return this._http.delete(`/company/${companyId}/user/${userId}`).pipe( + map((res) => { + this._notifications.showSuccessSnackbar(`${userName || email} has been removed from company.`); + this.company.next('deleted'); + return res; + }), + catchError((err) => { + console.log(err); + this._notifications.showAlert( + AlertType.Error, + { abstract: err.error.message, details: err.error.originalMessage }, + [ + { + type: AlertActionType.Button, + caption: 'Dismiss', + action: (_id: number) => this._notifications.dismissAlert(), + }, + ], + ); + return EMPTY; + }), + ); + } + + updateCompanyMemberRole(companyId: string, userId: string, role: CompanyMemberRole) { + return this._http.put(`/company/users/roles/${companyId}`, { users: [{ userId, role }] }).pipe( + map((res) => { + this._notifications.showSuccessSnackbar(`Company member role has been updated.`); + this.company.next('role'); + return res; + }), + catchError((err) => { + console.log(err); + this._notifications.showAlert( + AlertType.Error, + { abstract: err.error.message, details: err.error.originalMessage }, + [ + { + type: AlertActionType.Button, + caption: 'Dismiss', + action: (_id: number) => this._notifications.dismissAlert(), + }, + ], + ); + return EMPTY; + }), + ); + } + + suspendCompanyMember(companyId: string, usersEmails: string[]) { + return this._http.put(`/company/users/suspend/${companyId}`, { usersEmails }).pipe( + map((res) => { + this._notifications.showSuccessSnackbar(`Company member has been suspended.`); + this.company.next('suspended'); + return res; + }), + catchError((err) => { + console.log(err); + this._notifications.showAlert( + AlertType.Error, + { abstract: err.error.message, details: err.error.originalMessage }, + [ + { + type: AlertActionType.Button, + caption: 'Dismiss', + action: (_id: number) => this._notifications.dismissAlert(), + }, + ], + ); + return EMPTY; + }), + ); + } + + restoreCompanyMember(companyId: string, usersEmails: string[]) { + return this._http.put(`/company/users/unsuspend/${companyId}`, { usersEmails }).pipe( + map((res) => { + this._notifications.showSuccessSnackbar(`Company member has been restored.`); + this.company.next('unsuspended'); + return res; + }), + catchError((err) => { + console.log(err); + this._notifications.showAlert( + AlertType.Error, + { abstract: err.error.message, details: err.error.originalMessage }, + [ + { + type: AlertActionType.Button, + caption: 'Dismiss', + action: (_id: number) => this._notifications.dismissAlert(), + }, + ], + ); + return EMPTY; + }), + ); + } + + updateShowTestConnections(displayMode: 'on' | 'off') { + return this._http.put(`/company/connections/display`, undefined, { params: { displayMode } }).pipe( + map((res) => { + if (displayMode === 'on') { + this._notifications.showSuccessSnackbar('Test connections now are displayed to your company members.'); + } else { + this._notifications.showSuccessSnackbar('Test connections now are hidden from your company members.'); + } + this.company.next(''); + return res; + }), + catchError((err) => { + console.log(err); + this._notifications.showAlert( + AlertType.Error, + { abstract: err.error.message, details: err.error.originalMessage }, + [ + { + type: AlertActionType.Button, + caption: 'Dismiss', + action: (_id: number) => this._notifications.dismissAlert(), + }, + ], + ); + return EMPTY; + }), + ); + } + + getCustomDomain(companyId: string) { + const config = this._configuration.getConfig(); + + return this._http.get(config.saasURL + `/saas/custom-domain/${companyId}`).pipe( + map((res) => res), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + createCustomDomain(companyId: string, hostname: string) { + const config = this._configuration.getConfig(); + + return this._http.post(config.saasURL + `/saas/custom-domain/register/${companyId}`, { hostname }).pipe( + map((res) => { + // this._notifications.showAlert('Custom domain has been added.'); + this._notifications.showAlert( + AlertType.Success, + { + abstract: `Now your admin panel is live on your own domain: ${hostname}`, + details: + 'Check it out! If you have any issues or need help setting up your domain or CNAME record, please reach out to our support team.', + }, + [ + { + type: AlertActionType.Anchor, + caption: 'Open', + to: `https://${hostname}`, + }, + { + type: AlertActionType.Button, + caption: 'Dismiss', + action: (_id: number) => this._notifications.dismissAlert(), + }, + ], + ); + this.company.next('domain'); + return res; + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + updateCustomDomain(companyId: string, hostname: string) { + const config = this._configuration.getConfig(); + + return this._http.put(config.saasURL + `/saas/custom-domain/update/${companyId}`, { hostname }).pipe( + map((res) => { + this._notifications.showSuccessSnackbar('Custom domain has been updated.'); + this.company.next('domain'); + return res; + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + deleteCustomDomain(companyId: string) { + const config = this._configuration.getConfig(); + + return this._http.delete(config.saasURL + `/saas/custom-domain/delete/${companyId}`).pipe( + map((res) => { + this._notifications.showSuccessSnackbar('Custom domain has been removed.'); + this.company.next('domain'); + return res; + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + uploadLogo(companyId: string, file: File) { + const formData = new FormData(); + formData.append('file', file); + + return this._http.post(`/company/logo/${companyId}`, formData).pipe( + map((res) => { + this._notifications.showSuccessSnackbar('Logo has been updated. Please rerefresh the page to see the changes.'); + this.company.next('updated-white-label-settings'); + return res; + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + removeLogo(companyId: string) { + return this._http.delete(`/company/logo/${companyId}`).pipe( + map((res) => { + this._notifications.showSuccessSnackbar('Logo has been removed. Please rerefresh the page to see the changes.'); + this.company.next('updated-white-label-settings'); + return res; + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + uploadFavicon(companyId: string, file: File) { + const formData = new FormData(); + formData.append('file', file); + + return this._http.post(`/company/favicon/${companyId}`, formData).pipe( + map((res) => { + this._notifications.showSuccessSnackbar( + 'Favicon has been updated. Please rerefresh the page to see the changes.', + ); + this.company.next('updated-white-label-settings'); + return res; + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + removeFavicon(companyId: string) { + return this._http.delete(`/company/favicon/${companyId}`).pipe( + map((res) => { + this._notifications.showSuccessSnackbar( + 'Favicon has been removed. Please rerefresh the page to see the changes.', + ); + this.company.next('updated-white-label-settings'); + return res; + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + updateTabTitle(companyId: string, tab_title: string) { + return this._http.post(`/company/tab-title/${companyId}`, { tab_title }).pipe( + map((res) => { + this._notifications.showSuccessSnackbar( + 'Tab title has been saved. Please rerefresh the page to see the changes.', + ); + this.company.next('updated-white-label-settings'); + return res; + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + removeTabTitle(companyId: string) { + return this._http.delete(`/company/tab-title/${companyId}`).pipe( + map((res) => { + this._notifications.showSuccessSnackbar( + 'Tab title has been removed. Please rerefresh the page to see the changes.', + ); + this.company.next('updated-white-label-settings'); + return res; + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + getWhiteLabelProperties(companyId: string) { + return this._http.get(`/company/white-label-properties/${companyId}`).pipe( + map((res) => { + if (res.logo?.image && res.logo.mimeType) { + this.companyLogo = `data:${res.logo.mimeType};base64,${res.logo.image}`; + } else { + this.companyLogo = null; + } + + if (res.favicon?.image && res.favicon.mimeType) { + this.companyFavicon = `data:${res.favicon.mimeType};base64,${res.favicon.image}`; + } else { + this.companyFavicon = null; + } + + if (res.tab_title) { + this.companyTabTitle = res.tab_title; + } else { + this.companyTabTitle = null; + } + + this.companyTabTitleSubject.next(res.tab_title); + + this.company.next(''); + + return { + logo: this.companyLogo, + favicon: this.companyFavicon, + tab_title: res.tab_title, + }; + }), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + fetchSamlConfiguration(companyId: string) { + return this._http.get(`/saas/saml/company/full/${companyId}`).pipe( + map((res) => res), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + createSamlConfiguration(companyId: string, config: SamlConfig) { + return this._http.post(`/saas/saml/company/${companyId}`, config).pipe( + map((res) => res), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } + + updateSamlConfiguration(config: SamlConfig) { + return this._http.put(`/saas/saml/${config.id}`, config).pipe( + map((res) => res), + catchError((err) => { + console.log(err); + this._notifications.showErrorSnackbar(err.error.message || err.message); + return EMPTY; + }), + ); + } }