Skip to content

Commit 5422f64

Browse files
guguclaude
andauthored
feat: add self-hosted setup page for initial admin configuration (#1555)
- 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 <[email protected]>
1 parent b1bc842 commit 5422f64

File tree

8 files changed

+505
-0
lines changed

8 files changed

+505
-0
lines changed

frontend/src/app/app-routing.module.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { NgModule } from '@angular/core';
22
import { RouterModule, Routes } from '@angular/router';
33
import { AuthGuard } from './auth.guard';
4+
import { configurationGuard } from './guards/configuration.guard';
5+
import { setupGuard } from './guards/setup.guard';
46

57
const routes: Routes = [
68
{ path: '', redirectTo: '/connections-list', pathMatch: 'full' },
@@ -12,9 +14,16 @@ const routes: Routes = [
1214
path: 'registration',
1315
loadChildren: () => import('./routes/registration.routes').then((m) => m.REGISTRATION_ROUTES),
1416
},
17+
{
18+
path: 'setup',
19+
loadComponent: () => import('./components/setup/setup.component').then((m) => m.SetupComponent),
20+
canActivate: [setupGuard],
21+
title: 'Setup | Rocketadmin',
22+
},
1523
{
1624
path: 'login',
1725
loadComponent: () => import('./components/login/login.component').then((m) => m.LoginComponent),
26+
canActivate: [configurationGuard],
1827
title: 'Login | Rocketadmin',
1928
},
2029
{
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
:host app-alert:not(:empty) {
2+
--alert-margin: 24px;
3+
4+
position: absolute;
5+
top: var(--mat-toolbar-standard-height);
6+
width: calc(100% - 48px);
7+
}
8+
9+
.wrapper {
10+
height: calc(100vh - 56px);
11+
padding: 16px;
12+
}
13+
14+
@media (width <= 600px) {
15+
.wrapper {
16+
padding: 0;
17+
width: 100vw;
18+
}
19+
}
20+
21+
.setup-page {
22+
display: flex;
23+
flex-direction: column;
24+
justify-content: center;
25+
align-items: center;
26+
height: 100%;
27+
}
28+
29+
@media (width <= 600px) {
30+
.setup-page {
31+
justify-content: flex-start;
32+
}
33+
}
34+
35+
.setup-form {
36+
display: flex;
37+
flex-direction: column;
38+
align-items: center;
39+
width: clamp(360px, 30%, 600px);
40+
}
41+
42+
@media (width <= 600px) {
43+
.setup-form {
44+
gap: 8px;
45+
padding: 40px 9vw;
46+
width: 100%;
47+
}
48+
}
49+
50+
.setup-header {
51+
display: flex;
52+
flex-direction: column;
53+
align-items: center;
54+
padding-bottom: 40px;
55+
}
56+
57+
@media (width <= 600px) {
58+
.setup-header {
59+
padding-bottom: 0;
60+
}
61+
}
62+
63+
.setup-header__logo {
64+
margin-bottom: 36px;
65+
width: 44px;
66+
}
67+
68+
@media (width <= 600px) {
69+
.setup-header__logo {
70+
margin-bottom: 12px;
71+
}
72+
}
73+
74+
@media (prefers-color-scheme: dark) {
75+
.setup-header__logo path {
76+
fill: white;
77+
}
78+
}
79+
80+
.setup-title {
81+
font-weight: 600 !important;
82+
margin-bottom: 40px !important;
83+
text-align: center !important;
84+
}
85+
86+
.setup-title__emphasis {
87+
color: var(--color-accentedPalette-500);
88+
}
89+
90+
.setup-header__directions {
91+
text-align: center;
92+
}
93+
94+
@media (width <= 600px) {
95+
.setup-header__directions {
96+
display: none;
97+
}
98+
}
99+
100+
.setup-form__email {
101+
width: 100%;
102+
}
103+
104+
.setup-form__password {
105+
width: 100%;
106+
}
107+
108+
.setup-form__submit-button {
109+
width: 100%;
110+
margin-top: 40px;
111+
}
112+
113+
@media (width <= 600px) {
114+
.setup-form__submit-button {
115+
margin-top: 0;
116+
}
117+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<app-alert></app-alert>
2+
3+
<div class="wrapper background-decoration">
4+
<div class="setup-page">
5+
<form class="setup-form" #setupForm="ngForm" (ngSubmit)="createAdminAccount()">
6+
<div class="setup-header">
7+
<svg class="setup-header__logo" width="56" height="54" viewBox="0 0 56 54" fill="none" xmlns="http://www.w3.org/2000/svg">
8+
<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"/>
9+
</svg>
10+
<h1 class="mat-headline-4 setup-title">
11+
Welcome to <br>
12+
<span class="setup-title__emphasis">Rocketadmin</span>
13+
</h1>
14+
<div class="mat-body-1 setup-header__directions">
15+
Create your admin account to get started.
16+
</div>
17+
</div>
18+
19+
<mat-form-field appearance="fill" floatLabel="always" class="setup-form__email">
20+
<mat-label>Email</mat-label>
21+
<input matInput type="email" name="email" emailValidator
22+
placeholder="Email"
23+
data-testid="setup-email-input"
24+
#emailInput="ngModel" required
25+
[ngModel]="email()"
26+
(ngModelChange)="onEmailChange($event)">
27+
@if (emailInput.errors?.isInvalidEmail) {
28+
<mat-error>Invalid email format.</mat-error>
29+
}
30+
</mat-form-field>
31+
32+
<app-user-password
33+
[value]="password()"
34+
[label]="'Password'"
35+
(onFieldChange)="onPasswordChange($event)"
36+
class="setup-form__password">
37+
</app-user-password>
38+
39+
<button
40+
type="submit" mat-flat-button color="accent"
41+
data-testid="setup-submit-button"
42+
class="setup-form__submit-button"
43+
[disabled]="submitting() || setupForm.form.invalid || setupForm.form.pristine">
44+
{{ submitting() ? 'Creating...' : 'Create Admin Account' }}
45+
</button>
46+
</form>
47+
</div>
48+
</div>
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { provideHttpClient } from '@angular/common/http';
2+
import { ComponentFixture, TestBed } from '@angular/core/testing';
3+
import { FormsModule } from '@angular/forms';
4+
import { MatSnackBarModule } from '@angular/material/snack-bar';
5+
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
6+
import { provideRouter, Router } from '@angular/router';
7+
import { IPasswordStrengthMeterService } from 'angular-password-strength-meter';
8+
import { Angulartics2Module } from 'angulartics2';
9+
import { of } from 'rxjs';
10+
import { SelfhostedService } from 'src/app/services/selfhosted.service';
11+
import { SetupComponent } from './setup.component';
12+
13+
type SetupComponentTestable = SetupComponent & {
14+
email: ReturnType<typeof import('@angular/core').signal<string>>;
15+
password: ReturnType<typeof import('@angular/core').signal<string>>;
16+
submitting: ReturnType<typeof import('@angular/core').signal<boolean>>;
17+
};
18+
19+
describe('SetupComponent', () => {
20+
let component: SetupComponent;
21+
let fixture: ComponentFixture<SetupComponent>;
22+
let selfhostedService: SelfhostedService;
23+
let router: Router;
24+
25+
beforeEach(async () => {
26+
await TestBed.configureTestingModule({
27+
imports: [FormsModule, MatSnackBarModule, SetupComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()],
28+
providers: [provideHttpClient(), provideRouter([]), { provide: IPasswordStrengthMeterService, useValue: {} }],
29+
}).compileComponents();
30+
});
31+
32+
beforeEach(() => {
33+
fixture = TestBed.createComponent(SetupComponent);
34+
component = fixture.componentInstance;
35+
selfhostedService = TestBed.inject(SelfhostedService);
36+
router = TestBed.inject(Router);
37+
fixture.detectChanges();
38+
});
39+
40+
it('should create', () => {
41+
expect(component).toBeTruthy();
42+
});
43+
44+
it('should update email signal on change', () => {
45+
const testable = component as SetupComponentTestable;
46+
component.onEmailChange('[email protected]');
47+
expect(testable.email()).toBe('[email protected]');
48+
});
49+
50+
it('should update password signal on change', () => {
51+
const testable = component as SetupComponentTestable;
52+
component.onPasswordChange('SecurePass123');
53+
expect(testable.password()).toBe('SecurePass123');
54+
});
55+
56+
it('should not submit if email is empty', () => {
57+
const testable = component as SetupComponentTestable;
58+
const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser');
59+
60+
testable.email.set('');
61+
testable.password.set('SecurePass123');
62+
63+
component.createAdminAccount();
64+
expect(fakeCreateInitialUser).not.toHaveBeenCalled();
65+
});
66+
67+
it('should not submit if password is empty', () => {
68+
const testable = component as SetupComponentTestable;
69+
const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser');
70+
71+
testable.email.set('[email protected]');
72+
testable.password.set('');
73+
74+
component.createAdminAccount();
75+
expect(fakeCreateInitialUser).not.toHaveBeenCalled();
76+
});
77+
78+
it('should create admin account and navigate to login on success', () => {
79+
const testable = component as SetupComponentTestable;
80+
const fakeCreateInitialUser = vi
81+
.spyOn(selfhostedService, 'createInitialUser')
82+
.mockReturnValue(of({ success: true }));
83+
const fakeNavigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
84+
85+
testable.email.set('[email protected]');
86+
testable.password.set('SecurePass123');
87+
88+
component.createAdminAccount();
89+
90+
expect(fakeCreateInitialUser).toHaveBeenCalledWith({
91+
92+
password: 'SecurePass123',
93+
});
94+
expect(testable.submitting()).toBe(false);
95+
expect(fakeNavigate).toHaveBeenCalledWith(['/login']);
96+
});
97+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { CommonModule } from '@angular/common';
2+
import { Component, inject, signal } from '@angular/core';
3+
import { FormsModule } from '@angular/forms';
4+
import { MatButtonModule } from '@angular/material/button';
5+
import { MatFormFieldModule } from '@angular/material/form-field';
6+
import { MatInputModule } from '@angular/material/input';
7+
import { Router } from '@angular/router';
8+
import { EmailValidationDirective } from 'src/app/directives/emailValidator.directive';
9+
import { SelfhostedService } from 'src/app/services/selfhosted.service';
10+
import { AlertComponent } from '../ui-components/alert/alert.component';
11+
import { UserPasswordComponent } from '../ui-components/user-password/user-password.component';
12+
13+
@Component({
14+
selector: 'app-setup',
15+
templateUrl: './setup.component.html',
16+
styleUrls: ['./setup.component.css'],
17+
imports: [
18+
CommonModule,
19+
FormsModule,
20+
MatFormFieldModule,
21+
MatInputModule,
22+
MatButtonModule,
23+
EmailValidationDirective,
24+
AlertComponent,
25+
UserPasswordComponent,
26+
],
27+
})
28+
export class SetupComponent {
29+
private _selfhostedService = inject(SelfhostedService);
30+
private _router = inject(Router);
31+
32+
protected email = signal('');
33+
protected password = signal('');
34+
protected submitting = signal(false);
35+
36+
onEmailChange(value: string): void {
37+
this.email.set(value);
38+
}
39+
40+
onPasswordChange(value: string): void {
41+
this.password.set(value);
42+
}
43+
44+
createAdminAccount(): void {
45+
if (!this.email() || !this.password()) {
46+
return;
47+
}
48+
49+
this.submitting.set(true);
50+
51+
this._selfhostedService
52+
.createInitialUser({
53+
email: this.email(),
54+
password: this.password(),
55+
})
56+
.subscribe({
57+
next: () => {
58+
this.submitting.set(false);
59+
this._router.navigate(['/login']);
60+
},
61+
error: () => {
62+
this.submitting.set(false);
63+
},
64+
complete: () => {
65+
this.submitting.set(false);
66+
},
67+
});
68+
}
69+
}

0 commit comments

Comments
 (0)