feat: add self-hosted setup page for initial admin configuration#1555
feat: add self-hosted setup page for initial admin configuration#1555
Conversation
gugu
commented
Feb 3, 2026
- 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
- 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 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a self-hosted setup flow that allows users to create an initial admin account on first launch. The implementation includes backend integration via a new SelfhostedService, route guards to control access to setup and login pages based on configuration state, and a setup UI component matching the login page's design.
Changes:
- Added SelfhostedService with configuration checking and initial user creation endpoints
- Added guards (setupGuard and configurationGuard) to manage routing between /setup and /login based on configuration state
- Added SetupComponent with form UI for admin account creation
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/src/app/services/selfhosted.service.ts | New service managing self-hosted configuration state with signals and HTTP endpoints for checking configuration and creating initial admin user |
| frontend/src/app/guards/setup.guard.ts | New guard protecting /setup route, allowing access only for unconfigured self-hosted instances |
| frontend/src/app/guards/configuration.guard.ts | New guard protecting /login route, redirecting to /setup if self-hosted instance is not configured |
| frontend/src/app/components/setup/setup.component.ts | New component handling admin account creation form logic with email/password inputs |
| frontend/src/app/components/setup/setup.component.spec.ts | Unit tests for SetupComponent covering basic functionality and validation |
| frontend/src/app/components/setup/setup.component.html | Template for setup page with email and password fields matching login page design |
| frontend/src/app/components/setup/setup.component.css | Styles for setup page with responsive design |
| frontend/src/app/app-routing.module.ts | Updated routing configuration to include /setup route with guards |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| next: () => { | ||
| this.submitting.set(false); | ||
| this._router.navigate(['/login']); |
There was a problem hiding this comment.
The router.navigate() call lacks error handling. If navigation fails (e.g., if the configurationGuard prevents access), the user will remain on the setup page without feedback. Consider handling the navigation promise and providing appropriate feedback if navigation fails.
| 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); | |
| } |
| } | ||
|
|
||
| createInitialUser(userData: CreateInitialUserRequest): Observable<CreateInitialUserResponse> { | ||
| return this._http.post<CreateInitialUserResponse>('/selfhosted/initial-user', userData).pipe( |
There was a problem hiding this comment.
The HTTP endpoint '/selfhosted/initial-user' lacks the API prefix. Based on the codebase pattern, HTTP requests should be prefixed with '/api' or use the environment's apiRoot. For consistency with other endpoints like '/user/login' in auth.service.ts, consider using a full path or ensure the interceptor handles this path correctly.
| } | ||
|
|
||
| // Check configuration from the server | ||
| return selfhostedService.checkConfiguration().pipe( |
There was a problem hiding this comment.
There's a potential race condition if both setupGuard and configurationGuard check configuration simultaneously. If a user navigates directly to /setup while another process checks /login, both guards might call checkConfiguration() concurrently. While RxJS handles this, the guards should potentially share the same observable to avoid duplicate HTTP requests. Consider adding a shareReplay operator to the checkConfiguration() observable or implementing a caching mechanism.
| 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']); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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.
| 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; | ||
| } | ||
| }), | ||
| ); | ||
| }; |
There was a problem hiding this comment.
The setupGuard lacks test coverage. Given that the codebase has test files for other guards (e.g., auth.guard.spec.ts), and this guard implements critical routing logic that determines access to the setup page, it should have corresponding tests. Test cases should cover: (1) SaaS mode redirects to login, (2) self-hosted + configured redirects to login, (3) self-hosted + not configured allows access, and (4) behavior when configuration state is unknown.
| 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); |
There was a problem hiding this comment.
When checkConfiguration() fails and sets isConfigured to true (line 46), subsequent navigation attempts will bypass the setup page even if the system is actually not configured. If a user later tries to access /setup after the configuration check failed, they'll be incorrectly redirected to /login. Consider implementing a retry mechanism or explicitly handling the error state separately from the configured state, using a tri-state value like null (unknown), true (configured), or false (not configured), with 'error' as a fourth state.
|
|
||
| checkConfiguration(): Observable<IsConfiguredResponse> { | ||
| this._isCheckingConfiguration.set(true); | ||
| return this._http.get<IsConfiguredResponse>('/selfhosted/is-configured').pipe( |
There was a problem hiding this comment.
The HTTP endpoint '/selfhosted/is-configured' lacks the API prefix. Based on the codebase pattern, HTTP requests should be prefixed with '/api' or use the environment's apiRoot. For example, auth.service.ts uses '/user/login' which gets prefixed via the token interceptor. However, for consistency and clarity, consider using a full path like environment.apiRoot + '/selfhosted/is-configured' or ensure the interceptor handles this path correctly.
| // If the endpoint fails, assume configured to avoid blocking login | ||
| this._isConfigured.set(true); |
There was a problem hiding this comment.
When the configuration check fails, the service assumes the system is configured and returns EMPTY. This silent fallback could hide critical backend issues. If the backend is down or misconfigured, users might be unable to access either the setup page or login page. Consider throwing an error or showing a notification to alert users to the problem, rather than assuming a default state.
| // If the endpoint fails, assume configured to avoid blocking login | |
| this._isConfigured.set(true); | |
| // If the endpoint fails, assume configured to avoid blocking login, | |
| // but also show a visible error so backend issues are not silent. | |
| this._isConfigured.set(true); | |
| this._notifications.showAlert( | |
| AlertType.Error, | |
| { | |
| abstract: 'Unable to verify server configuration.', | |
| details: err?.error?.message || err?.message, | |
| }, | |
| [ | |
| { | |
| type: AlertActionType.Button, | |
| caption: 'Dismiss', | |
| action: () => this._notifications.dismissAlert(), | |
| }, | |
| ], | |
| ); |
| export class SelfhostedService { | ||
| private _http = inject(HttpClient); | ||
| private _notifications = inject(NotificationsService); | ||
|
|
||
| private _isConfigured = signal<boolean | null>(null); | ||
| private _isCheckingConfiguration = signal<boolean>(false); | ||
|
|
||
| public readonly isConfigured = this._isConfigured.asReadonly(); | ||
| public readonly isCheckingConfiguration = this._isCheckingConfiguration.asReadonly(); | ||
| public readonly isSelfHosted = computed(() => !(environment as any).saas); | ||
|
|
||
| checkConfiguration(): Observable<IsConfiguredResponse> { | ||
| this._isCheckingConfiguration.set(true); | ||
| return this._http.get<IsConfiguredResponse>('/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<CreateInitialUserResponse> { | ||
| return this._http.post<CreateInitialUserResponse>('/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); | ||
| } | ||
| } |
There was a problem hiding this comment.
The SelfhostedService lacks test coverage. Other services in the codebase (e.g., auth.service.spec.ts, user.service.spec.ts) have comprehensive test suites. Given that this service manages critical configuration state and API calls, it should have tests covering: (1) checkConfiguration success and failure scenarios, (2) createInitialUser success and failure scenarios, (3) signal state management, and (4) resetConfigurationState functionality.
| createAdminAccount(): void { | ||
| if (!this.email() || !this.password()) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
The form validation in the template allows submission when pristine is false, but the createAdminAccount method only checks if email and password are truthy. This creates a gap where a user could potentially submit an invalid email (if the email validator hasn't caught it yet) or a weak password. While the template validation prevents submission via the disabled button attribute, the method itself doesn't validate the data before sending it to the API. Consider adding explicit validation in the method or ensuring the method can only be called when the form is valid.