diff --git a/code/frontend/src/app/features/auth/setup/setup.component.ts b/code/frontend/src/app/features/auth/setup/setup.component.ts index f3344580..8a582f63 100644 --- a/code/frontend/src/app/features/auth/setup/setup.component.ts +++ b/code/frontend/src/app/features/auth/setup/setup.component.ts @@ -1,4 +1,4 @@ -import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChild, effect, afterNextRender } from '@angular/core'; +import { Component, ChangeDetectionStrategy, inject, signal, computed, viewChild, effect, afterNextRender, OnDestroy } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { ButtonComponent, InputComponent, SpinnerComponent } from '@ui'; @@ -17,7 +17,7 @@ import { QRCodeComponent } from 'angularx-qrcode'; changeDetection: ChangeDetectionStrategy.OnPush, viewProviders: [provideIcons({ tablerCheck, tablerCopy, tablerShieldLock })], }) -export class SetupComponent { +export class SetupComponent implements OnDestroy { private readonly auth = inject(AuthService); private readonly router = inject(Router); private readonly toast = inject(ToastService); @@ -181,6 +181,12 @@ export class SetupComponent { private plexPollTimer: ReturnType | null = null; + ngOnDestroy(): void { + if (this.plexPollTimer) { + clearInterval(this.plexPollTimer); + } + } + private pollPlexPin(): void { let attempts = 0; this.plexPollTimer = setInterval(() => { diff --git a/code/frontend/src/app/features/settings/account/account-settings.component.html b/code/frontend/src/app/features/settings/account/account-settings.component.html index 21a748f0..b328aded 100644 --- a/code/frontend/src/app/features/settings/account/account-settings.component.html +++ b/code/frontend/src/app/features/settings/account/account-settings.component.html @@ -77,6 +77,21 @@ @if (newRecoveryCodes().length > 0) {
+

New Authenticator Setup

+

Scan this QR code with your authenticator app to complete the setup.

+
+
+ +
+
+ Can't scan? Enter manually +
+

Secret key:

+ {{ newTotpSecret() }} +
+
+
+

New Recovery Codes

Save these codes in a secure location. Each code can only be used once.

diff --git a/code/frontend/src/app/features/settings/account/account-settings.component.scss b/code/frontend/src/app/features/settings/account/account-settings.component.scss index 335c113b..ee4d7985 100644 --- a/code/frontend/src/app/features/settings/account/account-settings.component.scss +++ b/code/frontend/src/app/features/settings/account/account-settings.component.scss @@ -42,6 +42,58 @@ } } +// QR code (2FA regeneration) +.qr-section { + margin-bottom: var(--space-4); +} + +.qr-code-wrapper { + display: flex; + justify-content: center; + margin-bottom: var(--space-4); + + qrcode { + background: #ffffff; + padding: var(--space-3); + border-radius: var(--radius-lg); + } +} + +.qr-manual-entry { + font-size: var(--font-size-sm); + color: var(--text-secondary); + + summary { + cursor: pointer; + text-align: center; + margin-bottom: var(--space-2); + + &:hover { + color: var(--text-primary); + } + } +} + +.qr-manual-content { + background: var(--surface-secondary); + border-radius: var(--radius-md); + padding: var(--space-3); + text-align: center; +} + +.qr-manual-label { + font-size: var(--font-size-xs); + color: var(--text-secondary); + margin-bottom: var(--space-1); +} + +.qr-secret { + font-size: var(--font-size-sm); + color: var(--text-primary); + font-family: monospace; + word-break: break-all; +} + // Recovery codes .recovery-section { margin-top: var(--space-2); diff --git a/code/frontend/src/app/features/settings/account/account-settings.component.ts b/code/frontend/src/app/features/settings/account/account-settings.component.ts index 8835ecbd..79a51779 100644 --- a/code/frontend/src/app/features/settings/account/account-settings.component.ts +++ b/code/frontend/src/app/features/settings/account/account-settings.component.ts @@ -1,4 +1,4 @@ -import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit } from '@angular/core'; +import { Component, ChangeDetectionStrategy, inject, signal, computed, OnInit, OnDestroy } from '@angular/core'; import { PageHeaderComponent } from '@layout/page-header/page-header.component'; import { CardComponent, ButtonComponent, InputComponent, SpinnerComponent, @@ -8,19 +8,20 @@ import { AccountApi, AccountInfo } from '@core/api/account.api'; import { ToastService } from '@core/services/toast.service'; import { ConfirmService } from '@core/services/confirm.service'; import { DeferredLoader } from '@shared/utils/loading.util'; +import { QRCodeComponent } from 'angularx-qrcode'; @Component({ selector: 'app-account-settings', standalone: true, imports: [ PageHeaderComponent, CardComponent, ButtonComponent, InputComponent, - SpinnerComponent, EmptyStateComponent, LoadingStateComponent, + SpinnerComponent, EmptyStateComponent, LoadingStateComponent, QRCodeComponent, ], templateUrl: './account-settings.component.html', styleUrl: './account-settings.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class AccountSettingsComponent implements OnInit { +export class AccountSettingsComponent implements OnInit, OnDestroy { private readonly api = inject(AccountApi); private readonly toast = inject(ToastService); private readonly confirmService = inject(ConfirmService); @@ -55,6 +56,8 @@ export class AccountSettingsComponent implements OnInit { readonly twoFaCode = signal(''); readonly regenerating2fa = signal(false); readonly newRecoveryCodes = signal([]); + readonly newQrCodeUri = signal(''); + readonly newTotpSecret = signal(''); // API key readonly apiKey = signal(''); @@ -70,6 +73,12 @@ export class AccountSettingsComponent implements OnInit { this.loadAccount(); } + ngOnDestroy(): void { + if (this.plexPollTimer) { + clearInterval(this.plexPollTimer); + } + } + private loadAccount(): void { this.loader.start(); this.api.getInfo().subscribe({ @@ -132,12 +141,14 @@ export class AccountSettingsComponent implements OnInit { this.regenerating2fa.set(true); this.api.regenerate2fa({ - currentPassword: this.twoFaPassword(), + password: this.twoFaPassword(), totpCode: this.twoFaCode(), }).subscribe({ next: (result) => { this.newRecoveryCodes.set(result.recoveryCodes); - this.toast.success('2FA regenerated. Save your new recovery codes!'); + this.newQrCodeUri.set(result.qrCodeUri); + this.newTotpSecret.set(result.secret); + this.toast.success('2FA regenerated. Scan the QR code and save your recovery codes!'); this.twoFaPassword.set(''); this.twoFaCode.set(''); this.regenerating2fa.set(false); @@ -157,6 +168,8 @@ export class AccountSettingsComponent implements OnInit { dismissRecoveryCodes(): void { this.newRecoveryCodes.set([]); + this.newQrCodeUri.set(''); + this.newTotpSecret.set(''); } // API key @@ -240,6 +253,11 @@ export class AccountSettingsComponent implements OnInit { this.loadAccount(); } }, + error: () => { + clearInterval(this.plexPollTimer!); + this.plexLinking.set(false); + this.toast.error('Plex linking failed'); + }, }); }, 2000); }