mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Feature: two-factor authentication (#8012)
This commit is contained in:
@@ -32,6 +32,20 @@
|
||||
</div>
|
||||
|
||||
<pngx-input-select i18n-title title="Groups" [items]="groups" multiple="true" formControlName="groups"></pngx-input-select>
|
||||
|
||||
@if (object?.is_mfa_enabled && currentUserIsSuperUser) {
|
||||
<label class="form-label" i18n>Two-factor Authentication</label>
|
||||
<pngx-confirm-button
|
||||
label="Disable Two-factor Authentication"
|
||||
i18n-label
|
||||
title="Disable Two-factor Authentication"
|
||||
i18n-title
|
||||
buttonClasses="btn-outline-danger btn-sm"
|
||||
iconName="trash"
|
||||
[disabled]="totpLoading"
|
||||
(confirm)="deactivateTotp()">
|
||||
</pngx-confirm-button>
|
||||
}
|
||||
</div>
|
||||
<div class="col">
|
||||
<pngx-permissions-select i18n-title title="Permissions" formControlName="user_permissions" [error]="error?.user_permissions" [inheritedPermissions]="inheritedPermissions"></pngx-permissions-select>
|
||||
|
@@ -7,7 +7,7 @@ import {
|
||||
} from '@angular/forms'
|
||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
@@ -21,10 +21,15 @@ import { EditDialogMode } from '../edit-dialog.component'
|
||||
import { UserEditDialogComponent } from './user-edit-dialog.component'
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
|
||||
describe('UserEditDialogComponent', () => {
|
||||
let component: UserEditDialogComponent
|
||||
let settingsService: SettingsService
|
||||
let permissionsService: PermissionsService
|
||||
let toastService: ToastService
|
||||
let fixture: ComponentFixture<UserEditDialogComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -71,6 +76,8 @@ describe('UserEditDialogComponent', () => {
|
||||
fixture = TestBed.createComponent(UserEditDialogComponent)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
||||
permissionsService = TestBed.inject(PermissionsService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
component = fixture.componentInstance
|
||||
|
||||
fixture.detectChanges()
|
||||
@@ -121,4 +128,38 @@ describe('UserEditDialogComponent', () => {
|
||||
component.save()
|
||||
expect(component.passwordIsSet).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should support deactivation of TOTP', () => {
|
||||
component.object = { id: 99, username: 'user99' }
|
||||
const deactivateSpy = jest.spyOn(
|
||||
component['service'] as UserService,
|
||||
'deactivateTotp'
|
||||
)
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
|
||||
component.deactivateTotp()
|
||||
expect(deactivateSpy).toHaveBeenCalled()
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
|
||||
deactivateSpy.mockReturnValueOnce(of(false))
|
||||
component.deactivateTotp()
|
||||
expect(deactivateSpy).toHaveBeenCalled()
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
|
||||
deactivateSpy.mockReturnValueOnce(of(true))
|
||||
component.deactivateTotp()
|
||||
expect(deactivateSpy).toHaveBeenCalled()
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should check superuser status of current user', () => {
|
||||
expect(component.currentUserIsSuperUser).toBeFalsy()
|
||||
permissionsService.initialize([], {
|
||||
id: 99,
|
||||
username: 'user99',
|
||||
is_superuser: true,
|
||||
})
|
||||
expect(component.currentUserIsSuperUser).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
@@ -5,9 +5,11 @@ import { first } from 'rxjs'
|
||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||
import { Group } from 'src/app/data/group'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { GroupService } from 'src/app/services/rest/group.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-user-edit-dialog',
|
||||
@@ -20,12 +22,15 @@ export class UserEditDialogComponent
|
||||
{
|
||||
groups: Group[]
|
||||
passwordIsSet: boolean = false
|
||||
public totpLoading: boolean = false
|
||||
|
||||
constructor(
|
||||
service: UserService,
|
||||
activeModal: NgbActiveModal,
|
||||
groupsService: GroupService,
|
||||
settingsService: SettingsService
|
||||
settingsService: SettingsService,
|
||||
private toastService: ToastService,
|
||||
private permissionsService: PermissionsService
|
||||
) {
|
||||
super(service, activeModal, service, settingsService)
|
||||
|
||||
@@ -87,4 +92,30 @@ export class UserEditDialogComponent
|
||||
.length > 0
|
||||
super.save()
|
||||
}
|
||||
|
||||
get currentUserIsSuperUser(): boolean {
|
||||
return this.permissionsService.isSuperUser()
|
||||
}
|
||||
|
||||
deactivateTotp() {
|
||||
this.totpLoading = true
|
||||
;(this.service as UserService)
|
||||
.deactivateTotp(this.object)
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.totpLoading = false
|
||||
if (result) {
|
||||
this.toastService.showInfo($localize`Totp deactivated`)
|
||||
this.object.is_mfa_enabled = false
|
||||
} else {
|
||||
this.toastService.showError($localize`Totp deactivation failed`)
|
||||
}
|
||||
},
|
||||
error: (e) => {
|
||||
this.totpLoading = false
|
||||
this.toastService.showError($localize`Totp deactivation failed`, e)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -5,94 +5,179 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error?.email"></pngx-input-text>
|
||||
<div ngbAccordion>
|
||||
<div ngbAccordionItem="first" [collapsed]="!showEmailConfirm" class="border-0 bg-transparent">
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody class="p-0 pb-3">
|
||||
<pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error?.email_confirm"></pngx-input-text>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6">
|
||||
<pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error?.email"></pngx-input-text>
|
||||
<div ngbAccordion>
|
||||
<div ngbAccordionItem="first" [collapsed]="!showEmailConfirm" class="border-0 bg-transparent">
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody class="p-0 pb-3">
|
||||
<pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error?.email_confirm"></pngx-input-text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error?.password"></pngx-input-password>
|
||||
<div ngbAccordion>
|
||||
<div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm" class="border-0 bg-transparent">
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody class="p-0 pb-3">
|
||||
<pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error?.password_confirm"></pngx-input-password>
|
||||
<pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error?.password"></pngx-input-password>
|
||||
<div ngbAccordion>
|
||||
<div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm" class="border-0 bg-transparent">
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody class="p-0 pb-3">
|
||||
<pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error?.password_confirm"></pngx-input-password>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
|
||||
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" i18n>API Auth Token</label>
|
||||
<div class="position-relative">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" formControlName="auth_token" readonly>
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
|
||||
@if (!copied) {
|
||||
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
|
||||
}
|
||||
@if (copied) {
|
||||
<i-bs width="1em" height="1em" name="clipboard-check-fill"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy</span>
|
||||
</button>
|
||||
<pngx-confirm-button
|
||||
title="Regenerate auth token"
|
||||
i18n-title
|
||||
buttonClasses=" btn-outline-secondary"
|
||||
iconName="arrow-repeat"
|
||||
[disabled]="!hasUsablePassword"
|
||||
(confirm)="generateAuthToken()">
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
|
||||
</div>
|
||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
|
||||
</div>
|
||||
@if (socialAccounts?.length > 0) {
|
||||
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
|
||||
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
|
||||
<div class="mb-3">
|
||||
<p i18n>Connected social accounts</p>
|
||||
<ul class="list-group">
|
||||
@for (account of socialAccounts; track account.id) {
|
||||
<li class="list-group-item"
|
||||
ngbPopover="Set a password before disconnecting social account."
|
||||
i18n-ngbPopover
|
||||
[disablePopover]="hasUsablePassword"
|
||||
triggers="mouseenter:mouseleave">
|
||||
{{account.name}} ({{account.provider}})
|
||||
<label class="form-label" i18n>API Auth Token</label>
|
||||
<div class="position-relative">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" formControlName="auth_token" readonly>
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
|
||||
@if (!copied) {
|
||||
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
|
||||
}
|
||||
@if (copied) {
|
||||
<i-bs width="1em" height="1em" name="clipboard-check-fill"></i-bs>
|
||||
}
|
||||
<span class="visually-hidden" i18n>Copy</span>
|
||||
</button>
|
||||
<pngx-confirm-button
|
||||
label="Disconnect"
|
||||
i18n-label
|
||||
title="Disconnect {{ account.name }} social account"
|
||||
title="Regenerate auth token"
|
||||
i18n-title
|
||||
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
|
||||
iconName="trash"
|
||||
buttonClasses=" btn-outline-secondary"
|
||||
iconName="arrow-repeat"
|
||||
[disabled]="!hasUsablePassword"
|
||||
(confirm)="disconnectSocialAccount(account.id)">
|
||||
(confirm)="generateAuthToken()">
|
||||
</pngx-confirm-button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
|
||||
</div>
|
||||
}
|
||||
@if (socialAccountProviders?.length > 0) {
|
||||
<div class="mb-3">
|
||||
<p i18n>Connect new social account</p>
|
||||
<div class="list-group">
|
||||
@for (provider of socialAccountProviders; track provider.name) {
|
||||
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
|
||||
{{provider.name}} <i-bs class="pb-1 ps-1" name="box-arrow-up-right"></i-bs>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
|
||||
</div>
|
||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
@if (socialAccounts?.length > 0) {
|
||||
<div class="mb-3">
|
||||
<p i18n>Connected social accounts</p>
|
||||
<ul class="list-group">
|
||||
@for (account of socialAccounts; track account.id) {
|
||||
<li class="list-group-item"
|
||||
ngbPopover="Set a password before disconnecting social account."
|
||||
i18n-ngbPopover
|
||||
[disablePopover]="hasUsablePassword"
|
||||
triggers="mouseenter:mouseleave">
|
||||
{{account.name}} ({{account.provider}})
|
||||
<pngx-confirm-button
|
||||
label="Disconnect"
|
||||
i18n-label
|
||||
title="Disconnect {{ account.name }} social account"
|
||||
i18n-title
|
||||
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
|
||||
iconName="trash"
|
||||
[disabled]="!hasUsablePassword"
|
||||
(confirm)="disconnectSocialAccount(account.id)">
|
||||
</pngx-confirm-button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
|
||||
</div>
|
||||
}
|
||||
@if (socialAccountProviders?.length > 0) {
|
||||
<div class="mb-3">
|
||||
<p i18n>Connect new social account</p>
|
||||
<div class="list-group">
|
||||
@for (provider of socialAccountProviders; track provider.name) {
|
||||
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
|
||||
{{provider.name}} <i-bs class="pb-1 ps-1" name="box-arrow-up-right"></i-bs>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!isTotpEnabled) {
|
||||
<div ngbAccordion>
|
||||
<div ngbAccordionItem>
|
||||
<h2 ngbAccordionHeader>
|
||||
<button ngbAccordionButton (click)="gettotpSettings()" i18n>Two-factor Authentication</button>
|
||||
</h2>
|
||||
<div ngbAccordionCollapse>
|
||||
<div ngbAccordionBody>
|
||||
<ng-template>
|
||||
@if (totpSettingsLoading) {
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
} @else if (totpSettings) {
|
||||
<figure class="figure">
|
||||
<div class="bg-white d-inline-block" [innerHTML]="totpSettings.qr_svg | safeHtml"></div>
|
||||
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
|
||||
</figure>
|
||||
<p>
|
||||
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings.secret}}</code>.
|
||||
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
|
||||
</p>
|
||||
<div class="input-group mb-3">
|
||||
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
|
||||
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading">
|
||||
<ng-container i18n>Enable</ng-container>
|
||||
@if (totpLoading) {
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<label class="d-block mb-2" i18n>Two-factor Authentication</label>
|
||||
@if (recoveryCodes) {
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<i-bs name="exclamation-triangle"></i-bs> <ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
|
||||
</div>
|
||||
<div class="d-flex flex-row align-items-start mb-3">
|
||||
<ul class="list-group w-50">
|
||||
@for (code of recoveryCodes; track code; let i = $index) {
|
||||
@if (i % 2 === 0) {
|
||||
<li class="list-group-item d-flex justify-content-around align-items-center">
|
||||
<code>{{code}}</code>
|
||||
@if (recoveryCodes[i + 1]) {
|
||||
<code>{{recoveryCodes[i + 1]}}</code>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" (click)="copyRecoveryCodes()" i18n-title title="Copy">
|
||||
@if (!codesCopied) {
|
||||
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
|
||||
<span i18n>Copy codes</span>
|
||||
}
|
||||
@if (codesCopied) {
|
||||
<i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary"></i-bs>
|
||||
<span class="text-primary" i18n>Copied!</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<pngx-confirm-button
|
||||
label="Disable Two-factor Authentication"
|
||||
i18n-label
|
||||
title="Disable Two-factor Authentication"
|
||||
i18n-title
|
||||
buttonClasses="btn-outline-danger btn-sm"
|
||||
iconName="trash"
|
||||
[disabled]="totpLoading"
|
||||
(confirm)="deactivateTotp()">
|
||||
</pngx-confirm-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
||||
|
@@ -294,4 +294,85 @@ describe('ProfileEditDialogComponent', () => {
|
||||
expect(disconnectSpy).toHaveBeenCalled()
|
||||
expect(component.socialAccounts).not.toContainEqual(socialAccount)
|
||||
})
|
||||
|
||||
it('should get totp settings', () => {
|
||||
const settings = {
|
||||
url: 'http://localhost/',
|
||||
qr_svg: 'svg',
|
||||
secret: 'secret',
|
||||
}
|
||||
const getSpy = jest.spyOn(profileService, 'getTotpSettings')
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
getSpy.mockReturnValueOnce(
|
||||
throwError(() => new Error('failed to get settings'))
|
||||
)
|
||||
component.gettotpSettings()
|
||||
expect(getSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
|
||||
getSpy.mockReturnValue(of(settings))
|
||||
component.gettotpSettings()
|
||||
expect(getSpy).toHaveBeenCalled()
|
||||
expect(component.totpSettings).toEqual(settings)
|
||||
})
|
||||
|
||||
it('should activate totp', () => {
|
||||
const activateSpy = jest.spyOn(profileService, 'activateTotp')
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const error = new Error('failed to activate totp')
|
||||
activateSpy.mockReturnValueOnce(throwError(() => error))
|
||||
component.totpSettings = {
|
||||
url: 'http://localhost/',
|
||||
qr_svg: 'svg',
|
||||
secret: 'secret',
|
||||
}
|
||||
component.form.get('totp_code').patchValue('123456')
|
||||
component.activateTotp()
|
||||
expect(activateSpy).toHaveBeenCalledWith(
|
||||
component.totpSettings.secret,
|
||||
component.form.get('totp_code').value
|
||||
)
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
|
||||
activateSpy.mockReturnValueOnce(of({ success: false, recovery_codes: [] }))
|
||||
component.activateTotp()
|
||||
expect(toastErrorSpy).toHaveBeenCalledWith('Error activating TOTP', error)
|
||||
|
||||
activateSpy.mockReturnValueOnce(
|
||||
of({ success: true, recovery_codes: ['1', '2', '3'] })
|
||||
)
|
||||
component.activateTotp()
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(component.isTotpEnabled).toBeTruthy()
|
||||
expect(component.recoveryCodes).toEqual(['1', '2', '3'])
|
||||
})
|
||||
|
||||
it('should deactivate totp', () => {
|
||||
const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp')
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const error = new Error('failed to deactivate totp')
|
||||
deactivateSpy.mockReturnValueOnce(throwError(() => error))
|
||||
component.deactivateTotp()
|
||||
expect(deactivateSpy).toHaveBeenCalled()
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
|
||||
deactivateSpy.mockReturnValueOnce(of(false))
|
||||
component.deactivateTotp()
|
||||
expect(toastErrorSpy).toHaveBeenCalledWith('Error deactivating TOTP', error)
|
||||
|
||||
deactivateSpy.mockReturnValueOnce(of(true))
|
||||
component.deactivateTotp()
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(component.isTotpEnabled).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should copy recovery codes', fakeAsync(() => {
|
||||
const copySpy = jest.spyOn(clipboard, 'copy')
|
||||
component.recoveryCodes = ['1', '2', '3']
|
||||
component.copyRecoveryCodes()
|
||||
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
|
||||
tick(3000)
|
||||
}))
|
||||
})
|
||||
|
@@ -2,7 +2,11 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||
import { FormControl, FormGroup } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ProfileService } from 'src/app/services/profile.service'
|
||||
import { SocialAccount, SocialAccountProvider } from 'src/app/data/user-profile'
|
||||
import {
|
||||
TotpSettings,
|
||||
SocialAccount,
|
||||
SocialAccountProvider,
|
||||
} from 'src/app/data/user-profile'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
@@ -25,6 +29,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
first_name: new FormControl(''),
|
||||
last_name: new FormControl(''),
|
||||
auth_token: new FormControl(''),
|
||||
totp_code: new FormControl(''),
|
||||
})
|
||||
|
||||
private currentPassword: string
|
||||
@@ -38,7 +43,14 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
private emailConfirm: string
|
||||
public showEmailConfirm: boolean = false
|
||||
|
||||
public isTotpEnabled: boolean = false
|
||||
public totpSettings: TotpSettings
|
||||
public totpSettingsLoading: boolean = false
|
||||
public totpLoading: boolean = false
|
||||
public recoveryCodes: string[]
|
||||
|
||||
public copied: boolean = false
|
||||
public codesCopied: boolean = false
|
||||
|
||||
public socialAccounts: SocialAccount[] = []
|
||||
public socialAccountProviders: SocialAccountProvider[] = []
|
||||
@@ -70,6 +82,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
this.onPasswordChange()
|
||||
})
|
||||
this.socialAccounts = profile.social_accounts
|
||||
this.isTotpEnabled = profile.is_mfa_enabled
|
||||
})
|
||||
|
||||
this.profileService
|
||||
@@ -147,6 +160,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
const passwordChanged =
|
||||
this.newPassword && this.currentPassword !== this.newPassword
|
||||
const profile = Object.assign({}, this.form.value)
|
||||
delete profile.totp_code
|
||||
this.networkActive = true
|
||||
this.profileService
|
||||
.update(profile)
|
||||
@@ -213,4 +227,81 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public gettotpSettings(): void {
|
||||
this.totpSettingsLoading = true
|
||||
this.profileService
|
||||
.getTotpSettings()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (totpSettings) => {
|
||||
this.totpSettingsLoading = false
|
||||
this.totpSettings = totpSettings
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error fetching TOTP settings`,
|
||||
error
|
||||
)
|
||||
this.totpSettingsLoading = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public activateTotp(): void {
|
||||
this.totpLoading = true
|
||||
this.form.get('totp_code').disable()
|
||||
this.profileService
|
||||
.activateTotp(this.totpSettings.secret, this.form.get('totp_code').value)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (activationResponse) => {
|
||||
this.totpLoading = false
|
||||
this.isTotpEnabled = activationResponse.success
|
||||
this.recoveryCodes = activationResponse.recovery_codes
|
||||
this.form.get('totp_code').enable()
|
||||
if (activationResponse.success) {
|
||||
this.toastService.showInfo($localize`TOTP activated successfully`)
|
||||
} else {
|
||||
this.toastService.showError($localize`Error activating TOTP`)
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
this.totpLoading = false
|
||||
this.form.get('totp_code').enable()
|
||||
this.toastService.showError($localize`Error activating TOTP`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public deactivateTotp(): void {
|
||||
this.totpLoading = true
|
||||
this.profileService
|
||||
.deactivateTotp()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (success) => {
|
||||
this.totpLoading = false
|
||||
this.isTotpEnabled = !success
|
||||
this.recoveryCodes = null
|
||||
if (success) {
|
||||
this.toastService.showInfo($localize`TOTP deactivated successfully`)
|
||||
} else {
|
||||
this.toastService.showError($localize`Error deactivating TOTP`)
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
this.totpLoading = false
|
||||
this.toastService.showError($localize`Error deactivating TOTP`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public copyRecoveryCodes(): void {
|
||||
this.clipboard.copy(this.recoveryCodes.join('\n'))
|
||||
this.codesCopied = true
|
||||
setTimeout(() => {
|
||||
this.codesCopied = false
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user