Skip to content

Commit da3a51d

Browse files
guguclaude
andauthored
Feat/selfhosted setup page (#1556)
* 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 <[email protected]> * refactor: remove RxJS, use native fetch and async/await - Replace HttpClient with native fetch API in SelfhostedService - Convert Observable methods to async/await Promises - Update guards to use async functions - Update SetupComponent to use async/await - Update tests to work with Promise-based methods Co-Authored-By: Claude Opus 4.5 <[email protected]> --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 5422f64 commit da3a51d

File tree

5 files changed

+89
-95
lines changed

5 files changed

+89
-95
lines changed

frontend/src/app/components/setup/setup.component.spec.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
66
import { provideRouter, Router } from '@angular/router';
77
import { IPasswordStrengthMeterService } from 'angular-password-strength-meter';
88
import { Angulartics2Module } from 'angulartics2';
9-
import { of } from 'rxjs';
109
import { SelfhostedService } from 'src/app/services/selfhosted.service';
1110
import { SetupComponent } from './setup.component';
1211

@@ -53,39 +52,37 @@ describe('SetupComponent', () => {
5352
expect(testable.password()).toBe('SecurePass123');
5453
});
5554

56-
it('should not submit if email is empty', () => {
55+
it('should not submit if email is empty', async () => {
5756
const testable = component as SetupComponentTestable;
5857
const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser');
5958

6059
testable.email.set('');
6160
testable.password.set('SecurePass123');
6261

63-
component.createAdminAccount();
62+
await component.createAdminAccount();
6463
expect(fakeCreateInitialUser).not.toHaveBeenCalled();
6564
});
6665

67-
it('should not submit if password is empty', () => {
66+
it('should not submit if password is empty', async () => {
6867
const testable = component as SetupComponentTestable;
6968
const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser');
7069

7170
testable.email.set('[email protected]');
7271
testable.password.set('');
7372

74-
component.createAdminAccount();
73+
await component.createAdminAccount();
7574
expect(fakeCreateInitialUser).not.toHaveBeenCalled();
7675
});
7776

78-
it('should create admin account and navigate to login on success', () => {
77+
it('should create admin account and navigate to login on success', async () => {
7978
const testable = component as SetupComponentTestable;
80-
const fakeCreateInitialUser = vi
81-
.spyOn(selfhostedService, 'createInitialUser')
82-
.mockReturnValue(of({ success: true }));
79+
const fakeCreateInitialUser = vi.spyOn(selfhostedService, 'createInitialUser').mockResolvedValue({ success: true });
8380
const fakeNavigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
8481

8582
testable.email.set('[email protected]');
8683
testable.password.set('SecurePass123');
8784

88-
component.createAdminAccount();
85+
await component.createAdminAccount();
8986

9087
expect(fakeCreateInitialUser).toHaveBeenCalledWith({
9188

frontend/src/app/components/setup/setup.component.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,29 +41,23 @@ export class SetupComponent {
4141
this.password.set(value);
4242
}
4343

44-
createAdminAccount(): void {
44+
async createAdminAccount(): Promise<void> {
4545
if (!this.email() || !this.password()) {
4646
return;
4747
}
4848

4949
this.submitting.set(true);
5050

51-
this._selfhostedService
52-
.createInitialUser({
51+
try {
52+
await this._selfhostedService.createInitialUser({
5353
email: this.email(),
5454
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-
},
6755
});
56+
this._router.navigate(['/login']);
57+
} catch {
58+
// Error handling is done in the service
59+
} finally {
60+
this.submitting.set(false);
61+
}
6862
}
6963
}
Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,36 @@
11
import { inject } from '@angular/core';
22
import { CanActivateFn, Router } from '@angular/router';
3-
import { map, of } from 'rxjs';
43
import { SelfhostedService } from '../services/selfhosted.service';
54

65
/**
76
* Guard that protects the /login route.
87
* In self-hosted mode, redirects to /setup if the app is not configured.
98
* In SaaS mode, allows access immediately.
109
*/
11-
export const configurationGuard: CanActivateFn = () => {
10+
export const configurationGuard: CanActivateFn = async () => {
1211
const selfhostedService = inject(SelfhostedService);
1312
const router = inject(Router);
1413

1514
// In SaaS mode, always allow access to login
1615
if (!selfhostedService.isSelfHosted()) {
17-
return of(true);
16+
return true;
1817
}
1918

2019
// If we already know the configuration state, use it
2120
const currentState = selfhostedService.isConfigured();
2221
if (currentState !== null) {
2322
if (currentState) {
24-
return of(true);
23+
return true;
2524
} else {
26-
return of(router.createUrlTree(['/setup']));
25+
return router.createUrlTree(['/setup']);
2726
}
2827
}
2928

3029
// Check configuration from the server
31-
return selfhostedService.checkConfiguration().pipe(
32-
map((response) => {
33-
if (response.isConfigured) {
34-
return true;
35-
} else {
36-
return router.createUrlTree(['/setup']);
37-
}
38-
}),
39-
);
30+
const response = await selfhostedService.checkConfiguration();
31+
if (response.isConfigured) {
32+
return true;
33+
} else {
34+
return router.createUrlTree(['/setup']);
35+
}
4036
};
Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { inject } from '@angular/core';
22
import { CanActivateFn, Router } from '@angular/router';
3-
import { map, of } from 'rxjs';
43
import { SelfhostedService } from '../services/selfhosted.service';
54

65
/**
@@ -9,37 +8,34 @@ import { SelfhostedService } from '../services/selfhosted.service';
98
* - In self-hosted mode, redirects to /login if already configured
109
* - Allows access to /setup only if self-hosted and not configured
1110
*/
12-
export const setupGuard: CanActivateFn = () => {
11+
export const setupGuard: CanActivateFn = async () => {
1312
const selfhostedService = inject(SelfhostedService);
1413
const router = inject(Router);
1514

1615
// In SaaS mode, redirect to login (setup is only for self-hosted)
1716
if (!selfhostedService.isSelfHosted()) {
18-
return of(router.createUrlTree(['/login']));
17+
return router.createUrlTree(['/login']);
1918
}
2019

2120
// If we already know the configuration state, use it
2221
const currentState = selfhostedService.isConfigured();
2322
if (currentState !== null) {
2423
if (currentState) {
2524
// Already configured, redirect to login
26-
return of(router.createUrlTree(['/login']));
25+
return router.createUrlTree(['/login']);
2726
} else {
2827
// Not configured, allow access to setup
29-
return of(true);
28+
return true;
3029
}
3130
}
3231

3332
// Check configuration from the server
34-
return selfhostedService.checkConfiguration().pipe(
35-
map((response) => {
36-
if (response.isConfigured) {
37-
// Already configured, redirect to login
38-
return router.createUrlTree(['/login']);
39-
} else {
40-
// Not configured, allow access to setup
41-
return true;
42-
}
43-
}),
44-
);
33+
const response = await selfhostedService.checkConfiguration();
34+
if (response.isConfigured) {
35+
// Already configured, redirect to login
36+
return router.createUrlTree(['/login']);
37+
} else {
38+
// Not configured, allow access to setup
39+
return true;
40+
}
4541
};

frontend/src/app/services/selfhosted.service.ts

Lines changed: 51 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import { HttpClient } from '@angular/common/http';
21
import { computed, Injectable, inject, signal } from '@angular/core';
3-
import { catchError, EMPTY, map, Observable, tap } from 'rxjs';
42
import { environment } from 'src/environments/environment';
53
import { AlertActionType, AlertType } from '../models/alert';
64
import { NotificationsService } from './notifications.service';
@@ -22,7 +20,6 @@ export interface CreateInitialUserResponse {
2220
providedIn: 'root',
2321
})
2422
export class SelfhostedService {
25-
private _http = inject(HttpClient);
2623
private _notifications = inject(NotificationsService);
2724

2825
private _isConfigured = signal<boolean | null>(null);
@@ -32,46 +29,60 @@ export class SelfhostedService {
3229
public readonly isCheckingConfiguration = this._isCheckingConfiguration.asReadonly();
3330
public readonly isSelfHosted = computed(() => !(environment as any).saas);
3431

35-
checkConfiguration(): Observable<IsConfiguredResponse> {
32+
async checkConfiguration(): Promise<IsConfiguredResponse> {
3633
this._isCheckingConfiguration.set(true);
37-
return this._http.get<IsConfiguredResponse>('/selfhosted/is-configured').pipe(
38-
tap((response) => {
39-
this._isConfigured.set(response.isConfigured);
40-
this._isCheckingConfiguration.set(false);
41-
}),
42-
catchError((err) => {
43-
console.error('Failed to check configuration:', err);
44-
this._isCheckingConfiguration.set(false);
45-
// If the endpoint fails, assume configured to avoid blocking login
46-
this._isConfigured.set(true);
47-
return EMPTY;
48-
}),
49-
);
34+
try {
35+
const response = await fetch('/api/selfhosted/is-configured');
36+
if (!response.ok) {
37+
throw new Error(`HTTP error: ${response.status}`);
38+
}
39+
const data: IsConfiguredResponse = await response.json();
40+
this._isConfigured.set(data.isConfigured);
41+
this._isCheckingConfiguration.set(false);
42+
return data;
43+
} catch (err) {
44+
console.error('Failed to check configuration:', err);
45+
this._isCheckingConfiguration.set(false);
46+
// If the endpoint fails, assume configured to avoid blocking login
47+
this._isConfigured.set(true);
48+
return { isConfigured: true };
49+
}
5050
}
5151

52-
createInitialUser(userData: CreateInitialUserRequest): Observable<CreateInitialUserResponse> {
53-
return this._http.post<CreateInitialUserResponse>('/selfhosted/initial-user', userData).pipe(
54-
map((res) => {
55-
this._notifications.showSuccessSnackbar('Admin account created successfully.');
56-
this._isConfigured.set(true);
57-
return res;
58-
}),
59-
catchError((err) => {
60-
console.error('Failed to create initial user:', err);
61-
this._notifications.showAlert(
62-
AlertType.Error,
63-
{ abstract: err.error?.message || err.message, details: err.error?.originalMessage },
64-
[
65-
{
66-
type: AlertActionType.Button,
67-
caption: 'Dismiss',
68-
action: () => this._notifications.dismissAlert(),
69-
},
70-
],
71-
);
72-
return EMPTY;
73-
}),
74-
);
52+
async createInitialUser(userData: CreateInitialUserRequest): Promise<CreateInitialUserResponse> {
53+
try {
54+
const response = await fetch('/api/selfhosted/initial-user', {
55+
method: 'POST',
56+
headers: {
57+
'Content-Type': 'application/json',
58+
},
59+
body: JSON.stringify(userData),
60+
});
61+
62+
if (!response.ok) {
63+
const errorData = await response.json().catch(() => ({}));
64+
throw { error: errorData, message: `HTTP error: ${response.status}` };
65+
}
66+
67+
const data: CreateInitialUserResponse = await response.json();
68+
this._notifications.showSuccessSnackbar('Admin account created successfully.');
69+
this._isConfigured.set(true);
70+
return data;
71+
} catch (err: any) {
72+
console.error('Failed to create initial user:', err);
73+
this._notifications.showAlert(
74+
AlertType.Error,
75+
{ abstract: err.error?.message || err.message, details: err.error?.originalMessage },
76+
[
77+
{
78+
type: AlertActionType.Button,
79+
caption: 'Dismiss',
80+
action: () => this._notifications.dismissAlert(),
81+
},
82+
],
83+
);
84+
throw err;
85+
}
7586
}
7687

7788
resetConfigurationState(): void {

0 commit comments

Comments
 (0)