Skip to content

Commit 9302951

Browse files
committed
Merge branch 'main' into backend_change_node_version
2 parents 0e02b3f + da3a51d commit 9302951

File tree

8 files changed

+499
-0
lines changed

8 files changed

+499
-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: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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 { SelfhostedService } from 'src/app/services/selfhosted.service';
10+
import { SetupComponent } from './setup.component';
11+
12+
type SetupComponentTestable = SetupComponent & {
13+
email: ReturnType<typeof import('@angular/core').signal<string>>;
14+
password: ReturnType<typeof import('@angular/core').signal<string>>;
15+
submitting: ReturnType<typeof import('@angular/core').signal<boolean>>;
16+
};
17+
18+
describe('SetupComponent', () => {
19+
let component: SetupComponent;
20+
let fixture: ComponentFixture<SetupComponent>;
21+
let selfhostedService: SelfhostedService;
22+
let router: Router;
23+
24+
beforeEach(async () => {
25+
await TestBed.configureTestingModule({
26+
imports: [FormsModule, MatSnackBarModule, SetupComponent, BrowserAnimationsModule, Angulartics2Module.forRoot()],
27+
providers: [provideHttpClient(), provideRouter([]), { provide: IPasswordStrengthMeterService, useValue: {} }],
28+
}).compileComponents();
29+
});
30+
31+
beforeEach(() => {
32+
fixture = TestBed.createComponent(SetupComponent);
33+
component = fixture.componentInstance;
34+
selfhostedService = TestBed.inject(SelfhostedService);
35+
router = TestBed.inject(Router);
36+
fixture.detectChanges();
37+
});
38+
39+
it('should create', () => {
40+
expect(component).toBeTruthy();
41+
});
42+
43+
it('should update email signal on change', () => {
44+
const testable = component as SetupComponentTestable;
45+
component.onEmailChange('[email protected]');
46+
expect(testable.email()).toBe('[email protected]');
47+
});
48+
49+
it('should update password signal on change', () => {
50+
const testable = component as SetupComponentTestable;
51+
component.onPasswordChange('SecurePass123');
52+
expect(testable.password()).toBe('SecurePass123');
53+
});
54+
55+
it('should not submit if email is empty', async () => {
56+
const testable = component as SetupComponentTestable;
57+
const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser');
58+
59+
testable.email.set('');
60+
testable.password.set('SecurePass123');
61+
62+
await component.createAdminAccount();
63+
expect(fakeCreateInitialUser).not.toHaveBeenCalled();
64+
});
65+
66+
it('should not submit if password is empty', async () => {
67+
const testable = component as SetupComponentTestable;
68+
const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser');
69+
70+
testable.email.set('[email protected]');
71+
testable.password.set('');
72+
73+
await component.createAdminAccount();
74+
expect(fakeCreateInitialUser).not.toHaveBeenCalled();
75+
});
76+
77+
it('should create admin account and navigate to login on success', async () => {
78+
const testable = component as SetupComponentTestable;
79+
const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser').mockResolvedValue({ success: true });
80+
const fakeNavigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
81+
82+
testable.email.set('[email protected]');
83+
testable.password.set('SecurePass123');
84+
85+
await component.createAdminAccount();
86+
87+
expect(fakeCreateInitialUser).toHaveBeenCalledWith({
88+
89+
password: 'SecurePass123',
90+
});
91+
expect(testable.submitting()).toBe(false);
92+
expect(fakeNavigate).toHaveBeenCalledWith(['/login']);
93+
});
94+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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+
async createAdminAccount(): Promise<void> {
45+
if (!this.email() || !this.password()) {
46+
return;
47+
}
48+
49+
this.submitting.set(true);
50+
51+
try {
52+
await this._selfhostedService.createInitialUser({
53+
email: this.email(),
54+
password: this.password(),
55+
});
56+
this._router.navigate(['/login']);
57+
} catch {
58+
// Error handling is done in the service
59+
} finally {
60+
this.submitting.set(false);
61+
}
62+
}
63+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { inject } from '@angular/core';
2+
import { CanActivateFn, Router } from '@angular/router';
3+
import { SelfhostedService } from '../services/selfhosted.service';
4+
5+
/**
6+
* Guard that protects the /login route.
7+
* In self-hosted mode, redirects to /setup if the app is not configured.
8+
* In SaaS mode, allows access immediately.
9+
*/
10+
export const configurationGuard: CanActivateFn = async () => {
11+
const selfhostedService = inject(SelfhostedService);
12+
const router = inject(Router);
13+
14+
// In SaaS mode, always allow access to login
15+
if (!selfhostedService.isSelfHosted()) {
16+
return true;
17+
}
18+
19+
// If we already know the configuration state, use it
20+
const currentState = selfhostedService.isConfigured();
21+
if (currentState !== null) {
22+
if (currentState) {
23+
return true;
24+
} else {
25+
return router.createUrlTree(['/setup']);
26+
}
27+
}
28+
29+
// Check configuration from the server
30+
const response = await selfhostedService.checkConfiguration();
31+
if (response.isConfigured) {
32+
return true;
33+
} else {
34+
return router.createUrlTree(['/setup']);
35+
}
36+
};

0 commit comments

Comments
 (0)