mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-28 03:46:06 -05:00 
			
		
		
		
	Feature: OIDC & social authentication (#5190)
--------- Co-authored-by: Moritz Pflanzer <moritz@chickadee-engineering.com> Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
This commit is contained in:
		| @@ -502,7 +502,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">55</context> | ||||
|           <context context-type="linenumber">92</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
| @@ -1563,7 +1563,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> | ||||
|           <context context-type="linenumber">121</context> | ||||
|           <context context-type="linenumber">140</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5260584511980773458" datatype="html"> | ||||
| @@ -1938,7 +1938,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">145</context> | ||||
|           <context context-type="linenumber">159</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2753185112875184719" datatype="html"> | ||||
| @@ -2405,21 +2405,21 @@ | ||||
|         <source>Sidebar views updated</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> | ||||
|           <context context-type="linenumber">263</context> | ||||
|           <context context-type="linenumber">282</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3547923076537026828" datatype="html"> | ||||
|         <source>Error updating sidebar views</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> | ||||
|           <context context-type="linenumber">266</context> | ||||
|           <context context-type="linenumber">285</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2526035785704676448" datatype="html"> | ||||
|         <source>An error occurred while saving update checking settings.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> | ||||
|           <context context-type="linenumber">287</context> | ||||
|           <context context-type="linenumber">306</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8700121026680200191" datatype="html"> | ||||
| @@ -2523,7 +2523,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">54</context> | ||||
|           <context context-type="linenumber">91</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/select-dialog/select-dialog.component.html</context> | ||||
| @@ -4103,39 +4103,88 @@ | ||||
|           <context context-type="linenumber">50</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8935717557476105185" datatype="html"> | ||||
|         <source>Connected social accounts</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">54</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8383227756109993898" datatype="html"> | ||||
|         <source>Set a password before disconnecting social account.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">58</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5322995394400578831" datatype="html"> | ||||
|         <source>Disconnect <x id="INTERPOLATION" equiv-text="{{ account.name }}"/> social account</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">68</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2907016025519254862" datatype="html"> | ||||
|         <source>Disconnect</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">69</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="649824314893051979" datatype="html"> | ||||
|         <source>Warning: disconnecting social accounts cannot be undone</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">74</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1375396510511350122" datatype="html"> | ||||
|         <source>Connect new social account</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context> | ||||
|           <context context-type="linenumber">79</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6141884091799403188" datatype="html"> | ||||
|         <source>Emails must match</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">94</context> | ||||
|           <context context-type="linenumber">108</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5281933990298241826" datatype="html"> | ||||
|         <source>Passwords must match</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">122</context> | ||||
|           <context context-type="linenumber">136</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4219429959475101385" datatype="html"> | ||||
|         <source>Profile updated successfully</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">142</context> | ||||
|           <context context-type="linenumber">156</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3417726855410304962" datatype="html"> | ||||
|         <source>Error saving profile</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">154</context> | ||||
|           <context context-type="linenumber">168</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="154249228726292516" datatype="html"> | ||||
|         <source>Error generating auth token</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">171</context> | ||||
|           <context context-type="linenumber">185</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4153637646944982460" datatype="html"> | ||||
|         <source>Error disconnecting social account</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">210</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3797570084942068182" datatype="html"> | ||||
|   | ||||
| @@ -21,6 +21,10 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { of, throwError } from 'rxjs' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { | ||||
|   DjangoMessageLevel, | ||||
|   DjangoMessagesService, | ||||
| } from 'src/app/services/django-messages.service' | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { OpenDocumentsService } from 'src/app/services/open-documents.service' | ||||
| import { ActivatedRoute, Router } from '@angular/router' | ||||
| @@ -83,6 +87,7 @@ describe('AppFrameComponent', () => { | ||||
|   let permissionsService: PermissionsService | ||||
|   let remoteVersionService: RemoteVersionService | ||||
|   let toastService: ToastService | ||||
|   let messagesService: DjangoMessagesService | ||||
|   let openDocumentsService: OpenDocumentsService | ||||
|   let searchService: SearchService | ||||
|   let documentListViewService: DocumentListViewService | ||||
| @@ -123,6 +128,7 @@ describe('AppFrameComponent', () => { | ||||
|         RemoteVersionService, | ||||
|         IfPermissionsDirective, | ||||
|         ToastService, | ||||
|         DjangoMessagesService, | ||||
|         OpenDocumentsService, | ||||
|         SearchService, | ||||
|         NgbModal, | ||||
| @@ -151,6 +157,7 @@ describe('AppFrameComponent', () => { | ||||
|     permissionsService = TestBed.inject(PermissionsService) | ||||
|     remoteVersionService = TestBed.inject(RemoteVersionService) | ||||
|     toastService = TestBed.inject(ToastService) | ||||
|     messagesService = TestBed.inject(DjangoMessagesService) | ||||
|     openDocumentsService = TestBed.inject(OpenDocumentsService) | ||||
|     searchService = TestBed.inject(SearchService) | ||||
|     documentListViewService = TestBed.inject(DocumentListViewService) | ||||
| @@ -393,4 +400,19 @@ describe('AppFrameComponent', () => { | ||||
|       backdrop: 'static', | ||||
|     }) | ||||
|   }) | ||||
|  | ||||
|   it('should show toasts for django messages', () => { | ||||
|     const toastErrorSpy = jest.spyOn(toastService, 'showError') | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     jest.spyOn(messagesService, 'get').mockReturnValue([ | ||||
|       { level: DjangoMessageLevel.WARNING, message: 'Test warning' }, | ||||
|       { level: DjangoMessageLevel.ERROR, message: 'Test error' }, | ||||
|       { level: DjangoMessageLevel.SUCCESS, message: 'Test success' }, | ||||
|       { level: DjangoMessageLevel.INFO, message: 'Test info' }, | ||||
|       { level: DjangoMessageLevel.DEBUG, message: 'Test debug' }, | ||||
|     ]) | ||||
|     component.ngOnInit() | ||||
|     expect(toastErrorSpy).toHaveBeenCalledTimes(2) | ||||
|     expect(toastInfoSpy).toHaveBeenCalledTimes(3) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -12,6 +12,10 @@ import { | ||||
| } from 'rxjs/operators' | ||||
| import { Document } from 'src/app/data/document' | ||||
| import { OpenDocumentsService } from 'src/app/services/open-documents.service' | ||||
| import { | ||||
|   DjangoMessageLevel, | ||||
|   DjangoMessagesService, | ||||
| } from 'src/app/services/django-messages.service' | ||||
| import { SavedViewService } from 'src/app/services/rest/saved-view.service' | ||||
| import { SearchService } from 'src/app/services/rest/search.service' | ||||
| import { environment } from 'src/environments/environment' | ||||
| @@ -73,7 +77,8 @@ export class AppFrameComponent | ||||
|     public tasksService: TasksService, | ||||
|     private readonly toastService: ToastService, | ||||
|     private modalService: NgbModal, | ||||
|     permissionsService: PermissionsService | ||||
|     public permissionsService: PermissionsService, | ||||
|     private djangoMessagesService: DjangoMessagesService | ||||
|   ) { | ||||
|     super() | ||||
|  | ||||
| @@ -92,6 +97,20 @@ export class AppFrameComponent | ||||
|       this.checkForUpdates() | ||||
|     } | ||||
|     this.tasksService.reload() | ||||
|  | ||||
|     this.djangoMessagesService.get().forEach((message) => { | ||||
|       switch (message.level) { | ||||
|         case DjangoMessageLevel.ERROR: | ||||
|         case DjangoMessageLevel.WARNING: | ||||
|           this.toastService.showError(message.message) | ||||
|           break | ||||
|         case DjangoMessageLevel.SUCCESS: | ||||
|         case DjangoMessageLevel.INFO: | ||||
|         case DjangoMessageLevel.DEBUG: | ||||
|           this.toastService.showInfo(message.message) | ||||
|           break | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   toggleSlimSidebar(): void { | ||||
|   | ||||
| @@ -49,6 +49,43 @@ | ||||
|         </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) { | ||||
|         <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}}) | ||||
|                 <button | ||||
|                   type="button" | ||||
|                   class="btn btn-outline-danger btn-sm ms-2 align-baseline" | ||||
|                   [disabled]="!hasUsablePassword && socialAccounts.length === 1" | ||||
|                   (click)="disconnectSocialAccount(account.id)" | ||||
|                   i18n-title title="Disconnect {{ account.name }} social account"> | ||||
|                 <ng-container i18n>Disconnect</ng-container> <i-bs name="trash"></i-bs> | ||||
|                 </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> | ||||
|       } | ||||
|     </div> | ||||
|     <div class="modal-footer"> | ||||
|       <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> | ||||
|   | ||||
| @@ -12,6 +12,7 @@ import { | ||||
|   NgbAccordionModule, | ||||
|   NgbActiveModal, | ||||
|   NgbModalModule, | ||||
|   NgbPopoverModule, | ||||
| } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { HttpClientModule } from '@angular/common/http' | ||||
| import { TextComponent } from '../input/text/text.component' | ||||
| @@ -21,13 +22,22 @@ import { ToastService } from 'src/app/services/toast.service' | ||||
| import { Clipboard } from '@angular/cdk/clipboard' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
|  | ||||
| const socialAccount = { | ||||
|   id: 1, | ||||
|   provider: 'test_provider', | ||||
|   name: 'Test Provider', | ||||
| } | ||||
| const profile = { | ||||
|   email: 'foo@bar.com', | ||||
|   password: '*********', | ||||
|   first_name: 'foo', | ||||
|   last_name: 'bar', | ||||
|   auth_token: '123456789abcdef', | ||||
|   social_accounts: [socialAccount], | ||||
| } | ||||
| const socialAccountProviders = [ | ||||
|   { name: 'Test Provider', login_url: 'https://example.com' }, | ||||
| ] | ||||
|  | ||||
| describe('ProfileEditDialogComponent', () => { | ||||
|   let component: ProfileEditDialogComponent | ||||
| @@ -51,6 +61,7 @@ describe('ProfileEditDialogComponent', () => { | ||||
|         NgbModalModule, | ||||
|         NgbAccordionModule, | ||||
|         NgxBootstrapIconsModule.pick(allIcons), | ||||
|         NgbPopoverModule, | ||||
|       ], | ||||
|     }) | ||||
|     profileService = TestBed.inject(ProfileService) | ||||
| @@ -64,6 +75,11 @@ describe('ProfileEditDialogComponent', () => { | ||||
|   it('should get profile on init, display in form', () => { | ||||
|     const getSpy = jest.spyOn(profileService, 'get') | ||||
|     getSpy.mockReturnValue(of(profile)) | ||||
|     const getProvidersSpy = jest.spyOn( | ||||
|       profileService, | ||||
|       'getSocialAccountProviders' | ||||
|     ) | ||||
|     getProvidersSpy.mockReturnValue(of(socialAccountProviders)) | ||||
|     component.ngOnInit() | ||||
|     expect(getSpy).toHaveBeenCalled() | ||||
|     fixture.detectChanges() | ||||
| @@ -103,6 +119,11 @@ describe('ProfileEditDialogComponent', () => { | ||||
|     expect(component.form.get('email_confirm').enabled).toBeFalsy() | ||||
|     const getSpy = jest.spyOn(profileService, 'get') | ||||
|     getSpy.mockReturnValue(of(profile)) | ||||
|     const getProvidersSpy = jest.spyOn( | ||||
|       profileService, | ||||
|       'getSocialAccountProviders' | ||||
|     ) | ||||
|     getProvidersSpy.mockReturnValue(of(socialAccountProviders)) | ||||
|     component.ngOnInit() | ||||
|     component.form.get('email').patchValue('foo@bar2.com') | ||||
|     component.onEmailKeyUp({ target: { value: 'foo@bar2.com' } } as any) | ||||
| @@ -134,6 +155,12 @@ describe('ProfileEditDialogComponent', () => { | ||||
|     expect(component.form.get('password_confirm').enabled).toBeFalsy() | ||||
|     const getSpy = jest.spyOn(profileService, 'get') | ||||
|     getSpy.mockReturnValue(of(profile)) | ||||
|     const getProvidersSpy = jest.spyOn( | ||||
|       profileService, | ||||
|       'getSocialAccountProviders' | ||||
|     ) | ||||
|     getProvidersSpy.mockReturnValue(of(socialAccountProviders)) | ||||
|     component.hasUsablePassword = true | ||||
|     component.ngOnInit() | ||||
|     component.form.get('password').patchValue('new*pass') | ||||
|     component.onPasswordKeyUp({ | ||||
| @@ -167,6 +194,11 @@ describe('ProfileEditDialogComponent', () => { | ||||
|   it('should logout on save if password changed', fakeAsync(() => { | ||||
|     const getSpy = jest.spyOn(profileService, 'get') | ||||
|     getSpy.mockReturnValue(of(profile)) | ||||
|     const getProvidersSpy = jest.spyOn( | ||||
|       profileService, | ||||
|       'getSocialAccountProviders' | ||||
|     ) | ||||
|     getProvidersSpy.mockReturnValue(of(socialAccountProviders)) | ||||
|     component.ngOnInit() | ||||
|     component['newPassword'] = 'new*pass' | ||||
|     component.form.get('password').patchValue('new*pass') | ||||
| @@ -189,6 +221,11 @@ describe('ProfileEditDialogComponent', () => { | ||||
|   it('should support auth token copy', fakeAsync(() => { | ||||
|     const getSpy = jest.spyOn(profileService, 'get') | ||||
|     getSpy.mockReturnValue(of(profile)) | ||||
|     const getProvidersSpy = jest.spyOn( | ||||
|       profileService, | ||||
|       'getSocialAccountProviders' | ||||
|     ) | ||||
|     getProvidersSpy.mockReturnValue(of(socialAccountProviders)) | ||||
|     component.ngOnInit() | ||||
|     const copySpy = jest.spyOn(clipboard, 'copy') | ||||
|     component.copyAuthToken() | ||||
| @@ -220,4 +257,40 @@ describe('ProfileEditDialogComponent', () => { | ||||
|     ) | ||||
|     expect(component.form.get('auth_token').value).toEqual(newToken) | ||||
|   }) | ||||
|  | ||||
|   it('should get social account providers on init', () => { | ||||
|     const getSpy = jest.spyOn(profileService, 'get') | ||||
|     getSpy.mockReturnValue(of(profile)) | ||||
|     const getProvidersSpy = jest.spyOn( | ||||
|       profileService, | ||||
|       'getSocialAccountProviders' | ||||
|     ) | ||||
|     getProvidersSpy.mockReturnValue(of(socialAccountProviders)) | ||||
|     component.ngOnInit() | ||||
|     expect(getProvidersSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should remove disconnected social account from component, show error if needed', () => { | ||||
|     const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount') | ||||
|     const getSpy = jest.spyOn(profileService, 'get') | ||||
|     getSpy.mockImplementation(() => of(profile)) | ||||
|     component.ngOnInit() | ||||
|  | ||||
|     const errorSpy = jest.spyOn(toastService, 'showError') | ||||
|  | ||||
|     expect(component.socialAccounts).toContainEqual(socialAccount) | ||||
|  | ||||
|     // fail first | ||||
|     disconnectSpy.mockReturnValueOnce( | ||||
|       throwError(() => new Error('unable to disconnect')) | ||||
|     ) | ||||
|     component.disconnectSocialAccount(socialAccount.id) | ||||
|     expect(errorSpy).toHaveBeenCalled() | ||||
|  | ||||
|     // succeed | ||||
|     disconnectSpy.mockReturnValue(of(socialAccount.id)) | ||||
|     component.disconnectSocialAccount(socialAccount.id) | ||||
|     expect(disconnectSpy).toHaveBeenCalled() | ||||
|     expect(component.socialAccounts).not.toContainEqual(socialAccount) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -2,6 +2,7 @@ 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 { ToastService } from 'src/app/services/toast.service' | ||||
| import { Subject, takeUntil } from 'rxjs' | ||||
| import { Clipboard } from '@angular/cdk/clipboard' | ||||
| @@ -30,6 +31,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy { | ||||
|   private newPassword: string | ||||
|   private passwordConfirm: string | ||||
|   public showPasswordConfirm: boolean = false | ||||
|   public hasUsablePassword: boolean = false | ||||
|  | ||||
|   private currentEmail: string | ||||
|   private newEmail: string | ||||
| @@ -38,6 +40,9 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy { | ||||
|  | ||||
|   public copied: boolean = false | ||||
|  | ||||
|   public socialAccounts: SocialAccount[] = [] | ||||
|   public socialAccountProviders: SocialAccountProvider[] = [] | ||||
|  | ||||
|   constructor( | ||||
|     private profileService: ProfileService, | ||||
|     public activeModal: NgbActiveModal, | ||||
| @@ -59,10 +64,19 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy { | ||||
|           this.onEmailChange() | ||||
|         }) | ||||
|         this.currentPassword = profile.password | ||||
|         this.hasUsablePassword = profile.has_usable_password | ||||
|         this.form.get('password').valueChanges.subscribe((newPassword) => { | ||||
|           this.newPassword = newPassword | ||||
|           this.onPasswordChange() | ||||
|         }) | ||||
|         this.socialAccounts = profile.social_accounts | ||||
|       }) | ||||
|  | ||||
|     this.profileService | ||||
|       .getSocialAccountProviders() | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe((providers) => { | ||||
|         this.socialAccountProviders = providers | ||||
|       }) | ||||
|   } | ||||
|  | ||||
| @@ -182,4 +196,21 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy { | ||||
|       this.copied = false | ||||
|     }, 3000) | ||||
|   } | ||||
|  | ||||
|   disconnectSocialAccount(id: number): void { | ||||
|     this.profileService | ||||
|       .disconnectSocialAccount(id) | ||||
|       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||
|       .subscribe({ | ||||
|         next: (id: number) => { | ||||
|           this.socialAccounts = this.socialAccounts.filter((a) => a.id != id) | ||||
|         }, | ||||
|         error: (error) => { | ||||
|           this.toastService.showError( | ||||
|             $localize`Error disconnecting social account`, | ||||
|             error | ||||
|           ) | ||||
|         }, | ||||
|       }) | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,7 +1,20 @@ | ||||
| export interface SocialAccount { | ||||
|   id: number | ||||
|   provider: string | ||||
|   name: string | ||||
| } | ||||
|  | ||||
| export interface SocialAccountProvider { | ||||
|   name: string | ||||
|   login_url: string | ||||
| } | ||||
|  | ||||
| export interface PaperlessUserProfile { | ||||
|   email?: string | ||||
|   password?: string | ||||
|   first_name?: string | ||||
|   last_name?: string | ||||
|   auth_token?: string | ||||
|   social_accounts?: SocialAccount[] | ||||
|   has_usable_password?: boolean | ||||
| } | ||||
|   | ||||
							
								
								
									
										30
									
								
								src-ui/src/app/services/django-messages.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src-ui/src/app/services/django-messages.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import { TestBed } from '@angular/core/testing' | ||||
|  | ||||
| import { | ||||
|   DjangoMessageLevel, | ||||
|   DjangoMessagesService, | ||||
| } from './django-messages.service' | ||||
|  | ||||
| const messages = [ | ||||
|   { level: DjangoMessageLevel.ERROR, message: 'Error Message' }, | ||||
|   { level: DjangoMessageLevel.INFO, message: 'Info Message' }, | ||||
| ] | ||||
|  | ||||
| describe('DjangoMessagesService', () => { | ||||
|   let service: DjangoMessagesService | ||||
|  | ||||
|   beforeEach(() => { | ||||
|     window['DJANGO_MESSAGES'] = messages | ||||
|     TestBed.configureTestingModule({ | ||||
|       providers: [DjangoMessagesService], | ||||
|     }) | ||||
|     service = TestBed.inject(DjangoMessagesService) | ||||
|   }) | ||||
|  | ||||
|   it('should retrieve global django messages if present', () => { | ||||
|     expect(service.get()).toEqual(messages) | ||||
|  | ||||
|     window['DJANGO_MESSAGES'] = undefined | ||||
|     expect(service.get()).toEqual([]) | ||||
|   }) | ||||
| }) | ||||
							
								
								
									
										27
									
								
								src-ui/src/app/services/django-messages.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src-ui/src/app/services/django-messages.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import { Injectable } from '@angular/core' | ||||
|  | ||||
| // see https://docs.djangoproject.com/en/5.0/ref/contrib/messages/#message-tags | ||||
| export enum DjangoMessageLevel { | ||||
|   DEBUG = 'debug', | ||||
|   INFO = 'info', | ||||
|   SUCCESS = 'success', | ||||
|   WARNING = 'warning', | ||||
|   ERROR = 'error', | ||||
| } | ||||
|  | ||||
| export interface DjangoMessage { | ||||
|   level: DjangoMessageLevel | ||||
|   message: string | ||||
| } | ||||
|  | ||||
| @Injectable({ | ||||
|   providedIn: 'root', | ||||
| }) | ||||
| export class DjangoMessagesService { | ||||
|   constructor() {} | ||||
|  | ||||
|   get(): DjangoMessage[] { | ||||
|     // These are embedded in the HTML as raw JS, the service is for convenience | ||||
|     return window['DJANGO_MESSAGES'] ?? [] | ||||
|   } | ||||
| } | ||||
| @@ -51,4 +51,20 @@ describe('ProfileService', () => { | ||||
|     ) | ||||
|     expect(req.request.method).toEqual('POST') | ||||
|   }) | ||||
|  | ||||
|   it('supports disconnecting a social account', () => { | ||||
|     service.disconnectSocialAccount(1).subscribe() | ||||
|     const req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}profile/disconnect_social_account/` | ||||
|     ) | ||||
|     expect(req.request.method).toEqual('POST') | ||||
|   }) | ||||
|  | ||||
|   it('calls get social account provider endpoint', () => { | ||||
|     service.getSocialAccountProviders().subscribe() | ||||
|     const req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}profile/social_account_providers/` | ||||
|     ) | ||||
|     expect(req.request.method).toEqual('GET') | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -1,7 +1,10 @@ | ||||
| import { HttpClient } from '@angular/common/http' | ||||
| import { Injectable } from '@angular/core' | ||||
| import { Observable } from 'rxjs' | ||||
| import { PaperlessUserProfile } from '../data/user-profile' | ||||
| import { | ||||
|   PaperlessUserProfile, | ||||
|   SocialAccountProvider, | ||||
| } from '../data/user-profile' | ||||
| import { environment } from 'src/environments/environment' | ||||
|  | ||||
| @Injectable({ | ||||
| @@ -31,4 +34,17 @@ export class ProfileService { | ||||
|       {} | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   disconnectSocialAccount(id: number): Observable<number> { | ||||
|     return this.http.post<number>( | ||||
|       `${environment.apiBaseUrl}${this.endpoint}/disconnect_social_account/`, | ||||
|       { id: id } | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   getSocialAccountProviders(): Observable<SocialAccountProvider[]> { | ||||
|     return this.http.get<SocialAccountProvider[]>( | ||||
|       `${environment.apiBaseUrl}${this.endpoint}/social_account_providers/` | ||||
|     ) | ||||
|   } | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 Moritz Pflanzer
					Moritz Pflanzer