mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Initial work on views
This commit is contained in:
		| @@ -343,6 +343,7 @@ describe('AppFrameComponent', () => { | ||||
|     component.editProfile() | ||||
|     expect(modalSpy).toHaveBeenCalledWith(ProfileEditDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|       size: 'xl', | ||||
|     }) | ||||
|   }) | ||||
|  | ||||
|   | ||||
| @@ -136,6 +136,7 @@ export class AppFrameComponent | ||||
|   editProfile() { | ||||
|     this.modalService.open(ProfileEditDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|       size: 'xl', | ||||
|     }) | ||||
|     this.closeMenu() | ||||
|   } | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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,82 @@ 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) => { | ||||
|           console.log(activationResponse) | ||||
|  | ||||
|           this.totpLoading = false | ||||
|           this.isTotpEnabled = activationResponse.success | ||||
|           this.recoveryCodes = activationResponse.recovery_codes | ||||
|           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) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -17,4 +17,11 @@ export interface PaperlessUserProfile { | ||||
|   auth_token?: string | ||||
|   social_accounts?: SocialAccount[] | ||||
|   has_usable_password?: boolean | ||||
|   is_mfa_enabled?: boolean | ||||
| } | ||||
|  | ||||
| export interface TotpSettings { | ||||
|   url: string | ||||
|   qr_svg: string | ||||
|   secret: string | ||||
| } | ||||
|   | ||||
| @@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http' | ||||
| import { Injectable } from '@angular/core' | ||||
| import { Observable } from 'rxjs' | ||||
| import { | ||||
|   TotpSettings, | ||||
|   PaperlessUserProfile, | ||||
|   SocialAccountProvider, | ||||
| } from '../data/user-profile' | ||||
| @@ -47,4 +48,30 @@ export class ProfileService { | ||||
|       `${environment.apiBaseUrl}${this.endpoint}/social_account_providers/` | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   getTotpSettings(): Observable<TotpSettings> { | ||||
|     return this.http.get<TotpSettings>( | ||||
|       `${environment.apiBaseUrl}${this.endpoint}/totp_activate/` | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   activateTotp( | ||||
|     totpSecret: string, | ||||
|     totpCode: string | ||||
|   ): Observable<{ success: boolean; recovery_codes: string[] }> { | ||||
|     return this.http.post<{ success: boolean; recovery_codes: string[] }>( | ||||
|       `${environment.apiBaseUrl}${this.endpoint}/totp_activate/`, | ||||
|       { | ||||
|         secret: totpSecret, | ||||
|         code: totpCode, | ||||
|       } | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   deactivateTotp(): Observable<boolean> { | ||||
|     return this.http.delete<boolean>( | ||||
|       `${environment.apiBaseUrl}${this.endpoint}/totp_activate/`, | ||||
|       {} | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import logging | ||||
|  | ||||
| from allauth.mfa.adapter import get_adapter as get_mfa_adapter | ||||
| from allauth.socialaccount.models import SocialAccount | ||||
| from django.contrib.auth.models import Group | ||||
| from django.contrib.auth.models import Permission | ||||
| @@ -130,6 +131,11 @@ class ProfileSerializer(serializers.ModelSerializer): | ||||
|         read_only=True, | ||||
|         source="socialaccount_set", | ||||
|     ) | ||||
|     is_mfa_enabled = serializers.SerializerMethodField() | ||||
|  | ||||
|     def get_is_mfa_enabled(self, user: User): | ||||
|         mfa_adapter = get_mfa_adapter() | ||||
|         return mfa_adapter.is_mfa_enabled(user) | ||||
|  | ||||
|     class Meta: | ||||
|         model = User | ||||
| @@ -141,6 +147,7 @@ class ProfileSerializer(serializers.ModelSerializer): | ||||
|             "auth_token", | ||||
|             "social_accounts", | ||||
|             "has_usable_password", | ||||
|             "is_mfa_enabled", | ||||
|         ) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -459,6 +459,8 @@ SOCIALACCOUNT_PROVIDERS = json.loads( | ||||
|     os.getenv("PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"), | ||||
| ) | ||||
|  | ||||
| MFA_TOTP_ISSUER = "Paperless-ngx" | ||||
|  | ||||
| ACCOUNT_EMAIL_SUBJECT_PREFIX = "[Paperless-ngx] " | ||||
|  | ||||
| DISABLE_REGULAR_LOGIN = __get_boolean("PAPERLESS_DISABLE_REGULAR_LOGIN") | ||||
|   | ||||
| @@ -54,6 +54,7 @@ from paperless.views import GenerateAuthTokenView | ||||
| from paperless.views import GroupViewSet | ||||
| from paperless.views import ProfileView | ||||
| from paperless.views import SocialAccountProvidersView | ||||
| from paperless.views import TOTPActivateView | ||||
| from paperless.views import UserViewSet | ||||
| from paperless_mail.views import MailAccountTestView | ||||
| from paperless_mail.views import MailAccountViewSet | ||||
| @@ -157,8 +158,21 @@ urlpatterns = [ | ||||
|                 ), | ||||
|                 re_path( | ||||
|                     "^profile/", | ||||
|                     ProfileView.as_view(), | ||||
|                     name="profile_view", | ||||
|                     include( | ||||
|                         [ | ||||
|                             re_path( | ||||
|                                 "^$", | ||||
|                                 ProfileView.as_view(), | ||||
|                                 name="profile_view", | ||||
|                             ), | ||||
|                             path( | ||||
|                                 "totp_activate/", | ||||
|                                 TOTPActivateView.as_view(), | ||||
|                                 name="activate", | ||||
|                             ), | ||||
|                             # TODO: remove allauth urls? | ||||
|                         ], | ||||
|                     ), | ||||
|                 ), | ||||
|                 re_path( | ||||
|                     "^status/", | ||||
|   | ||||
| @@ -1,6 +1,12 @@ | ||||
| import os | ||||
| from collections import OrderedDict | ||||
|  | ||||
| from allauth.mfa import signals | ||||
| from allauth.mfa.adapter import get_adapter as get_mfa_adapter | ||||
| from allauth.mfa.base.internal.flows import delete_and_cleanup | ||||
| from allauth.mfa.models import Authenticator | ||||
| from allauth.mfa.recovery_codes.internal.flows import auto_generate_recovery_codes | ||||
| from allauth.mfa.totp.internal import auth as totp_auth | ||||
| from allauth.socialaccount.adapter import get_adapter | ||||
| from allauth.socialaccount.models import SocialAccount | ||||
| from django.contrib.auth.models import Group | ||||
| @@ -145,6 +151,75 @@ class ProfileView(GenericAPIView): | ||||
|         return Response(serializer.to_representation(user)) | ||||
|  | ||||
|  | ||||
| class TOTPActivateView(GenericAPIView): | ||||
|     """ | ||||
|     TOTP views | ||||
|     """ | ||||
|  | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def get(self, request, *args, **kwargs): | ||||
|         user = self.request.user | ||||
|         mfa_adapter = get_mfa_adapter() | ||||
|         secret = totp_auth.get_totp_secret(regenerate=True) | ||||
|         url = mfa_adapter.build_totp_url(user, secret) | ||||
|         svg = mfa_adapter.build_totp_svg(url) | ||||
|         return Response( | ||||
|             { | ||||
|                 "url": url, | ||||
|                 "qr_svg": svg, | ||||
|                 "secret": secret, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def post(self, request, *args, **kwargs): | ||||
|         valid = totp_auth.validate_totp_code( | ||||
|             request.data["secret"], | ||||
|             request.data["code"], | ||||
|         ) | ||||
|         recovery_codes = None | ||||
|         if valid: | ||||
|             # from allauth.mfa.totp.internal.flows activate_totp | ||||
|             auth = totp_auth.TOTP.activate( | ||||
|                 request.user, | ||||
|                 request.data["secret"], | ||||
|             ).instance | ||||
|             signals.authenticator_added.send( | ||||
|                 sender=Authenticator, | ||||
|                 request=request, | ||||
|                 user=request.user, | ||||
|                 authenticator=auth, | ||||
|             ) | ||||
|             # adapter = get_adapter() | ||||
|             # adapter.add_message(request, messages.SUCCESS, "mfa/messages/totp_activated.txt") | ||||
|             # adapter.send_notification_mail("mfa/email/totp_activated", request.user) | ||||
|             rc_auth: Authenticator = auto_generate_recovery_codes(request) | ||||
|             if rc_auth: | ||||
|                 recovery_codes = rc_auth.wrap().get_unused_codes() | ||||
|         return Response( | ||||
|             { | ||||
|                 "success": valid, | ||||
|                 "recovery_codes": recovery_codes, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def delete(self, request, *args, **kwargs): | ||||
|         user = self.request.user | ||||
|         try: | ||||
|             # from allauth.mfa.totp.internal.flows deactivate_totp | ||||
|             authenticator = Authenticator.objects.filter( | ||||
|                 user=user, | ||||
|                 type=Authenticator.Type.TOTP, | ||||
|             ).first() | ||||
|             delete_and_cleanup(request, authenticator) | ||||
|             # adapter = get_account_adapter(request) | ||||
|             # adapter.add_message(request, messages.SUCCESS, "mfa/messages/totp_deactivated.txt") | ||||
|             # adapter.send_notification_mail("mfa/email/totp_deactivated", request.user) | ||||
|             return Response(True) | ||||
|         except Authenticator.DoesNotExist: | ||||
|             return HttpResponseBadRequest("TOTP not found") | ||||
|  | ||||
|  | ||||
| class GenerateAuthTokenView(GenericAPIView): | ||||
|     """ | ||||
|     Generates (or re-generates) an auth token, requires a logged in user | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon