-
-
Notifications
You must be signed in to change notification settings - Fork 18
feat: add self-hosted setup page for initial admin configuration #1555
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| <app-alert></app-alert> | ||
|
|
||
| <div class="wrapper background-decoration"> | ||
| <div class="setup-page"> | ||
| <form class="setup-form" #setupForm="ngForm" (ngSubmit)="createAdminAccount()"> | ||
| <div class="setup-header"> | ||
| <svg class="setup-header__logo" width="56" height="54" viewBox="0 0 56 54" fill="none" xmlns="http://www.w3.org/2000/svg"> | ||
| <path d="M47.2367 17.9125H35.1499C33.3671 17.9125 32.4833 16.5678 33.1783 14.9286L37.5295 4.71527C38.5191 2.39612 37.2651 0.5 34.742 0.5H23.4711C22.3682 0.5 21.1142 1.33097 20.6836 2.34323L15.4485 14.634C14.5798 16.6661 15.6827 18.328 17.8885 18.328H29.4465C30.7231 18.328 31.8789 19.0909 32.3775 20.2618L37.5295 32.3486C38.232 33.9954 37.3406 35.3401 35.5578 35.3401H8.62703C7.52411 35.3401 6.27011 36.1711 5.83952 37.1833L0.823517 48.9453C-0.166086 51.2644 1.08792 53.1605 3.61103 53.1605H29.8242C30.9271 53.1605 32.1811 52.3296 32.6117 51.3173L38.4435 37.629C38.9346 36.4808 40.0526 35.7405 41.299 35.7405H52.6833C54.9722 35.7405 56.1053 34.0181 55.2064 31.918L50.0242 19.7633C49.5936 18.751 48.3396 17.92 47.2367 17.92V17.9125Z" fill="#08041B"/> | ||
| </svg> | ||
| <h1 class="mat-headline-4 setup-title"> | ||
| Welcome to <br> | ||
| <span class="setup-title__emphasis">Rocketadmin</span> | ||
| </h1> | ||
| <div class="mat-body-1 setup-header__directions"> | ||
| Create your admin account to get started. | ||
| </div> | ||
| </div> | ||
|
|
||
| <mat-form-field appearance="fill" floatLabel="always" class="setup-form__email"> | ||
| <mat-label>Email</mat-label> | ||
| <input matInput type="email" name="email" emailValidator | ||
| placeholder="Email" | ||
| data-testid="setup-email-input" | ||
| #emailInput="ngModel" required | ||
| [ngModel]="email()" | ||
| (ngModelChange)="onEmailChange($event)"> | ||
| @if (emailInput.errors?.isInvalidEmail) { | ||
| <mat-error>Invalid email format.</mat-error> | ||
| } | ||
| </mat-form-field> | ||
|
|
||
| <app-user-password | ||
| [value]="password()" | ||
| [label]="'Password'" | ||
| (onFieldChange)="onPasswordChange($event)" | ||
| class="setup-form__password"> | ||
| </app-user-password> | ||
|
|
||
| <button | ||
| type="submit" mat-flat-button color="accent" | ||
| data-testid="setup-submit-button" | ||
| class="setup-form__submit-button" | ||
| [disabled]="submitting() || setupForm.form.invalid || setupForm.form.pristine"> | ||
| {{ submitting() ? 'Creating...' : 'Create Admin Account' }} | ||
| </button> | ||
| </form> | ||
| </div> | ||
| </div> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<typeof import('@angular/core').signal<string>>; | ||
| password: ReturnType<typeof import('@angular/core').signal<string>>; | ||
| submitting: ReturnType<typeof import('@angular/core').signal<boolean>>; | ||
| }; | ||
|
|
||
| describe('SetupComponent', () => { | ||
| let component: SetupComponent; | ||
| let fixture: ComponentFixture<SetupComponent>; | ||
| 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']); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+44
to
+47
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.submitting.set(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this._selfhostedService | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .createInitialUser({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| email: this.email(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| password: this.password(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .subscribe({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| next: () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.submitting.set(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this._router.navigate(['/login']); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+57
to
+59
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| next: () => { | |
| this.submitting.set(false); | |
| this._router.navigate(['/login']); | |
| next: async () => { | |
| this.submitting.set(false); | |
| try { | |
| const navigated = await this._router.navigate(['/login']); | |
| if (!navigated) { | |
| console.error('Navigation to /login was unsuccessful.'); | |
| } | |
| } catch (error) { | |
| console.error('Navigation to /login failed.', error); | |
| } |
Copilot
AI
Feb 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After successfully creating the admin account, the user is redirected to /login. However, there's no automatic login mechanism, forcing the newly created admin to manually enter their credentials again. Consider automatically logging in the user after account creation (if the backend returns an auth token) to provide a smoother user experience, similar to how registration flows typically work.
| next: () => { | |
| this.submitting.set(false); | |
| this._router.navigate(['/login']); | |
| next: (response: any) => { | |
| // If the backend returns an auth token, store it to automatically log the user in. | |
| const token = response && (response.token || response.authToken); | |
| if (token) { | |
| localStorage.setItem('authToken', token); | |
| } | |
| this.submitting.set(false); | |
| // Redirect to the main application route after successful account creation (and login, if token was provided). | |
| this._router.navigate(['/']); |
Copilot
AI
Feb 3, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The subscribe callback has redundant handling of the submitting state. The submitting flag is set to false in three places: next, error, and complete callbacks. Since complete is called after either next or error, setting submitting to false in the complete callback alone would be sufficient. This pattern differs from login.component.ts which sets the flag in both next and error callbacks. Consider simplifying by only setting it in the complete callback.
| complete: () => { | |
| this.submitting.set(false); | |
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test suite lacks coverage for error scenarios. Missing test cases include: (1) testing behavior when createInitialUser fails and (2) testing the error callback that sets submitting to false. Given that other component tests in the codebase (like login.component.spec.ts) include error scenario testing, this component should follow the same pattern for consistency and completeness.