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 @@
+
+
+
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);
+ }
+}