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/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/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/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; + }), + ); + } } 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..0c61886f2 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: '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', + 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 @@ } } + + +