Feature: OIDC support
This commit is contained in:
@@ -49,6 +49,36 @@
|
||||
</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>
|
||||
<div class="position-relative">
|
||||
<ul>
|
||||
@for (account of socialAccounts; track account.id) {
|
||||
<li>{{account.name}}<button type="button" class="btn btn-outline-secondary btn-sm ms-2 align-baseline" (click)="disconnectSocialAccount(account.id)" i18n-title title="Disconnect {{ account.name }} social account">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
||||
</svg>
|
||||
</button></li>
|
||||
}
|
||||
</ul>
|
||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the social accounts cannot be undone</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (socialAccountProviders?.length > 0) {
|
||||
<div class="mb-3">
|
||||
<p i18n>Available social account providers</p>
|
||||
<div class="position-relative">
|
||||
<ul>
|
||||
@for (provider of socialAccountProviders; track provider.name) {
|
||||
<li><a href="{{ provider.login_url }}">{{provider.name}}</a></li>
|
||||
}
|
||||
</ul>
|
||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the social account providers cannot be undone</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
||||
|
||||
@@ -21,13 +21,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
|
||||
@@ -64,6 +73,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 +117,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 +153,11 @@ 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.ngOnInit()
|
||||
component.form.get('password').patchValue('new*pass')
|
||||
component.onPasswordKeyUp({
|
||||
@@ -167,6 +191,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 +218,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 +254,42 @@ 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', async () => {
|
||||
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
|
||||
disconnectSpy.mockReturnValue(of(socialAccount.id))
|
||||
|
||||
let resolve
|
||||
const p = new Promise((r) => (resolve = r))
|
||||
|
||||
const getSpy = jest.spyOn(profileService, 'get')
|
||||
getSpy.mockImplementation(() => {
|
||||
resolve()
|
||||
return of(profile)
|
||||
})
|
||||
|
||||
component.ngOnInit()
|
||||
|
||||
await p
|
||||
|
||||
expect(getSpy).toHaveBeenCalled()
|
||||
expect(component.socialAccounts).toContainEqual(socialAccount)
|
||||
|
||||
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'
|
||||
@@ -38,6 +39,9 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
|
||||
public copied: boolean = false
|
||||
|
||||
public socialAccounts: SocialAccount[] = []
|
||||
public socialAccountProviders: SocialAccountProvider[] = []
|
||||
|
||||
constructor(
|
||||
private profileService: ProfileService,
|
||||
public activeModal: NgbActiveModal,
|
||||
@@ -63,6 +67,14 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
this.newPassword = newPassword
|
||||
this.onPasswordChange()
|
||||
})
|
||||
this.socialAccounts = profile.social_accounts
|
||||
})
|
||||
|
||||
this.profileService
|
||||
.getSocialAccountProviders()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((providers) => {
|
||||
this.socialAccountProviders = providers
|
||||
})
|
||||
}
|
||||
|
||||
@@ -182,4 +194,18 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
||||
this.copied = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
disconnectSocialAccount(id: number): void {
|
||||
this.profileService.disconnectSocialAccount(id).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,19 @@
|
||||
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[]
|
||||
}
|
||||
|
||||
@@ -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