From 5eaf8728fe3deb68724310c20fd404d852e82322 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Tue, 3 Feb 2026 12:22:17 +0000 Subject: [PATCH 1/2] feat: add self-hosted setup page for initial admin configuration - Add SelfhostedService with signals for configuration state management - Add ConfigurationGuard to redirect /login to /setup when not configured - Add SetupGuard to protect /setup route (only accessible when not configured) - Add SetupComponent with email/password form matching login page style - Update app-routing.module.ts with /setup route and guards Co-Authored-By: Claude Opus 4.5 --- frontend/src/app/app-routing.module.ts | 9 ++ .../app/components/setup/setup.component.css | 117 ++++++++++++++++++ .../app/components/setup/setup.component.html | 48 +++++++ .../components/setup/setup.component.spec.ts | 97 +++++++++++++++ .../app/components/setup/setup.component.ts | 69 +++++++++++ .../src/app/guards/configuration.guard.ts | 40 ++++++ frontend/src/app/guards/setup.guard.ts | 45 +++++++ .../src/app/services/selfhosted.service.ts | 80 ++++++++++++ 8 files changed, 505 insertions(+) create mode 100644 frontend/src/app/components/setup/setup.component.css create mode 100644 frontend/src/app/components/setup/setup.component.html create mode 100644 frontend/src/app/components/setup/setup.component.spec.ts create mode 100644 frontend/src/app/components/setup/setup.component.ts create mode 100644 frontend/src/app/guards/configuration.guard.ts create mode 100644 frontend/src/app/guards/setup.guard.ts create mode 100644 frontend/src/app/services/selfhosted.service.ts diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 01a0f249d..11a0acaea 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -1,6 +1,8 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { AuthGuard } from './auth.guard'; +import { configurationGuard } from './guards/configuration.guard'; +import { setupGuard } from './guards/setup.guard'; const routes: Routes = [ { path: '', redirectTo: '/connections-list', pathMatch: 'full' }, @@ -12,9 +14,16 @@ const routes: Routes = [ path: 'registration', loadChildren: () => import('./routes/registration.routes').then((m) => m.REGISTRATION_ROUTES), }, + { + path: 'setup', + loadComponent: () => import('./components/setup/setup.component').then((m) => m.SetupComponent), + canActivate: [setupGuard], + title: 'Setup | Rocketadmin', + }, { path: 'login', loadComponent: () => import('./components/login/login.component').then((m) => m.LoginComponent), + canActivate: [configurationGuard], title: 'Login | Rocketadmin', }, { diff --git a/frontend/src/app/components/setup/setup.component.css b/frontend/src/app/components/setup/setup.component.css new file mode 100644 index 000000000..c9f7df615 --- /dev/null +++ b/frontend/src/app/components/setup/setup.component.css @@ -0,0 +1,117 @@ +:host app-alert:not(:empty) { + --alert-margin: 24px; + + position: absolute; + top: var(--mat-toolbar-standard-height); + width: calc(100% - 48px); +} + +.wrapper { + height: calc(100vh - 56px); + padding: 16px; +} + +@media (width <= 600px) { + .wrapper { + padding: 0; + width: 100vw; + } +} + +.setup-page { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100%; +} + +@media (width <= 600px) { + .setup-page { + justify-content: flex-start; + } +} + +.setup-form { + display: flex; + flex-direction: column; + align-items: center; + width: clamp(360px, 30%, 600px); +} + +@media (width <= 600px) { + .setup-form { + gap: 8px; + padding: 40px 9vw; + width: 100%; + } +} + +.setup-header { + display: flex; + flex-direction: column; + align-items: center; + padding-bottom: 40px; +} + +@media (width <= 600px) { + .setup-header { + padding-bottom: 0; + } +} + +.setup-header__logo { + margin-bottom: 36px; + width: 44px; +} + +@media (width <= 600px) { + .setup-header__logo { + margin-bottom: 12px; + } +} + +@media (prefers-color-scheme: dark) { + .setup-header__logo path { + fill: white; + } +} + +.setup-title { + font-weight: 600 !important; + margin-bottom: 40px !important; + text-align: center !important; +} + +.setup-title__emphasis { + color: var(--color-accentedPalette-500); +} + +.setup-header__directions { + text-align: center; +} + +@media (width <= 600px) { + .setup-header__directions { + display: none; + } +} + +.setup-form__email { + width: 100%; +} + +.setup-form__password { + width: 100%; +} + +.setup-form__submit-button { + width: 100%; + margin-top: 40px; +} + +@media (width <= 600px) { + .setup-form__submit-button { + margin-top: 0; + } +} diff --git a/frontend/src/app/components/setup/setup.component.html b/frontend/src/app/components/setup/setup.component.html new file mode 100644 index 000000000..0a2d0e105 --- /dev/null +++ b/frontend/src/app/components/setup/setup.component.html @@ -0,0 +1,48 @@ + + +
+
+
+
+ +

+ Welcome to
+ Rocketadmin +

+
+ Create your admin account to get started. +
+
+ + + + + + + +
+
+
diff --git a/frontend/src/app/components/setup/setup.component.spec.ts b/frontend/src/app/components/setup/setup.component.spec.ts new file mode 100644 index 000000000..04c761019 --- /dev/null +++ b/frontend/src/app/components/setup/setup.component.spec.ts @@ -0,0 +1,97 @@ +import { provideHttpClient } from '@angular/common/http'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { MatSnackBarModule } from '@angular/material/snack-bar'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { provideRouter, Router } from '@angular/router'; +import { IPasswordStrengthMeterService } from 'angular-password-strength-meter'; +import { Angulartics2Module } from 'angulartics2'; +import { of } from 'rxjs'; +import { SelfhostedService } from 'src/app/services/selfhosted.service'; +import { SetupComponent } from './setup.component'; + +type SetupComponentTestable = SetupComponent & { + email: ReturnType>; + password: ReturnType>; + submitting: ReturnType>; +}; + +describe('SetupComponent', () => { + let component: SetupComponent; + let fixture: ComponentFixture; + let selfhostedService: SelfhostedService; + let router: Router; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FormsModule, MatSnackBarModule, SetupComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()], + providers: [provideHttpClient(), provideRouter([]), { provide: IPasswordStrengthMeterService, useValue: {} }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SetupComponent); + component = fixture.componentInstance; + selfhostedService = TestBed.inject(SelfhostedService); + router = TestBed.inject(Router); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should update email signal on change', () => { + const testable = component as SetupComponentTestable; + component.onEmailChange('test@example.com'); + expect(testable.email()).toBe('test@example.com'); + }); + + it('should update password signal on change', () => { + const testable = component as SetupComponentTestable; + component.onPasswordChange('SecurePass123'); + expect(testable.password()).toBe('SecurePass123'); + }); + + it('should not submit if email is empty', () => { + const testable = component as SetupComponentTestable; + const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser'); + + testable.email.set(''); + testable.password.set('SecurePass123'); + + component.createAdminAccount(); + expect(fakeCreateInitialUser).not.toHaveBeenCalled(); + }); + + it('should not submit if password is empty', () => { + const testable = component as SetupComponentTestable; + const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser'); + + testable.email.set('test@example.com'); + testable.password.set(''); + + component.createAdminAccount(); + expect(fakeCreateInitialUser).not.toHaveBeenCalled(); + }); + + it('should create admin account and navigate to login on success', () => { + const testable = component as SetupComponentTestable; + const fakeCreateInitialUser = vi + .spyOn(selfhostedService, 'createInitialUser') + .mockReturnValue(of({ success: true })); + const fakeNavigate = vi.spyOn(router, 'navigate').mockResolvedValue(true); + + testable.email.set('admin@example.com'); + testable.password.set('SecurePass123'); + + component.createAdminAccount(); + + expect(fakeCreateInitialUser).toHaveBeenCalledWith({ + email: 'admin@example.com', + password: 'SecurePass123', + }); + expect(testable.submitting()).toBe(false); + expect(fakeNavigate).toHaveBeenCalledWith(['/login']); + }); +}); diff --git a/frontend/src/app/components/setup/setup.component.ts b/frontend/src/app/components/setup/setup.component.ts new file mode 100644 index 000000000..33d374b67 --- /dev/null +++ b/frontend/src/app/components/setup/setup.component.ts @@ -0,0 +1,69 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { Router } from '@angular/router'; +import { EmailValidationDirective } from 'src/app/directives/emailValidator.directive'; +import { SelfhostedService } from 'src/app/services/selfhosted.service'; +import { AlertComponent } from '../ui-components/alert/alert.component'; +import { UserPasswordComponent } from '../ui-components/user-password/user-password.component'; + +@Component({ + selector: 'app-setup', + templateUrl: './setup.component.html', + styleUrls: ['./setup.component.css'], + imports: [ + CommonModule, + FormsModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + EmailValidationDirective, + AlertComponent, + UserPasswordComponent, + ], +}) +export class SetupComponent { + private _selfhostedService = inject(SelfhostedService); + private _router = inject(Router); + + protected email = signal(''); + protected password = signal(''); + protected submitting = signal(false); + + onEmailChange(value: string): void { + this.email.set(value); + } + + onPasswordChange(value: string): void { + this.password.set(value); + } + + createAdminAccount(): void { + if (!this.email() || !this.password()) { + return; + } + + this.submitting.set(true); + + this._selfhostedService + .createInitialUser({ + email: this.email(), + password: this.password(), + }) + .subscribe({ + next: () => { + this.submitting.set(false); + this._router.navigate(['/login']); + }, + error: () => { + this.submitting.set(false); + }, + complete: () => { + this.submitting.set(false); + }, + }); + } +} diff --git a/frontend/src/app/guards/configuration.guard.ts b/frontend/src/app/guards/configuration.guard.ts new file mode 100644 index 000000000..c3aea3e6b --- /dev/null +++ b/frontend/src/app/guards/configuration.guard.ts @@ -0,0 +1,40 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { map, of } from 'rxjs'; +import { SelfhostedService } from '../services/selfhosted.service'; + +/** + * Guard that protects the /login route. + * In self-hosted mode, redirects to /setup if the app is not configured. + * In SaaS mode, allows access immediately. + */ +export const configurationGuard: CanActivateFn = () => { + const selfhostedService = inject(SelfhostedService); + const router = inject(Router); + + // In SaaS mode, always allow access to login + if (!selfhostedService.isSelfHosted()) { + return of(true); + } + + // If we already know the configuration state, use it + const currentState = selfhostedService.isConfigured(); + if (currentState !== null) { + if (currentState) { + return of(true); + } else { + return of(router.createUrlTree(['/setup'])); + } + } + + // Check configuration from the server + return selfhostedService.checkConfiguration().pipe( + map((response) => { + if (response.isConfigured) { + return true; + } else { + return router.createUrlTree(['/setup']); + } + }), + ); +}; diff --git a/frontend/src/app/guards/setup.guard.ts b/frontend/src/app/guards/setup.guard.ts new file mode 100644 index 000000000..8615e1e5e --- /dev/null +++ b/frontend/src/app/guards/setup.guard.ts @@ -0,0 +1,45 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { map, of } from 'rxjs'; +import { SelfhostedService } from '../services/selfhosted.service'; + +/** + * Guard that protects the /setup route. + * - In SaaS mode, redirects to /login (setup is only for self-hosted) + * - In self-hosted mode, redirects to /login if already configured + * - Allows access to /setup only if self-hosted and not configured + */ +export const setupGuard: CanActivateFn = () => { + const selfhostedService = inject(SelfhostedService); + const router = inject(Router); + + // In SaaS mode, redirect to login (setup is only for self-hosted) + if (!selfhostedService.isSelfHosted()) { + return of(router.createUrlTree(['/login'])); + } + + // If we already know the configuration state, use it + const currentState = selfhostedService.isConfigured(); + if (currentState !== null) { + if (currentState) { + // Already configured, redirect to login + return of(router.createUrlTree(['/login'])); + } else { + // Not configured, allow access to setup + return of(true); + } + } + + // Check configuration from the server + return selfhostedService.checkConfiguration().pipe( + map((response) => { + if (response.isConfigured) { + // Already configured, redirect to login + return router.createUrlTree(['/login']); + } else { + // Not configured, allow access to setup + return true; + } + }), + ); +}; diff --git a/frontend/src/app/services/selfhosted.service.ts b/frontend/src/app/services/selfhosted.service.ts new file mode 100644 index 000000000..e38d211f7 --- /dev/null +++ b/frontend/src/app/services/selfhosted.service.ts @@ -0,0 +1,80 @@ +import { HttpClient } from '@angular/common/http'; +import { computed, Injectable, inject, signal } from '@angular/core'; +import { catchError, EMPTY, map, Observable, tap } from 'rxjs'; +import { environment } from 'src/environments/environment'; +import { AlertActionType, AlertType } from '../models/alert'; +import { NotificationsService } from './notifications.service'; + +export interface IsConfiguredResponse { + isConfigured: boolean; +} + +export interface CreateInitialUserRequest { + email: string; + password: string; +} + +export interface CreateInitialUserResponse { + success: boolean; +} + +@Injectable({ + providedIn: 'root', +}) +export class SelfhostedService { + private _http = inject(HttpClient); + private _notifications = inject(NotificationsService); + + private _isConfigured = signal(null); + private _isCheckingConfiguration = signal(false); + + public readonly isConfigured = this._isConfigured.asReadonly(); + public readonly isCheckingConfiguration = this._isCheckingConfiguration.asReadonly(); + public readonly isSelfHosted = computed(() => !(environment as any).saas); + + checkConfiguration(): Observable { + this._isCheckingConfiguration.set(true); + return this._http.get('/selfhosted/is-configured').pipe( + tap((response) => { + this._isConfigured.set(response.isConfigured); + this._isCheckingConfiguration.set(false); + }), + catchError((err) => { + console.error('Failed to check configuration:', err); + this._isCheckingConfiguration.set(false); + // If the endpoint fails, assume configured to avoid blocking login + this._isConfigured.set(true); + return EMPTY; + }), + ); + } + + createInitialUser(userData: CreateInitialUserRequest): Observable { + return this._http.post('/selfhosted/initial-user', userData).pipe( + map((res) => { + this._notifications.showSuccessSnackbar('Admin account created successfully.'); + this._isConfigured.set(true); + return res; + }), + catchError((err) => { + console.error('Failed to create initial user:', err); + this._notifications.showAlert( + AlertType.Error, + { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, + [ + { + type: AlertActionType.Button, + caption: 'Dismiss', + action: () => this._notifications.dismissAlert(), + }, + ], + ); + return EMPTY; + }), + ); + } + + resetConfigurationState(): void { + this._isConfigured.set(null); + } +} From f65bcf2c928621ae2ebf48b2f686b5eecb2ec58a Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Tue, 3 Feb 2026 12:35:22 +0000 Subject: [PATCH 2/2] refactor: remove RxJS, use native fetch and async/await - Replace HttpClient with native fetch API in SelfhostedService - Convert Observable methods to async/await Promises - Update guards to use async functions - Update SetupComponent to use async/await - Update tests to work with Promise-based methods Co-Authored-By: Claude Opus 4.5 --- .../components/setup/setup.component.spec.ts | 17 ++-- .../app/components/setup/setup.component.ts | 24 ++--- .../src/app/guards/configuration.guard.ts | 24 ++--- frontend/src/app/guards/setup.guard.ts | 28 +++--- .../src/app/services/selfhosted.service.ts | 91 +++++++++++-------- 5 files changed, 89 insertions(+), 95 deletions(-) diff --git a/frontend/src/app/components/setup/setup.component.spec.ts b/frontend/src/app/components/setup/setup.component.spec.ts index 04c761019..031286bc4 100644 --- a/frontend/src/app/components/setup/setup.component.spec.ts +++ b/frontend/src/app/components/setup/setup.component.spec.ts @@ -6,7 +6,6 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { provideRouter, Router } from '@angular/router'; import { IPasswordStrengthMeterService } from 'angular-password-strength-meter'; import { Angulartics2Module } from 'angulartics2'; -import { of } from 'rxjs'; import { SelfhostedService } from 'src/app/services/selfhosted.service'; import { SetupComponent } from './setup.component'; @@ -53,39 +52,37 @@ describe('SetupComponent', () => { expect(testable.password()).toBe('SecurePass123'); }); - it('should not submit if email is empty', () => { + it('should not submit if email is empty', async () => { const testable = component as SetupComponentTestable; const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser'); testable.email.set(''); testable.password.set('SecurePass123'); - component.createAdminAccount(); + await component.createAdminAccount(); expect(fakeCreateInitialUser).not.toHaveBeenCalled(); }); - it('should not submit if password is empty', () => { + it('should not submit if password is empty', async () => { const testable = component as SetupComponentTestable; const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser'); testable.email.set('test@example.com'); testable.password.set(''); - component.createAdminAccount(); + await component.createAdminAccount(); expect(fakeCreateInitialUser).not.toHaveBeenCalled(); }); - it('should create admin account and navigate to login on success', () => { + it('should create admin account and navigate to login on success', async () => { const testable = component as SetupComponentTestable; - const fakeCreateInitialUser = vi - .spyOn(selfhostedService, 'createInitialUser') - .mockReturnValue(of({ success: true })); + const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser').mockResolvedValue({ success: true }); const fakeNavigate = vi.spyOn(router, 'navigate').mockResolvedValue(true); testable.email.set('admin@example.com'); testable.password.set('SecurePass123'); - component.createAdminAccount(); + await component.createAdminAccount(); expect(fakeCreateInitialUser).toHaveBeenCalledWith({ email: 'admin@example.com', diff --git a/frontend/src/app/components/setup/setup.component.ts b/frontend/src/app/components/setup/setup.component.ts index 33d374b67..5a57a6690 100644 --- a/frontend/src/app/components/setup/setup.component.ts +++ b/frontend/src/app/components/setup/setup.component.ts @@ -41,29 +41,23 @@ export class SetupComponent { this.password.set(value); } - createAdminAccount(): void { + async createAdminAccount(): Promise { if (!this.email() || !this.password()) { return; } this.submitting.set(true); - this._selfhostedService - .createInitialUser({ + try { + await this._selfhostedService.createInitialUser({ email: this.email(), password: this.password(), - }) - .subscribe({ - next: () => { - this.submitting.set(false); - this._router.navigate(['/login']); - }, - error: () => { - this.submitting.set(false); - }, - complete: () => { - this.submitting.set(false); - }, }); + this._router.navigate(['/login']); + } catch { + // Error handling is done in the service + } finally { + this.submitting.set(false); + } } } diff --git a/frontend/src/app/guards/configuration.guard.ts b/frontend/src/app/guards/configuration.guard.ts index c3aea3e6b..5d41ed184 100644 --- a/frontend/src/app/guards/configuration.guard.ts +++ b/frontend/src/app/guards/configuration.guard.ts @@ -1,6 +1,5 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; -import { map, of } from 'rxjs'; import { SelfhostedService } from '../services/selfhosted.service'; /** @@ -8,33 +7,30 @@ import { SelfhostedService } from '../services/selfhosted.service'; * In self-hosted mode, redirects to /setup if the app is not configured. * In SaaS mode, allows access immediately. */ -export const configurationGuard: CanActivateFn = () => { +export const configurationGuard: CanActivateFn = async () => { const selfhostedService = inject(SelfhostedService); const router = inject(Router); // In SaaS mode, always allow access to login if (!selfhostedService.isSelfHosted()) { - return of(true); + return true; } // If we already know the configuration state, use it const currentState = selfhostedService.isConfigured(); if (currentState !== null) { if (currentState) { - return of(true); + return true; } else { - return of(router.createUrlTree(['/setup'])); + return router.createUrlTree(['/setup']); } } // Check configuration from the server - return selfhostedService.checkConfiguration().pipe( - map((response) => { - if (response.isConfigured) { - return true; - } else { - return router.createUrlTree(['/setup']); - } - }), - ); + const response = await selfhostedService.checkConfiguration(); + if (response.isConfigured) { + return true; + } else { + return router.createUrlTree(['/setup']); + } }; diff --git a/frontend/src/app/guards/setup.guard.ts b/frontend/src/app/guards/setup.guard.ts index 8615e1e5e..717a024c3 100644 --- a/frontend/src/app/guards/setup.guard.ts +++ b/frontend/src/app/guards/setup.guard.ts @@ -1,6 +1,5 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; -import { map, of } from 'rxjs'; import { SelfhostedService } from '../services/selfhosted.service'; /** @@ -9,13 +8,13 @@ import { SelfhostedService } from '../services/selfhosted.service'; * - In self-hosted mode, redirects to /login if already configured * - Allows access to /setup only if self-hosted and not configured */ -export const setupGuard: CanActivateFn = () => { +export const setupGuard: CanActivateFn = async () => { const selfhostedService = inject(SelfhostedService); const router = inject(Router); // In SaaS mode, redirect to login (setup is only for self-hosted) if (!selfhostedService.isSelfHosted()) { - return of(router.createUrlTree(['/login'])); + return router.createUrlTree(['/login']); } // If we already know the configuration state, use it @@ -23,23 +22,20 @@ export const setupGuard: CanActivateFn = () => { if (currentState !== null) { if (currentState) { // Already configured, redirect to login - return of(router.createUrlTree(['/login'])); + return router.createUrlTree(['/login']); } else { // Not configured, allow access to setup - return of(true); + return true; } } // Check configuration from the server - return selfhostedService.checkConfiguration().pipe( - map((response) => { - if (response.isConfigured) { - // Already configured, redirect to login - return router.createUrlTree(['/login']); - } else { - // Not configured, allow access to setup - return true; - } - }), - ); + const response = await selfhostedService.checkConfiguration(); + if (response.isConfigured) { + // Already configured, redirect to login + return router.createUrlTree(['/login']); + } else { + // Not configured, allow access to setup + return true; + } }; diff --git a/frontend/src/app/services/selfhosted.service.ts b/frontend/src/app/services/selfhosted.service.ts index e38d211f7..af4f52a2f 100644 --- a/frontend/src/app/services/selfhosted.service.ts +++ b/frontend/src/app/services/selfhosted.service.ts @@ -1,6 +1,4 @@ -import { HttpClient } from '@angular/common/http'; import { computed, Injectable, inject, signal } from '@angular/core'; -import { catchError, EMPTY, map, Observable, tap } from 'rxjs'; import { environment } from 'src/environments/environment'; import { AlertActionType, AlertType } from '../models/alert'; import { NotificationsService } from './notifications.service'; @@ -22,7 +20,6 @@ export interface CreateInitialUserResponse { providedIn: 'root', }) export class SelfhostedService { - private _http = inject(HttpClient); private _notifications = inject(NotificationsService); private _isConfigured = signal(null); @@ -32,46 +29,60 @@ export class SelfhostedService { public readonly isCheckingConfiguration = this._isCheckingConfiguration.asReadonly(); public readonly isSelfHosted = computed(() => !(environment as any).saas); - checkConfiguration(): Observable { + async checkConfiguration(): Promise { this._isCheckingConfiguration.set(true); - return this._http.get('/selfhosted/is-configured').pipe( - tap((response) => { - this._isConfigured.set(response.isConfigured); - this._isCheckingConfiguration.set(false); - }), - catchError((err) => { - console.error('Failed to check configuration:', err); - this._isCheckingConfiguration.set(false); - // If the endpoint fails, assume configured to avoid blocking login - this._isConfigured.set(true); - return EMPTY; - }), - ); + try { + const response = await fetch('/api/selfhosted/is-configured'); + if (!response.ok) { + throw new Error(`HTTP error: ${response.status}`); + } + const data: IsConfiguredResponse = await response.json(); + this._isConfigured.set(data.isConfigured); + this._isCheckingConfiguration.set(false); + return data; + } catch (err) { + console.error('Failed to check configuration:', err); + this._isCheckingConfiguration.set(false); + // If the endpoint fails, assume configured to avoid blocking login + this._isConfigured.set(true); + return { isConfigured: true }; + } } - createInitialUser(userData: CreateInitialUserRequest): Observable { - return this._http.post('/selfhosted/initial-user', userData).pipe( - map((res) => { - this._notifications.showSuccessSnackbar('Admin account created successfully.'); - this._isConfigured.set(true); - return res; - }), - catchError((err) => { - console.error('Failed to create initial user:', err); - this._notifications.showAlert( - AlertType.Error, - { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, - [ - { - type: AlertActionType.Button, - caption: 'Dismiss', - action: () => this._notifications.dismissAlert(), - }, - ], - ); - return EMPTY; - }), - ); + async createInitialUser(userData: CreateInitialUserRequest): Promise { + try { + const response = await fetch('/api/selfhosted/initial-user', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(userData), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw { error: errorData, message: `HTTP error: ${response.status}` }; + } + + const data: CreateInitialUserResponse = await response.json(); + this._notifications.showSuccessSnackbar('Admin account created successfully.'); + this._isConfigured.set(true); + return data; + } catch (err: any) { + console.error('Failed to create initial user:', err); + this._notifications.showAlert( + AlertType.Error, + { abstract: err.error?.message || err.message, details: err.error?.originalMessage }, + [ + { + type: AlertActionType.Button, + caption: 'Dismiss', + action: () => this._notifications.dismissAlert(), + }, + ], + ); + throw err; + } } resetConfigurationState(): void {