From 5eaf8728fe3deb68724310c20fd404d852e82322 Mon Sep 17 00:00:00 2001 From: Andrii Kostenko Date: Tue, 3 Feb 2026 12:22:17 +0000 Subject: [PATCH] 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); + } +}