mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-03-28 19:11:29 -04:00
218 lines
5.8 KiB
TypeScript
218 lines
5.8 KiB
TypeScript
import { Component, ChangeDetectionStrategy, inject, signal, viewChild, effect, afterNextRender, OnInit, OnDestroy } from '@angular/core';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { Router } from '@angular/router';
|
|
import { ButtonComponent, InputComponent, SpinnerComponent } from '@ui';
|
|
import { AuthService } from '@core/auth/auth.service';
|
|
import { ApiError } from '@core/interceptors/error.interceptor';
|
|
|
|
type LoginView = 'credentials' | '2fa' | 'recovery';
|
|
|
|
@Component({
|
|
selector: 'app-login',
|
|
standalone: true,
|
|
imports: [FormsModule, ButtonComponent, InputComponent, SpinnerComponent],
|
|
templateUrl: './login.component.html',
|
|
styleUrl: './login.component.scss',
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
})
|
|
export class LoginComponent implements OnInit, OnDestroy {
|
|
private readonly auth = inject(AuthService);
|
|
private readonly router = inject(Router);
|
|
|
|
view = signal<LoginView>('credentials');
|
|
loading = signal(false);
|
|
error = signal('');
|
|
|
|
// Credentials
|
|
username = signal('');
|
|
password = signal('');
|
|
|
|
// 2FA
|
|
loginToken = signal('');
|
|
totpCode = signal('');
|
|
recoveryCode = signal('');
|
|
|
|
// Retry countdown
|
|
retryCountdown = signal(0);
|
|
private countdownTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
// Plex
|
|
plexLinked = this.auth.plexLinked;
|
|
plexLoading = signal(false);
|
|
plexPinId = signal(0);
|
|
|
|
// Auto-focus refs
|
|
usernameInput = viewChild<InputComponent>('usernameInput');
|
|
totpInput = viewChild<InputComponent>('totpInput');
|
|
recoveryInput = viewChild<InputComponent>('recoveryInput');
|
|
|
|
constructor() {
|
|
// Auto-focus username input on initial render
|
|
afterNextRender(() => {
|
|
this.usernameInput()?.focus();
|
|
});
|
|
|
|
// Auto-focus on view change
|
|
effect(() => {
|
|
const currentView = this.view();
|
|
if (currentView === '2fa') {
|
|
setTimeout(() => this.totpInput()?.focus());
|
|
} else if (currentView === 'recovery') {
|
|
setTimeout(() => this.recoveryInput()?.focus());
|
|
}
|
|
});
|
|
}
|
|
|
|
ngOnInit(): void {
|
|
this.auth.checkStatus().subscribe();
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.clearCountdown();
|
|
if (this.plexPollTimer) {
|
|
clearInterval(this.plexPollTimer);
|
|
}
|
|
}
|
|
|
|
submitLogin(): void {
|
|
this.loading.set(true);
|
|
this.error.set('');
|
|
|
|
this.auth.login(this.username(), this.password()).subscribe({
|
|
next: (result) => {
|
|
if (result.requiresTwoFactor && result.loginToken) {
|
|
this.loginToken.set(result.loginToken);
|
|
this.view.set('2fa');
|
|
} else if (!result.requiresTwoFactor) {
|
|
// 2FA not enabled — tokens already handled by AuthService
|
|
this.router.navigate(['/dashboard']);
|
|
}
|
|
this.loading.set(false);
|
|
},
|
|
error: (err) => {
|
|
this.error.set(err.message || 'Invalid credentials');
|
|
this.loading.set(false);
|
|
|
|
const retryAfter = (err as ApiError).retryAfterSeconds;
|
|
if (retryAfter && retryAfter > 0) {
|
|
this.startCountdown(retryAfter);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
submit2fa(): void {
|
|
this.loading.set(true);
|
|
this.error.set('');
|
|
|
|
this.auth.verify2fa(this.loginToken(), this.totpCode()).subscribe({
|
|
next: () => {
|
|
this.router.navigate(['/dashboard']);
|
|
},
|
|
error: (err) => {
|
|
this.error.set(err.message || 'Invalid code');
|
|
this.loading.set(false);
|
|
},
|
|
});
|
|
}
|
|
|
|
submitRecoveryCode(): void {
|
|
this.loading.set(true);
|
|
this.error.set('');
|
|
|
|
this.auth.verify2fa(this.loginToken(), this.recoveryCode(), true).subscribe({
|
|
next: () => {
|
|
this.router.navigate(['/dashboard']);
|
|
},
|
|
error: (err) => {
|
|
this.error.set(err.message || 'Invalid recovery code');
|
|
this.loading.set(false);
|
|
},
|
|
});
|
|
}
|
|
|
|
useRecoveryCode(): void {
|
|
this.view.set('recovery');
|
|
this.error.set('');
|
|
}
|
|
|
|
backTo2fa(): void {
|
|
this.view.set('2fa');
|
|
this.error.set('');
|
|
}
|
|
|
|
backToCredentials(): void {
|
|
this.view.set('credentials');
|
|
this.error.set('');
|
|
this.loginToken.set('');
|
|
}
|
|
|
|
private plexPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
startPlexLogin(): void {
|
|
this.plexLoading.set(true);
|
|
this.error.set('');
|
|
|
|
this.auth.requestPlexPin().subscribe({
|
|
next: (result) => {
|
|
this.plexPinId.set(result.pinId);
|
|
window.open(result.authUrl, '_blank');
|
|
this.pollPlexPin();
|
|
},
|
|
error: (err) => {
|
|
this.error.set(err.message || 'Failed to start Plex login');
|
|
this.plexLoading.set(false);
|
|
},
|
|
});
|
|
}
|
|
|
|
private pollPlexPin(): void {
|
|
let attempts = 0;
|
|
this.plexPollTimer = setInterval(() => {
|
|
attempts++;
|
|
if (attempts > 60) {
|
|
clearInterval(this.plexPollTimer!);
|
|
this.plexLoading.set(false);
|
|
this.error.set('Plex authorization timed out');
|
|
return;
|
|
}
|
|
|
|
this.auth.verifyPlexPin(this.plexPinId()).subscribe({
|
|
next: (result) => {
|
|
if (result.completed) {
|
|
clearInterval(this.plexPollTimer!);
|
|
this.plexLoading.set(false);
|
|
this.router.navigate(['/dashboard']);
|
|
}
|
|
},
|
|
error: (err) => {
|
|
clearInterval(this.plexPollTimer!);
|
|
this.plexLoading.set(false);
|
|
this.error.set(err.message || 'Plex authorization failed');
|
|
},
|
|
});
|
|
}, 2000);
|
|
}
|
|
|
|
private startCountdown(seconds: number): void {
|
|
this.clearCountdown();
|
|
this.retryCountdown.set(seconds);
|
|
this.countdownTimer = setInterval(() => {
|
|
const current = this.retryCountdown();
|
|
if (current <= 1) {
|
|
this.clearCountdown();
|
|
} else {
|
|
this.retryCountdown.set(current - 1);
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
private clearCountdown(): void {
|
|
this.retryCountdown.set(0);
|
|
if (this.countdownTimer) {
|
|
clearInterval(this.countdownTimer);
|
|
this.countdownTimer = null;
|
|
}
|
|
}
|
|
}
|