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:
Moritz Pflanzer 2024-02-08 17:15:38 +01:00 committed by GitHub
parent b47f301831
commit c508be6ecd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1197 additions and 190 deletions

View File

@ -8,6 +8,7 @@ dateparser = "~=1.2"
# WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes.
django = "~=4.2.9"
django-allauth = "*"
django-auditlog = "*"
django-celery-results = "*"
django-compression-middleware = "*"

8
Pipfile.lock generated
View File

@ -452,6 +452,14 @@
"markers": "python_version >= '3.8'",
"version": "==4.2.9"
},
"django-allauth": {
"hashes": [
"sha256:ec19efb80b34d2f18bd831eab9b10b6301f58d1cce9f39af35f497b7e5b0a141"
],
"index": "pypi",
"markers": "python_version >= '3.7'",
"version": "==0.59.0"
},
"django-auditlog": {
"hashes": [
"sha256:7bc2c87e4aff62dec9785d1b2359a2b27148f8c286f8a52b9114fc7876c5a9f7",

View File

@ -640,3 +640,42 @@ single-sided split marker page, the split document(s) will have an empty page at
whatever else was on the backside of the split marker page.) You can work around that by having
a split marker page that has the split barcode on _both_ sides. This way, the extra page will
get automatically removed.
## SSO and third party authentication with Paperless-ngx
Paperless-ngx has a built-in authentication system from Django but you can easily integrate an
external authentication solution using one of the following methods:
### Remote User authentication
This is a simple option that uses remote user authentication made available by certain SSO
applications. See the relevant configuration options for more information:
[PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER) and
[PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME](configuration.md#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME)
### OpenID Connect and social authentication
Version 2.5.0 of Paperless-ngx added support for integrating other authentication systems via
the [django-allauth](https://github.com/pennersr/django-allauth) package. Once set up, users
can either log in or (optionally) sign up using any third party systems you integrate. See the
relevant [configuration settings](configuration.md#PAPERLESS_SOCIALACCOUNT_PROVIDERS) and
[django-allauth docs](https://docs.allauth.org/en/latest/socialaccount/configuration.html)
for more information.
As an example, to set up login via Github, the following environment variables would need to be
set:
```conf
PAPERLESS_APPS="allauth.socialaccount.providers.github"
PAPERLESS_SOCIALACCOUNT_PROVIDERS='{"github": {"APPS": [{"provider_id": "github","name": "Github","client_id": "<CLIENT_ID>","secret": "<CLIENT_SECRET>"}]}}'
```
Or, to use OpenID Connect ("OIDC"), via Keycloak in this example:
```conf
PAPERLESS_APPS="allauth.socialaccount.providers.openid_connect"
PAPERLESS_SOCIALACCOUNT_PROVIDERS='
{"openid_connect": {"APPS": [{"provider_id": "keycloak","name": "Keycloak","client_id": "paperless","secret": "<CLIENT_SECRET>","settings": { "server_url": "https://<KEYCLOAK_SERVER>/realms/<REALM>/.well-known/openid-configuration"}}]}}'
```
More details about configuration option for various providers can be found in the allauth documentation: https://docs.allauth.org/en/latest/socialaccount/providers/index.html#provider-specifics

View File

@ -535,6 +535,42 @@ This is for use with self-signed certificates against local IMAP servers.
Settings this value has security implications for the security of your email.
Understand what it does and be sure you need to before setting.
#### [`PAPERLESS_SOCIALACCOUNT_PROVIDERS=<json>`](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) {#PAPERLESS_SOCIALACCOUNT_PROVIDERS}
: This variable is used to setup login and signup via social account providers which are compatible with django-allauth.
See the corresponding [django-allauth documentation](https://docs.allauth.org/en/0.60.0/socialaccount/providers/index.html)
for a list of provider configurations. You will also likely need to include the relevant Django 'application' inside the
[PAPERLESS_APPS](#PAPERLESS_APPS) setting.
Defaults to None, which does not enable any third party authentication systems.
#### [`PAPERLESS_SOCIAL_AUTO_SIGNUP=<bool>`](#PAPERLESS_SOCIAL_AUTO_SIGNUP) {#PAPERLESS_SOCIAL_AUTO_SIGNUP}
: Attempt to signup the user using retrieved email, username etc from the third party authentication
system. See the corresponding
[django-allauth documentation](https://docs.allauth.org/en/0.60.0/socialaccount/configuration.html)
Defaults to False
#### [`PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS}
: Allow users to signup for a new Paperless-ngx account using any setup third party authentication systems.
Defaults to True
#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
: Allow users to signup for a new Paperless-ngx account.
Defaults to False
#### [`PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=<string>`](#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL) {#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL}
: The protocol used when generating URLs, e.g. login callback URLs. See the corresponding
[django-allauth documentation](https://docs.allauth.org/en/latest/account/configuration.html)
Defaults to 'https'
## OCR settings {#ocr}
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
@ -905,6 +941,14 @@ documents.
Default is none, which disables the temporary directory.
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
: A comma-separated list of Django apps to be included in Django's
[`INSTALLED_APPS`](https://docs.djangoproject.com/en/5.0/ref/applications/). This setting should
be used with caution!
Defaults to None, which does not add any additional apps.
## Document Consumption {#consume_config}
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}

View File

@ -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">

View File

@ -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)
})
})

View File

@ -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 {

View File

@ -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>&nbsp;<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}}&nbsp;<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>

View File

@ -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)
})
})

View File

@ -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
)
},
})
}
}

View File

@ -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
}

View 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([])
})
})

View 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'] ?? []
}
}

View File

@ -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')
})
})

View File

@ -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/`
)
}
}

View File

@ -18,7 +18,8 @@
</head>
<body class="text-center">
<form class="form-signin position-absolute top-50 start-50 translate-middle" method="post">
<div class="position-absolute top-50 start-50 translate-middle">
<form class="form-signin" method="post">
{% csrf_token %}
<svg xmlns="http://www.w3.org/2000/svg" width="300" class="logo mb-4" viewBox="0 0 2897.4 896.6">
<path class="leaf" d="M140,713.7c-3.4-16.4-10.3-49.1-11.2-49.1c-145.7-87.1-128.4-238-80.2-324.2C59,449,251.2,524,139.1,656.8 c-0.9,1.7,5.2,22.4,10.3,41.4c22.4-37.9,56-83.6,54.3-87.9C65.9,273.9,496.9,248.1,586.6,39.4c40.5,201.8-20.7,513.9-367.2,593.2 c-1.7,0.9-62.9,108.6-65.5,109.5c0-1.7-25.9-0.9-22.4-9.5C133.1,727.4,136.6,720.6,140,713.7L140,713.7z M135.7,632.6 c44-50.9-7.8-137.9-38.8-166.4C149.5,556.7,146,609.3,135.7,632.6L135.7,632.6z" transform="translate(0)" style="fill:#17541f"/>
@ -38,6 +39,11 @@
<path d="M757.6,293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5,39.2-21.1,76.4-37.6,111.3c-9.9,20.8-21.1,40.6-33.6,59.4v207.2h88.9 V521.5h72c25.2,0,47.8-5.4,67.8-16.2s35.7-25.6,47.1-44.2c11.4-18.7,17.1-39.1,17.1-61.3c0.1-22.7-5.6-43.3-17-61.9 C793.3,319.2,777.6,304.5,757.6,293.7z M716.6,434.3c-9.3,8.9-21.6,13.3-36.7,13.3l-62.2,0.4v-92.5l62.2-0.4 c15.1,0,27.3,4.4,36.7,13.3c9.4,8.9,14,19.9,14,32.9C730.6,414.5,726,425.4,716.6,434.3z" transform="translate(0)"/>
</g>
</svg>
{% for message in messages %}
<div class="alert alert-{{ message.level_tag }}" role="alert">
{{ message }}
</div>
{% endfor %}
<p>{% translate "Please sign in." %}</p>
{% if form.errors %}
<div class="alert alert-danger" role="alert">
@ -55,7 +61,7 @@
{% translate "Username" as i18n_username %}
{% translate "Password" as i18n_password %}
<div class="form-floating">
<input type="text" name="username" id="inputUsername" placeholder="{{ i18n_username }}" class="form-control" autocorrect="off" autocapitalize="none" required autofocus>
<input type="text" name="login" id="inputUsername" placeholder="{{ i18n_username }}" class="form-control" autocorrect="off" autocapitalize="none" required autofocus>
<label for="inputUsername">{{ i18n_username }}</label>
</div>
<div class="form-floating">
@ -67,9 +73,33 @@
</div>
{% if EMAIL_ENABLED %}
<div class="d-grid mt-3">
<a class="btn btn-link" href="{% url 'password_reset' %}">{% translate "Forgot your password?" %}</a>
<a class="btn btn-link" href="{% url 'account_reset_password' %}">{% translate "Forgot your password?" %}</a>
</div>
{% endif %}
</form>
{% load allauth socialaccount %}
{% get_providers as socialaccount_providers %}
{% if socialaccount_providers %}
<p class="mt-3">{% translate "or sign in via" %}</p>
<ul class="m-0 p-0">
{% for provider in socialaccount_providers %}
{% if provider.id == "openid" %}
{% for brand in provider.get_brands %}
{% provider_login_url provider openid=brand.openid_url process=process as href %}
<li class="d-grid mt-3"><a class="btn btn-secondary" href="{{ href }}">{{ brand.name }}</a></li>
{% endfor %}
{% else %}
{% provider_login_url provider process=process scope=scope auth_params=auth_params as href %}
<li class="d-grid mt-3">
<form class="d-grid" method="POST" action="{{ href }}">
{% csrf_token %}
<button type="submit" class="btn btn-secondary">{{ provider.name }}</button>
</form>
</li>
{% endif %}
{% endfor %}
</ul>
{% endif %}
</div>
</body>
</html>

View File

@ -2,6 +2,7 @@
{% load static %}
{% load i18n %}
{% load allauth %}
<html lang="en">
<head>
@ -18,7 +19,8 @@
</head>
<body class="text-center">
<form class="form-signin position-absolute top-50 start-50 translate-middle" method="post">
{% url 'account_reset_password' as reset_url %}
<form class="form-signin position-absolute top-50 start-50 translate-middle" method="post" action="{{reset_url}}">
{% csrf_token %}
<svg xmlns="http://www.w3.org/2000/svg" width="300" class="logo mb-4" viewBox="0 0 2897.4 896.6">
<path class="leaf" d="M140,713.7c-3.4-16.4-10.3-49.1-11.2-49.1c-145.7-87.1-128.4-238-80.2-324.2C59,449,251.2,524,139.1,656.8 c-0.9,1.7,5.2,22.4,10.3,41.4c22.4-37.9,56-83.6,54.3-87.9C65.9,273.9,496.9,248.1,586.6,39.4c40.5,201.8-20.7,513.9-367.2,593.2 c-1.7,0.9-62.9,108.6-65.5,109.5c0-1.7-25.9-0.9-22.4-9.5C133.1,727.4,136.6,720.6,140,713.7L140,713.7z M135.7,632.6 c44-50.9-7.8-137.9-38.8-166.4C149.5,556.7,146,609.3,135.7,632.6L135.7,632.6z" transform="translate(0)" style="fill:#17541f"/>
@ -47,7 +49,7 @@
{% translate "Email" as i18n_email %}
<h1></h1>
<div class="form-floating">
<input type="email" name="email" id="inputEmail" placeholder="{{ i18n_email }}" class="form-control" required>
<input type="{{form.email.type}}" name="{{form.email.name}}" id="inputEmail" placeholder="{{ i18n_email }}" class="form-control" required>
<label for="inputEmail">{{ i18n_email }}</label>
</div>
<div class="d-grid mt-3">

View File

@ -2,6 +2,7 @@
{% load static %}
{% load i18n %}
{% load allauth %}
<html lang="en">
<head>
@ -38,7 +39,10 @@
<path d="M757.6,293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5,39.2-21.1,76.4-37.6,111.3c-9.9,20.8-21.1,40.6-33.6,59.4v207.2h88.9 V521.5h72c25.2,0,47.8-5.4,67.8-16.2s35.7-25.6,47.1-44.2c11.4-18.7,17.1-39.1,17.1-61.3c0.1-22.7-5.6-43.3-17-61.9 C793.3,319.2,777.6,304.5,757.6,293.7z M716.6,434.3c-9.3,8.9-21.6,13.3-36.7,13.3l-62.2,0.4v-92.5l62.2-0.4 c15.1,0,27.3,4.4,36.7,13.3c9.4,8.9,14,19.9,14,32.9C730.6,414.5,726,425.4,716.6,434.3z" transform="translate(0)"/>
</g>
</svg>
{% if validlink %}
{% if token_fail %}
{% url 'account_reset_password' as passwd_reset_url %}
<p>The password reset link was invalid, possibly because it has already been used. Please <a class="btn btn-link" href="{{passwd_reset_url}}">{% translate "request a new password reset" %}</a>.</p>
{% else %}
<p>{% translate "Set a new password." %}</p>
{% if form.errors %}
<div class="alert alert-danger" role="alert">
@ -50,20 +54,16 @@
{% translate "Confirm Password" as i18n_new_password2 %}
<h1></h1>
<div class="form-floating">
<input type="password" name="new_password1" id="inputPassword1" placeholder="{{ i18n_new_password1 }}" class="form-control" required>
<input type="password" name="{{form.password1.name}}" id="inputPassword1" placeholder="{{ i18n_new_password1 }}" class="form-control" required>
<label for="inputPassword1">{{ i18n_new_password1 }}</label>
</div>
<div class="form-floating">
<input type="password" name="new_password2" id="inputPassword2" placeholder="{{ i18n_new_password2 }}" class="form-control" required>
<input type="password" name="{{form.password2.name}}" id="inputPassword2" placeholder="{{ i18n_new_password2 }}" class="form-control" required>
<label for="inputPassword2">{{ i18n_new_password2 }}</label>
</div>
<div class="d-grid mt-3">
<button class="btn btn-lg btn-primary" type="submit">{% translate "Change my password" %}</button>
</div>
{% else %}
<p>The password reset link was invalid, possibly because it has already been used. Please <a class="btn btn-link" href="{% url 'password_reset' %}">{% translate "request a new password reset" %}</a>.</p>
{% endif %}
</form>
</body>

View File

@ -38,7 +38,7 @@
</g>
</svg>
<h3>{% translate "Password reset complete." %}</h3>
{% url 'login' as login_url %}
{% url 'account_login' as login_url %}
<p>{% blocktranslate %}Your new password has been set. You can now <a href="{{ login_url }}">log in</a>{% endblocktranslate %}.</p>
</div>
</body>

View File

@ -80,6 +80,13 @@
<p class="warning m-auto mt-3 small fade hide">{% translate "Still here?! Hmm, something might be wrong." %} <a href="https://docs.paperless-ngx.com">{% translate "Here's a link to the docs." %}</a></p>
</div>
</div>
<script type="text/javascript">{# Pass Django messages to Angular frontend #}
window.DJANGO_MESSAGES = [
{% for message in messages %}
{ level: "{{ message.level_tag | escapejs }}", message: "{{ message | escapejs }}" },
{% endfor %}
]
</script>
</pngx-root>
<script src="{% static runtime_js %}" defer></script>
<script src="{% static polyfills_js %}" defer></script>

View File

@ -2,23 +2,25 @@
{% load static %}
{% load i18n %}
{% load allauth %}
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Paperless-ngx Signed Out">
<meta name="description" content="Paperless-ngx Sign In">
<meta name="author" content="Paperless-ngx project and contributors">
<meta name="robots" content="noindex,nofollow">
<title>{% translate "Paperless-ngx signed out" %}</title>
<title>{% translate "Paperless-ngx social account sign in" %}</title>
<link href="{% static 'bootstrap.min.css' %}" rel="stylesheet">
<link href="{% static 'bootstrap.min.css' %}" rel="stylesheet">
<link href="{% static 'signin.css' %}" rel="stylesheet">
</head>
<body class="text-center">
<div class="position-absolute top-50 start-50 translate-middle">
{% csrf_token %}
<svg xmlns="http://www.w3.org/2000/svg" width="300" class="logo mb-4" viewBox="0 0 2897.4 896.6">
<path class="leaf" d="M140,713.7c-3.4-16.4-10.3-49.1-11.2-49.1c-145.7-87.1-128.4-238-80.2-324.2C59,449,251.2,524,139.1,656.8 c-0.9,1.7,5.2,22.4,10.3,41.4c22.4-37.9,56-83.6,54.3-87.9C65.9,273.9,496.9,248.1,586.6,39.4c40.5,201.8-20.7,513.9-367.2,593.2 c-1.7,0.9-62.9,108.6-65.5,109.5c0-1.7-25.9-0.9-22.4-9.5C133.1,727.4,136.6,720.6,140,713.7L140,713.7z M135.7,632.6 c44-50.9-7.8-137.9-38.8-166.4C149.5,556.7,146,609.3,135.7,632.6L135.7,632.6z" transform="translate(0)" style="fill:#17541f"/>
<g class="text" style="fill:#000">
@ -37,8 +39,8 @@
<path d="M757.6,293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5,39.2-21.1,76.4-37.6,111.3c-9.9,20.8-21.1,40.6-33.6,59.4v207.2h88.9 V521.5h72c25.2,0,47.8-5.4,67.8-16.2s35.7-25.6,47.1-44.2c11.4-18.7,17.1-39.1,17.1-61.3c0.1-22.7-5.6-43.3-17-61.9 C793.3,319.2,777.6,304.5,757.6,293.7z M716.6,434.3c-9.3,8.9-21.6,13.3-36.7,13.3l-62.2,0.4v-92.5l62.2-0.4 c15.1,0,27.3,4.4,36.7,13.3c9.4,8.9,14,19.9,14,32.9C730.6,414.5,726,425.4,716.6,434.3z" transform="translate(0)"/>
</g>
</svg>
<p>{% translate "You have been successfully logged out. Bye!" %}</p>
<a href="{% url 'base' %}">{% translate "Sign in again" %}</a>
</div>
{% url 'account_login' as login_url %}
<p>{% blocktranslate %}An error occurred while attempting to login via your social network account. Back to the <a href="{{ login_url }}">login page</a>{% endblocktranslate %}</p>
</div>
</body>
</html>

View File

@ -0,0 +1,52 @@
<!doctype html>
{% load static %}
{% load i18n %}
{% load allauth %}
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Paperless-ngx Sign In">
<meta name="author" content="Paperless-ngx project and contributors">
<meta name="robots" content="noindex,nofollow">
<title>{% translate "Paperless-ngx social account sign in" %}</title>
<link href="{% static 'bootstrap.min.css' %}" rel="stylesheet">
<link href="{% static 'signin.css' %}" rel="stylesheet">
</head>
<body class="text-center">
<div class="position-absolute top-50 start-50 translate-middle">
<form class="form-signin" method="post">
{% csrf_token %}
<svg xmlns="http://www.w3.org/2000/svg" width="300" class="logo mb-4" viewBox="0 0 2897.4 896.6">
<path class="leaf" d="M140,713.7c-3.4-16.4-10.3-49.1-11.2-49.1c-145.7-87.1-128.4-238-80.2-324.2C59,449,251.2,524,139.1,656.8 c-0.9,1.7,5.2,22.4,10.3,41.4c22.4-37.9,56-83.6,54.3-87.9C65.9,273.9,496.9,248.1,586.6,39.4c40.5,201.8-20.7,513.9-367.2,593.2 c-1.7,0.9-62.9,108.6-65.5,109.5c0-1.7-25.9-0.9-22.4-9.5C133.1,727.4,136.6,720.6,140,713.7L140,713.7z M135.7,632.6 c44-50.9-7.8-137.9-38.8-166.4C149.5,556.7,146,609.3,135.7,632.6L135.7,632.6z" transform="translate(0)" style="fill:#17541f"/>
<g class="text" style="fill:#000">
<path d="M1022.3,428.7c-17.8-19.9-42.7-29.8-74.7-29.8c-22.3,0-42.4,5.7-60.5,17.3c-18.1,11.6-32.3,27.5-42.5,47.8 s-15.3,42.9-15.3,67.8c0,24.9,5.1,47.5,15.3,67.8c10.3,20.3,24.4,36.2,42.5,47.8c18.1,11.5,38.3,17.3,60.5,17.3 c32,0,56.9-9.9,74.7-29.8v20.4v0.2h84.5V408.3h-84.5V428.7z M1010.5,575c-10.2,11.7-23.6,17.6-40.2,17.6s-29.9-5.9-40-17.6 s-15.1-26.1-15.1-43.3c0-17.1,5-31.6,15.1-43.3s23.4-17.6,40-17.6c16.6,0,30,5.9,40.2,17.6s15.3,26.1,15.3,43.3 S1020.7,563.3,1010.5,575z" transform="translate(0)"/>
<path d="M1381,416.1c-18.1-11.5-38.3-17.3-60.5-17.4c-32,0-56.9,9.9-74.7,29.8v-20.4h-84.5v390.7h84.5v-164 c17.8,19.9,42.7,29.8,74.7,29.8c22.3,0,42.4-5.7,60.5-17.3s32.3-27.5,42.5-47.8c10.2-20.3,15.3-42.9,15.3-67.8s-5.1-47.5-15.3-67.8 C1413.2,443.6,1399.1,427.7,1381,416.1z M1337.9,575c-10.1,11.7-23.4,17.6-40,17.6s-29.9-5.9-40-17.6s-15.1-26.1-15.1-43.3 c0-17.1,5-31.6,15.1-43.3s23.4-17.6,40-17.6s29.9,5.9,40,17.6s15.1,26.1,15.1,43.3S1347.9,563.3,1337.9,575z" transform="translate(0)"/>
<path d="M1672.2,416.8c-20.5-12-43-18-67.6-18c-24.9,0-47.6,5.9-68,17.6c-20.4,11.7-36.5,27.7-48.2,48s-17.6,42.7-17.6,67.3 c0.3,25.2,6.2,47.8,17.8,68c11.5,20.2,28,36,49.3,47.6c21.3,11.5,45.9,17.3,73.8,17.3c48.6,0,86.8-14.7,114.7-44l-52.5-48.9 c-8.6,8.3-17.6,14.6-26.7,19c-9.3,4.3-21.1,6.4-35.3,6.4c-11.6,0-22.5-3.6-32.7-10.9c-10.3-7.3-17.1-16.5-20.7-27.8h180l0.4-11.6 c0-29.6-6-55.7-18-78.2S1692.6,428.8,1672.2,416.8z M1558.3,503.2c2.1-12.1,7.5-21.8,16.2-29.1s18.7-10.9,30-10.9 s21.2,3.6,29.8,10.9c8.6,7.2,13.9,16.9,16,29.1H1558.3z" transform="translate(0)"/>
<path d="M1895.3,411.7c-11,5.6-20.3,13.7-28,24.4h-0.1v-28h-84.5v247.3h84.5V536.3c0-22.6,4.7-38.1,14.2-46.5 c9.5-8.5,22.7-12.7,39.6-12.7c6.2,0,13.5,1,21.8,3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9C1917.1,403.3,1906.3,406.1,1895.3,411.7z" transform="translate(0)"/>
<rect x="1985" y="277.4" width="84.5" height="377.8" transform="translate(0)"/>
<path d="M2313.2,416.8c-20.5-12-43-18-67.6-18c-24.9,0-47.6,5.9-68,17.6s-36.5,27.7-48.2,48c-11.7,20.3-17.6,42.7-17.6,67.3 c0.3,25.2,6.2,47.8,17.8,68c11.5,20.2,28,36,49.3,47.6c21.3,11.5,45.9,17.3,73.8,17.3c48.6,0,86.8-14.7,114.7-44l-52.5-48.9 c-8.6,8.3-17.6,14.6-26.7,19c-9.3,4.3-21.1,6.4-35.3,6.4c-11.6,0-22.5-3.6-32.7-10.9c-10.3-7.3-17.1-16.5-20.7-27.8h180l0.4-11.6 c0-29.6-6-55.7-18-78.2S2333.6,428.8,2313.2,416.8z M2199.3,503.2c2.1-12.1,7.5-21.8,16.2-29.1s18.7-10.9,30-10.9 s21.2,3.6,29.8,10.9c8.6,7.2,13.9,16.9,16,29.1H2199.3z" transform="translate(0)"/>
<path d="M2583.6,507.7c-13.8-4.4-30.6-8.1-50.5-11.1c-15.1-2.7-26.1-5.2-32.9-7.6c-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7,6.7-10.9 c4.4-2.2,11.5-3.3,21.3-3.3c11.6,0,24.3,2.4,38.1,7.2c13.9,4.8,26.2,11,36.9,18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8 c-18.7-7.1-39.6-10.7-62.7-10.7c-33.7,0-60.2,7.6-79.3,22.7c-19.1,15.1-28.7,36.1-28.7,63.1c0,19,4.8,33.9,14.4,44.7 c9.6,10.8,21,18.5,34,22.9c13.1,4.5,28.9,8.3,47.6,11.6c14.6,2.7,25.1,5.3,31.6,7.8s9.8,6.5,9.8,11.8c0,10.4-9.7,15.6-29.3,15.6 c-13.7,0-28.5-2.3-44.7-6.9c-16.1-4.6-29.2-11.3-39.3-20.2l-33.3,60c9.2,7.4,24.6,14.7,46.2,22c21.7,7.3,45.2,10.9,70.7,10.9 c34.7,0,62.9-7.4,84.5-22.4c21.7-15,32.5-37.3,32.5-66.9c0-19.3-5-34.2-15.1-44.9S2597.4,512.1,2583.6,507.7z" transform="translate(0)"/>
<path d="M2883.4,575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1c-15.1-2.7-26.1-5.2-32.9-7.6 c-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7,6.7-10.9c4.4-2.2,11.5-3.3,21.3-3.3c11.6,0,24.3,2.4,38.1,7.2c13.9,4.8,26.2,11,36.9,18.4 l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8c-18.7-7.1-39.6-10.7-62.7-10.7c-33.7,0-60.2,7.6-79.3,22.7 c-19.1,15.1-28.7,36.1-28.7,63.1c0,19,4.8,33.9,14.4,44.7c9.6,10.8,21,18.5,34,22.9c13.1,4.5,28.9,8.3,47.6,11.6 c14.6,2.7,25.1,5.3,31.6,7.8s9.8,6.5,9.8,11.8c0,10.4-9.7,15.6-29.3,15.6c-13.7,0-28.5-2.3-44.7-6.9c-16.1-4.6-29.2-11.3-39.3-20.2 l-33.3,60c9.2,7.4,24.6,14.7,46.2,22c21.7,7.3,45.2,10.9,70.7,10.9c34.7,0,62.9-7.4,84.5-22.4 C2872.6,627.2,2883.4,604.9,2883.4,575.3z" transform="translate(0)"/>
<rect x="2460.7" y="738.7" width="59.6" height="17.2" transform="translate(0)"/>
<path d="M2596.5,706.4c-5.7,0-11,1-15.8,3s-9,5-12.5,8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6,2.1-15.3,6.3-20c4.2-4.7,9.5-7.1,15.9-7.1 c7.8,0,13.4,2.3,16.8,6.7c3.4,4.5,5.1,11.3,5.1,20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3 C2615.8,709.8,2607.3,706.4,2596.5,706.4z" transform="translate(0)"/>
<path d="M2733.8,717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7,0-16.5,2.1-23.5,6.3s-12.5,10-16.5,17.3 c-4,7.3-6,15.4-6,24.4c0,8.9,2,17.1,6,24.3c4,7.3,9.5,13,16.5,17.2s14.9,6.3,23.5,6.3c5.6,0,11-1,16.2-3.1 c5.1-2.1,9.5-4.8,13.1-8.2v24.4c0,8.5-2.5,14.8-7.6,18.7c-5,3.9-11,5.9-18,5.9c-6.7,0-12.4-1.6-17.3-4.7c-4.8-3.1-7.6-7.7-8.3-13.8 h-19.4c0.6,7.7,2.9,14.2,7.1,19.5s9.6,9.3,16.2,12c6.6,2.7,13.8,4,21.7,4c12.8,0,23.5-3.4,32-10.1c8.6-6.7,12.8-17.1,12.8-31.1 V708.9h-19.2V717.7z M2732.2,770.1c-2.5,4.7-6,8.3-10.4,11.2c-4.4,2.7-9.4,4-14.9,4c-5.7,0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4 c-2.3-4.8-3.5-9.8-3.5-15.2c0-5.5,1.1-10.6,3.5-15.3s5.8-8.5,10.2-11.3s9.5-4.2,15.2-4.2c5.5,0,10.5,1.4,14.9,4s7.9,6.3,10.4,11 s3.8,10,3.8,15.8S2734.7,765.4,2732.2,770.1z" transform="translate(0)"/>
<polygon points="2867.9,708.9 2846.5,708.9 2820.9,741.9 2795.5,708.9 2773.1,708.9 2809.1,755 2771.5,802.5 2792.9,802.5 2820.1,767.9 2847.2,802.6 2869.6,802.6 2832,754.4 " transform="translate(0)"/>
<path d="M757.6,293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5,39.2-21.1,76.4-37.6,111.3c-9.9,20.8-21.1,40.6-33.6,59.4v207.2h88.9 V521.5h72c25.2,0,47.8-5.4,67.8-16.2s35.7-25.6,47.1-44.2c11.4-18.7,17.1-39.1,17.1-61.3c0.1-22.7-5.6-43.3-17-61.9 C793.3,319.2,777.6,304.5,757.6,293.7z M716.6,434.3c-9.3,8.9-21.6,13.3-36.7,13.3l-62.2,0.4v-92.5l62.2-0.4 c15.1,0,27.3,4.4,36.7,13.3c9.4,8.9,14,19.9,14,32.9C730.6,414.5,726,425.4,716.6,434.3z" transform="translate(0)"/>
</g>
</svg>
<p>
{% blocktrans with provider.name as provider %}You are about to connect a new third-party account from {{ provider }}.{% endblocktrans %}
</p>
<div class="d-grid mt-3">
<button class="btn btn-lg btn-primary" type="submit">{% translate "Continue" %}</button>
</div>
</form>
</div>
</body>
</html>

View File

@ -0,0 +1,77 @@
<!doctype html>
{% load static %}
{% load i18n %}
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Paperless-ngx Sign In">
<meta name="author" content="Paperless-ngx project and contributors">
<meta name="robots" content="noindex,nofollow">
<title>{% translate "Paperless-ngx social account sign up" %}</title>
<link href="{% static 'bootstrap.min.css' %}" rel="stylesheet">
<link href="{% static 'signin.css' %}" rel="stylesheet">
</head>
<body class="text-center">
<div class="position-absolute top-50 start-50 translate-middle">
<form class="form-signin" method="post" action="{% url 'socialaccount_signup' %}">
{% csrf_token %}
<svg xmlns="http://www.w3.org/2000/svg" width="300" class="logo mb-4" viewBox="0 0 2897.4 896.6">
<path class="leaf" d="M140,713.7c-3.4-16.4-10.3-49.1-11.2-49.1c-145.7-87.1-128.4-238-80.2-324.2C59,449,251.2,524,139.1,656.8 c-0.9,1.7,5.2,22.4,10.3,41.4c22.4-37.9,56-83.6,54.3-87.9C65.9,273.9,496.9,248.1,586.6,39.4c40.5,201.8-20.7,513.9-367.2,593.2 c-1.7,0.9-62.9,108.6-65.5,109.5c0-1.7-25.9-0.9-22.4-9.5C133.1,727.4,136.6,720.6,140,713.7L140,713.7z M135.7,632.6 c44-50.9-7.8-137.9-38.8-166.4C149.5,556.7,146,609.3,135.7,632.6L135.7,632.6z" transform="translate(0)" style="fill:#17541f"/>
<g class="text" style="fill:#000">
<path d="M1022.3,428.7c-17.8-19.9-42.7-29.8-74.7-29.8c-22.3,0-42.4,5.7-60.5,17.3c-18.1,11.6-32.3,27.5-42.5,47.8 s-15.3,42.9-15.3,67.8c0,24.9,5.1,47.5,15.3,67.8c10.3,20.3,24.4,36.2,42.5,47.8c18.1,11.5,38.3,17.3,60.5,17.3 c32,0,56.9-9.9,74.7-29.8v20.4v0.2h84.5V408.3h-84.5V428.7z M1010.5,575c-10.2,11.7-23.6,17.6-40.2,17.6s-29.9-5.9-40-17.6 s-15.1-26.1-15.1-43.3c0-17.1,5-31.6,15.1-43.3s23.4-17.6,40-17.6c16.6,0,30,5.9,40.2,17.6s15.3,26.1,15.3,43.3 S1020.7,563.3,1010.5,575z" transform="translate(0)"/>
<path d="M1381,416.1c-18.1-11.5-38.3-17.3-60.5-17.4c-32,0-56.9,9.9-74.7,29.8v-20.4h-84.5v390.7h84.5v-164 c17.8,19.9,42.7,29.8,74.7,29.8c22.3,0,42.4-5.7,60.5-17.3s32.3-27.5,42.5-47.8c10.2-20.3,15.3-42.9,15.3-67.8s-5.1-47.5-15.3-67.8 C1413.2,443.6,1399.1,427.7,1381,416.1z M1337.9,575c-10.1,11.7-23.4,17.6-40,17.6s-29.9-5.9-40-17.6s-15.1-26.1-15.1-43.3 c0-17.1,5-31.6,15.1-43.3s23.4-17.6,40-17.6s29.9,5.9,40,17.6s15.1,26.1,15.1,43.3S1347.9,563.3,1337.9,575z" transform="translate(0)"/>
<path d="M1672.2,416.8c-20.5-12-43-18-67.6-18c-24.9,0-47.6,5.9-68,17.6c-20.4,11.7-36.5,27.7-48.2,48s-17.6,42.7-17.6,67.3 c0.3,25.2,6.2,47.8,17.8,68c11.5,20.2,28,36,49.3,47.6c21.3,11.5,45.9,17.3,73.8,17.3c48.6,0,86.8-14.7,114.7-44l-52.5-48.9 c-8.6,8.3-17.6,14.6-26.7,19c-9.3,4.3-21.1,6.4-35.3,6.4c-11.6,0-22.5-3.6-32.7-10.9c-10.3-7.3-17.1-16.5-20.7-27.8h180l0.4-11.6 c0-29.6-6-55.7-18-78.2S1692.6,428.8,1672.2,416.8z M1558.3,503.2c2.1-12.1,7.5-21.8,16.2-29.1s18.7-10.9,30-10.9 s21.2,3.6,29.8,10.9c8.6,7.2,13.9,16.9,16,29.1H1558.3z" transform="translate(0)"/>
<path d="M1895.3,411.7c-11,5.6-20.3,13.7-28,24.4h-0.1v-28h-84.5v247.3h84.5V536.3c0-22.6,4.7-38.1,14.2-46.5 c9.5-8.5,22.7-12.7,39.6-12.7c6.2,0,13.5,1,21.8,3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9C1917.1,403.3,1906.3,406.1,1895.3,411.7z" transform="translate(0)"/>
<rect x="1985" y="277.4" width="84.5" height="377.8" transform="translate(0)"/>
<path d="M2313.2,416.8c-20.5-12-43-18-67.6-18c-24.9,0-47.6,5.9-68,17.6s-36.5,27.7-48.2,48c-11.7,20.3-17.6,42.7-17.6,67.3 c0.3,25.2,6.2,47.8,17.8,68c11.5,20.2,28,36,49.3,47.6c21.3,11.5,45.9,17.3,73.8,17.3c48.6,0,86.8-14.7,114.7-44l-52.5-48.9 c-8.6,8.3-17.6,14.6-26.7,19c-9.3,4.3-21.1,6.4-35.3,6.4c-11.6,0-22.5-3.6-32.7-10.9c-10.3-7.3-17.1-16.5-20.7-27.8h180l0.4-11.6 c0-29.6-6-55.7-18-78.2S2333.6,428.8,2313.2,416.8z M2199.3,503.2c2.1-12.1,7.5-21.8,16.2-29.1s18.7-10.9,30-10.9 s21.2,3.6,29.8,10.9c8.6,7.2,13.9,16.9,16,29.1H2199.3z" transform="translate(0)"/>
<path d="M2583.6,507.7c-13.8-4.4-30.6-8.1-50.5-11.1c-15.1-2.7-26.1-5.2-32.9-7.6c-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7,6.7-10.9 c4.4-2.2,11.5-3.3,21.3-3.3c11.6,0,24.3,2.4,38.1,7.2c13.9,4.8,26.2,11,36.9,18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8 c-18.7-7.1-39.6-10.7-62.7-10.7c-33.7,0-60.2,7.6-79.3,22.7c-19.1,15.1-28.7,36.1-28.7,63.1c0,19,4.8,33.9,14.4,44.7 c9.6,10.8,21,18.5,34,22.9c13.1,4.5,28.9,8.3,47.6,11.6c14.6,2.7,25.1,5.3,31.6,7.8s9.8,6.5,9.8,11.8c0,10.4-9.7,15.6-29.3,15.6 c-13.7,0-28.5-2.3-44.7-6.9c-16.1-4.6-29.2-11.3-39.3-20.2l-33.3,60c9.2,7.4,24.6,14.7,46.2,22c21.7,7.3,45.2,10.9,70.7,10.9 c34.7,0,62.9-7.4,84.5-22.4c21.7-15,32.5-37.3,32.5-66.9c0-19.3-5-34.2-15.1-44.9S2597.4,512.1,2583.6,507.7z" transform="translate(0)"/>
<path d="M2883.4,575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1c-15.1-2.7-26.1-5.2-32.9-7.6 c-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7,6.7-10.9c4.4-2.2,11.5-3.3,21.3-3.3c11.6,0,24.3,2.4,38.1,7.2c13.9,4.8,26.2,11,36.9,18.4 l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8c-18.7-7.1-39.6-10.7-62.7-10.7c-33.7,0-60.2,7.6-79.3,22.7 c-19.1,15.1-28.7,36.1-28.7,63.1c0,19,4.8,33.9,14.4,44.7c9.6,10.8,21,18.5,34,22.9c13.1,4.5,28.9,8.3,47.6,11.6 c14.6,2.7,25.1,5.3,31.6,7.8s9.8,6.5,9.8,11.8c0,10.4-9.7,15.6-29.3,15.6c-13.7,0-28.5-2.3-44.7-6.9c-16.1-4.6-29.2-11.3-39.3-20.2 l-33.3,60c9.2,7.4,24.6,14.7,46.2,22c21.7,7.3,45.2,10.9,70.7,10.9c34.7,0,62.9-7.4,84.5-22.4 C2872.6,627.2,2883.4,604.9,2883.4,575.3z" transform="translate(0)"/>
<rect x="2460.7" y="738.7" width="59.6" height="17.2" transform="translate(0)"/>
<path d="M2596.5,706.4c-5.7,0-11,1-15.8,3s-9,5-12.5,8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6,2.1-15.3,6.3-20c4.2-4.7,9.5-7.1,15.9-7.1 c7.8,0,13.4,2.3,16.8,6.7c3.4,4.5,5.1,11.3,5.1,20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3 C2615.8,709.8,2607.3,706.4,2596.5,706.4z" transform="translate(0)"/>
<path d="M2733.8,717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7,0-16.5,2.1-23.5,6.3s-12.5,10-16.5,17.3 c-4,7.3-6,15.4-6,24.4c0,8.9,2,17.1,6,24.3c4,7.3,9.5,13,16.5,17.2s14.9,6.3,23.5,6.3c5.6,0,11-1,16.2-3.1 c5.1-2.1,9.5-4.8,13.1-8.2v24.4c0,8.5-2.5,14.8-7.6,18.7c-5,3.9-11,5.9-18,5.9c-6.7,0-12.4-1.6-17.3-4.7c-4.8-3.1-7.6-7.7-8.3-13.8 h-19.4c0.6,7.7,2.9,14.2,7.1,19.5s9.6,9.3,16.2,12c6.6,2.7,13.8,4,21.7,4c12.8,0,23.5-3.4,32-10.1c8.6-6.7,12.8-17.1,12.8-31.1 V708.9h-19.2V717.7z M2732.2,770.1c-2.5,4.7-6,8.3-10.4,11.2c-4.4,2.7-9.4,4-14.9,4c-5.7,0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4 c-2.3-4.8-3.5-9.8-3.5-15.2c0-5.5,1.1-10.6,3.5-15.3s5.8-8.5,10.2-11.3s9.5-4.2,15.2-4.2c5.5,0,10.5,1.4,14.9,4s7.9,6.3,10.4,11 s3.8,10,3.8,15.8S2734.7,765.4,2732.2,770.1z" transform="translate(0)"/>
<polygon points="2867.9,708.9 2846.5,708.9 2820.9,741.9 2795.5,708.9 2773.1,708.9 2809.1,755 2771.5,802.5 2792.9,802.5 2820.1,767.9 2847.2,802.6 2869.6,802.6 2832,754.4 " transform="translate(0)"/>
<path d="M757.6,293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5,39.2-21.1,76.4-37.6,111.3c-9.9,20.8-21.1,40.6-33.6,59.4v207.2h88.9 V521.5h72c25.2,0,47.8-5.4,67.8-16.2s35.7-25.6,47.1-44.2c11.4-18.7,17.1-39.1,17.1-61.3c0.1-22.7-5.6-43.3-17-61.9 C793.3,319.2,777.6,304.5,757.6,293.7z M716.6,434.3c-9.3,8.9-21.6,13.3-36.7,13.3l-62.2,0.4v-92.5l62.2-0.4 c15.1,0,27.3,4.4,36.7,13.3c9.4,8.9,14,19.9,14,32.9C730.6,414.5,726,425.4,716.6,434.3z" transform="translate(0)"/>
</g>
</svg>
<!-- TODO: Translations? -->
{% if form.errors.username %}
<div class="alert alert-danger" role="alert">
{{ form.errors.username }}
</div>
{% endif %}
{% if form.errors.email %}
<div class="alert alert-danger" role="alert">
{{ form.errors.email }}
</div>
{% endif %}
{% blocktrans with provider_name=account.get_provider.name site_name=site.name %}You are about to use your {{provider_name}} account to login to
{{site_name}}. As a final step, please complete the following form:{% endblocktrans %}
</p>
{% translate "Username" as i18n_username %}
{% translate "Email" as i18n_email %}
<div class="form-floating">
<input type="{{ form.username.type }}" name="{{ form.username.name }}" id="inputUsername" placeholder="{{ i18n_username }}" class="form-control" autocorrect="off" autocapitalize="none" required autofocus value="{{ form.username.value }}">
<label for="inputUsername">{{ i18n_username }}</label>
</div>
<div class="form-floating">
<input type="{{ form.email.type }}" name="{{ form.email.name }}" id="inputEmail" placeholder="{{ i18n_email }}" class="form-control" autocorrect="off" autocapitalize="none" required autofocus value="{{ form.email.value }}">
<label for="inputEmail">{{ i18n_email }}</label>
</div>
{% if redirect_field_value %}
<input type="hidden"
name="{{ redirect_field_name }}"
value="{{ redirect_field_value }}" />
{% endif %}
<div class="d-grid mt-3">
<button class="btn btn-lg btn-primary" type="submit">{% translate "Sign up" %}</button>
</div>
</form>
</div>
</body>
</html>

View File

@ -1,3 +1,7 @@
from unittest import mock
from allauth.socialaccount.models import SocialAccount
from allauth.socialaccount.models import SocialApp
from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.authtoken.models import Token
@ -6,6 +10,44 @@ from rest_framework.test import APITestCase
from documents.tests.utils import DirectoriesMixin
# see allauth.socialaccount.providers.openid.provider.OpenIDProvider
class MockOpenIDProvider:
id = "openid"
name = "OpenID"
def get_brands(self):
default_servers = [
dict(id="yahoo", name="Yahoo", openid_url="http://me.yahoo.com"),
dict(id="hyves", name="Hyves", openid_url="http://hyves.nl"),
]
return default_servers
def get_login_url(self, request, **kwargs):
return "openid/login/"
# see allauth.socialaccount.providers.openid_connect.provider.OpenIDConnectProviderAccount
class MockOpenIDConnectProviderAccount:
def __init__(self, mock_social_account_dict):
self.account = mock_social_account_dict
def to_str(self):
return self.account["name"]
# see allauth.socialaccount.providers.openid_connect.provider.OpenIDConnectProvider
class MockOpenIDConnectProvider:
id = "openid_connect"
name = "OpenID Connect"
def __init__(self, app=None):
self.app = app
self.name = app.name
def get_login_url(self, request, **kwargs):
return f"{self.app.provider_id}/login/?process=connect"
class TestApiProfile(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/profile/"
@ -19,6 +61,17 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
)
self.client.force_authenticate(user=self.user)
def setupSocialAccount(self):
SocialApp.objects.create(
name="Keycloak",
provider="openid_connect",
provider_id="keycloak-test",
)
self.user.socialaccount_set.add(
SocialAccount(uid="123456789", provider="keycloak-test"),
bulk=False,
)
def test_get_profile(self):
"""
GIVEN:
@ -28,7 +81,6 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
THEN:
- Profile is returned
"""
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
@ -37,6 +89,52 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
self.assertEqual(response.data["first_name"], self.user.first_name)
self.assertEqual(response.data["last_name"], self.user.last_name)
@mock.patch(
"allauth.socialaccount.models.SocialAccount.get_provider_account",
)
@mock.patch(
"allauth.socialaccount.adapter.DefaultSocialAccountAdapter.list_providers",
)
def test_get_profile_w_social(self, mock_list_providers, mock_get_provider_account):
"""
GIVEN:
- Configured user and setup social account
WHEN:
- API call is made to get profile
THEN:
- Profile is returned with social accounts
"""
self.setupSocialAccount()
openid_provider = (
MockOpenIDConnectProvider(
app=SocialApp.objects.get(provider_id="keycloak-test"),
),
)
mock_list_providers.return_value = [
openid_provider,
]
mock_get_provider_account.return_value = MockOpenIDConnectProviderAccount(
mock_social_account_dict={
"name": openid_provider[0].name,
},
)
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data["social_accounts"],
[
{
"id": 1,
"provider": "keycloak-test",
"name": "Keycloak",
},
],
)
def test_update_profile(self):
"""
GIVEN:
@ -103,3 +201,101 @@ class TestApiProfile(DirectoriesMixin, APITestCase):
response = self.client.post(f"{self.ENDPOINT}generate_auth_token/")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@mock.patch(
"allauth.socialaccount.adapter.DefaultSocialAccountAdapter.list_providers",
)
def test_get_social_account_providers(
self,
mock_list_providers,
):
"""
GIVEN:
- Configured user
WHEN:
- API call is made to get social account providers
THEN:
- Social account providers are returned
"""
self.setupSocialAccount()
mock_list_providers.return_value = [
MockOpenIDConnectProvider(
app=SocialApp.objects.get(provider_id="keycloak-test"),
),
]
response = self.client.get(f"{self.ENDPOINT}social_account_providers/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data[0]["name"],
"Keycloak",
)
self.assertIn(
"keycloak-test/login/?process=connect",
response.data[0]["login_url"],
)
@mock.patch(
"allauth.socialaccount.adapter.DefaultSocialAccountAdapter.list_providers",
)
def test_get_social_account_providers_openid(
self,
mock_list_providers,
):
"""
GIVEN:
- Configured user and openid social account provider
WHEN:
- API call is made to get social account providers
THEN:
- Brands for openid provider are returned
"""
mock_list_providers.return_value = [
MockOpenIDProvider(),
]
response = self.client.get(f"{self.ENDPOINT}social_account_providers/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
len(response.data),
2,
)
def test_disconnect_social_account(self):
"""
GIVEN:
- Configured user
WHEN:
- API call is made to disconnect a social account
THEN:
- Social account is deleted from the user or request fails
"""
self.setupSocialAccount()
# Test with invalid id
response = self.client.post(
f"{self.ENDPOINT}disconnect_social_account/",
{"id": -1},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
# Test with valid id
social_account_id = self.user.socialaccount_set.all()[0].pk
response = self.client.post(
f"{self.ENDPOINT}disconnect_social_account/",
{"id": social_account_id},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, social_account_id)
self.assertEqual(
len(self.user.socialaccount_set.filter(pk=social_account_id)),
0,
)

View File

@ -177,9 +177,9 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
os.path.join(self.dirs.media_dir, "documents"),
)
manifest = self._do_export(use_filename_format=use_filename_format)
num_permission_objects = Permission.objects.count()
self.assertEqual(len(manifest), 190)
manifest = self._do_export(use_filename_format=use_filename_format)
# dont include consumer or AnonymousUser users
self.assertEqual(
@ -273,7 +273,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertEqual(Document.objects.get(id=self.d4.id).title, "wow_dec")
self.assertEqual(GroupObjectPermission.objects.count(), 1)
self.assertEqual(UserObjectPermission.objects.count(), 1)
self.assertEqual(Permission.objects.count(), 136)
self.assertEqual(Permission.objects.count(), num_permission_objects)
messages = check_sanity()
# everything is alright after the test
self.assertEqual(len(messages), 0)
@ -753,15 +753,15 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
os.path.join(self.dirs.media_dir, "documents"),
)
self.assertEqual(ContentType.objects.count(), 34)
self.assertEqual(Permission.objects.count(), 136)
num_content_type_objects = ContentType.objects.count()
num_permission_objects = Permission.objects.count()
manifest = self._do_export()
with paperless_environment():
self.assertEqual(
len(list(filter(lambda e: e["model"] == "auth.permission", manifest))),
136,
num_permission_objects,
)
# add 1 more to db to show objects are not re-created by import
Permission.objects.create(
@ -769,7 +769,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
codename="test_perm",
content_type_id=1,
)
self.assertEqual(Permission.objects.count(), 137)
self.assertEqual(Permission.objects.count(), num_permission_objects + 1)
# will cause an import error
self.user.delete()
@ -778,5 +778,5 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
with self.assertRaises(IntegrityError):
call_command("document_importer", "--no-progress-bar", self.target)
self.assertEqual(ContentType.objects.count(), 34)
self.assertEqual(Permission.objects.count(), 137)
self.assertEqual(ContentType.objects.count(), num_content_type_objects)
self.assertEqual(Permission.objects.count(), num_permission_objects + 1)

View File

@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-02-02 20:17-0800\n"
"POT-Creation-Date: 2024-02-07 06:20+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@ -777,15 +777,136 @@ msgstr ""
msgid "Invalid color."
msgstr ""
#: documents/serialisers.py:1049
#: documents/serialisers.py:1060
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
#: documents/serialisers.py:1152
#: documents/serialisers.py:1163
msgid "Invalid variable detected."
msgstr ""
#: documents/templates/account/login.html:14
msgid "Paperless-ngx sign in"
msgstr ""
#: documents/templates/account/login.html:47
msgid "Please sign in."
msgstr ""
#: documents/templates/account/login.html:50
msgid "Your username and password didn't match. Please try again."
msgstr ""
#: documents/templates/account/login.html:54
msgid "Share link was not found."
msgstr ""
#: documents/templates/account/login.html:58
msgid "Share link has expired."
msgstr ""
#: documents/templates/account/login.html:61
#: documents/templates/socialaccount/signup.html:56
msgid "Username"
msgstr ""
#: documents/templates/account/login.html:62
msgid "Password"
msgstr ""
#: documents/templates/account/login.html:72
msgid "Sign in"
msgstr ""
#: documents/templates/account/login.html:76
msgid "Forgot your password?"
msgstr ""
#: documents/templates/account/login.html:83
msgid "or sign in via"
msgstr ""
#: documents/templates/account/password_reset.html:15
msgid "Paperless-ngx reset password request"
msgstr ""
#: documents/templates/account/password_reset.html:43
msgid ""
"Enter your email address below, and we'll email instructions for setting a "
"new one."
msgstr ""
#: documents/templates/account/password_reset.html:46
msgid "An error occurred. Please try again."
msgstr ""
#: documents/templates/account/password_reset.html:49
#: documents/templates/socialaccount/signup.html:57
msgid "Email"
msgstr ""
#: documents/templates/account/password_reset.html:56
msgid "Send me instructions!"
msgstr ""
#: documents/templates/account/password_reset_done.html:14
msgid "Paperless-ngx reset password sent"
msgstr ""
#: documents/templates/account/password_reset_done.html:40
msgid "Check your inbox."
msgstr ""
#: documents/templates/account/password_reset_done.html:41
msgid ""
"We've emailed you instructions for setting your password. You should receive "
"the email shortly!"
msgstr ""
#: documents/templates/account/password_reset_from_key.html:15
msgid "Paperless-ngx reset password confirmation"
msgstr ""
#: documents/templates/account/password_reset_from_key.html:44
msgid "request a new password reset"
msgstr ""
#: documents/templates/account/password_reset_from_key.html:46
msgid "Set a new password."
msgstr ""
#: documents/templates/account/password_reset_from_key.html:50
msgid "Passwords did not match or too weak. Try again."
msgstr ""
#: documents/templates/account/password_reset_from_key.html:53
msgid "New Password"
msgstr ""
#: documents/templates/account/password_reset_from_key.html:54
msgid "Confirm Password"
msgstr ""
#: documents/templates/account/password_reset_from_key.html:65
msgid "Change my password"
msgstr ""
#: documents/templates/account/password_reset_from_key_done.html:14
msgid "Paperless-ngx reset password complete"
msgstr ""
#: documents/templates/account/password_reset_from_key_done.html:40
msgid "Password reset complete."
msgstr ""
#: documents/templates/account/password_reset_from_key_done.html:42
#, python-format
msgid ""
"Your new password has been set. You can now <a href=\"%(login_url)s\">log "
"in</a>"
msgstr ""
#: documents/templates/index.html:79
msgid "Paperless-ngx is loading..."
msgstr ""
@ -798,131 +919,40 @@ msgstr ""
msgid "Here's a link to the docs."
msgstr ""
#: documents/templates/registration/logged_out.html:14
msgid "Paperless-ngx signed out"
#: documents/templates/socialaccount/authentication_error.html:15
#: documents/templates/socialaccount/login.html:15
msgid "Paperless-ngx social account sign in"
msgstr ""
#: documents/templates/registration/logged_out.html:40
msgid "You have been successfully logged out. Bye!"
msgstr ""
#: documents/templates/registration/logged_out.html:41
msgid "Sign in again"
msgstr ""
#: documents/templates/registration/login.html:14
msgid "Paperless-ngx sign in"
msgstr ""
#: documents/templates/registration/login.html:41
msgid "Please sign in."
msgstr ""
#: documents/templates/registration/login.html:44
msgid "Your username and password didn't match. Please try again."
msgstr ""
#: documents/templates/registration/login.html:48
msgid "Share link was not found."
msgstr ""
#: documents/templates/registration/login.html:52
msgid "Share link has expired."
msgstr ""
#: documents/templates/registration/login.html:55
msgid "Username"
msgstr ""
#: documents/templates/registration/login.html:56
msgid "Password"
msgstr ""
#: documents/templates/registration/login.html:66
msgid "Sign in"
msgstr ""
#: documents/templates/registration/login.html:70
msgid "Forgot your password?"
msgstr ""
#: documents/templates/registration/password_reset_complete.html:14
msgid "Paperless-ngx reset password complete"
msgstr ""
#: documents/templates/registration/password_reset_complete.html:40
msgid "Password reset complete."
msgstr ""
#: documents/templates/registration/password_reset_complete.html:42
#: documents/templates/socialaccount/authentication_error.html:43
#, python-format
msgid ""
"Your new password has been set. You can now <a href=\"%(login_url)s\">log "
"in</a>"
"An error occurred while attempting to login via your social network account. "
"Back to the <a href=\"%(login_url)s\">login page</a>"
msgstr ""
#: documents/templates/registration/password_reset_confirm.html:14
msgid "Paperless-ngx reset password confirmation"
#: documents/templates/socialaccount/login.html:44
#, python-format
msgid "You are about to connect a new third-party account from %(provider)s."
msgstr ""
#: documents/templates/registration/password_reset_confirm.html:42
msgid "Set a new password."
#: documents/templates/socialaccount/login.html:47
msgid "Continue"
msgstr ""
#: documents/templates/registration/password_reset_confirm.html:46
msgid "Passwords did not match or too weak. Try again."
#: documents/templates/socialaccount/signup.html:14
msgid "Paperless-ngx social account sign up"
msgstr ""
#: documents/templates/registration/password_reset_confirm.html:49
msgid "New Password"
msgstr ""
#: documents/templates/registration/password_reset_confirm.html:50
msgid "Confirm Password"
msgstr ""
#: documents/templates/registration/password_reset_confirm.html:61
msgid "Change my password"
msgstr ""
#: documents/templates/registration/password_reset_confirm.html:65
msgid "request a new password reset"
msgstr ""
#: documents/templates/registration/password_reset_done.html:14
msgid "Paperless-ngx reset password sent"
msgstr ""
#: documents/templates/registration/password_reset_done.html:40
msgid "Check your inbox."
msgstr ""
#: documents/templates/registration/password_reset_done.html:41
#: documents/templates/socialaccount/signup.html:53
#, python-format
msgid ""
"We've emailed you instructions for setting your password. You should receive "
"the email shortly!"
"You are about to use your %(provider_name)s account to login to\n"
"%(site_name)s. As a final step, please complete the following form:"
msgstr ""
#: documents/templates/registration/password_reset_form.html:14
msgid "Paperless-ngx reset password request"
msgstr ""
#: documents/templates/registration/password_reset_form.html:41
msgid ""
"Enter your email address below, and we'll email instructions for setting a "
"new one."
msgstr ""
#: documents/templates/registration/password_reset_form.html:44
msgid "An error occurred. Please try again."
msgstr ""
#: documents/templates/registration/password_reset_form.html:47
msgid "Email"
msgstr ""
#: documents/templates/registration/password_reset_form.html:54
msgid "Send me instructions!"
#: documents/templates/socialaccount/signup.html:72
msgid "Sign up"
msgstr ""
#: documents/validators.py:17
@ -1088,135 +1118,135 @@ msgstr ""
msgid "paperless application settings"
msgstr ""
#: paperless/settings.py:617
#: paperless/settings.py:658
msgid "English (US)"
msgstr ""
#: paperless/settings.py:618
#: paperless/settings.py:659
msgid "Arabic"
msgstr ""
#: paperless/settings.py:619
#: paperless/settings.py:660
msgid "Afrikaans"
msgstr ""
#: paperless/settings.py:620
#: paperless/settings.py:661
msgid "Belarusian"
msgstr ""
#: paperless/settings.py:621
#: paperless/settings.py:662
msgid "Bulgarian"
msgstr ""
#: paperless/settings.py:622
#: paperless/settings.py:663
msgid "Catalan"
msgstr ""
#: paperless/settings.py:623
#: paperless/settings.py:664
msgid "Czech"
msgstr ""
#: paperless/settings.py:624
#: paperless/settings.py:665
msgid "Danish"
msgstr ""
#: paperless/settings.py:625
#: paperless/settings.py:666
msgid "German"
msgstr ""
#: paperless/settings.py:626
#: paperless/settings.py:667
msgid "Greek"
msgstr ""
#: paperless/settings.py:627
#: paperless/settings.py:668
msgid "English (GB)"
msgstr ""
#: paperless/settings.py:628
#: paperless/settings.py:669
msgid "Spanish"
msgstr ""
#: paperless/settings.py:629
#: paperless/settings.py:670
msgid "Finnish"
msgstr ""
#: paperless/settings.py:630
#: paperless/settings.py:671
msgid "French"
msgstr ""
#: paperless/settings.py:631
#: paperless/settings.py:672
msgid "Hungarian"
msgstr ""
#: paperless/settings.py:632
#: paperless/settings.py:673
msgid "Italian"
msgstr ""
#: paperless/settings.py:633
#: paperless/settings.py:674
msgid "Japanese"
msgstr ""
#: paperless/settings.py:634
#: paperless/settings.py:675
msgid "Luxembourgish"
msgstr ""
#: paperless/settings.py:635
#: paperless/settings.py:676
msgid "Norwegian"
msgstr ""
#: paperless/settings.py:636
#: paperless/settings.py:677
msgid "Dutch"
msgstr ""
#: paperless/settings.py:637
#: paperless/settings.py:678
msgid "Polish"
msgstr ""
#: paperless/settings.py:638
#: paperless/settings.py:679
msgid "Portuguese (Brazil)"
msgstr ""
#: paperless/settings.py:639
#: paperless/settings.py:680
msgid "Portuguese"
msgstr ""
#: paperless/settings.py:640
#: paperless/settings.py:681
msgid "Romanian"
msgstr ""
#: paperless/settings.py:641
#: paperless/settings.py:682
msgid "Russian"
msgstr ""
#: paperless/settings.py:642
#: paperless/settings.py:683
msgid "Slovak"
msgstr ""
#: paperless/settings.py:643
#: paperless/settings.py:684
msgid "Slovenian"
msgstr ""
#: paperless/settings.py:644
#: paperless/settings.py:685
msgid "Serbian"
msgstr ""
#: paperless/settings.py:645
#: paperless/settings.py:686
msgid "Swedish"
msgstr ""
#: paperless/settings.py:646
#: paperless/settings.py:687
msgid "Turkish"
msgstr ""
#: paperless/settings.py:647
#: paperless/settings.py:688
msgid "Ukrainian"
msgstr ""
#: paperless/settings.py:648
#: paperless/settings.py:689
msgid "Chinese Simplified"
msgstr ""
#: paperless/urls.py:214
#: paperless/urls.py:224
msgid "Paperless-ngx administration"
msgstr ""

30
src/paperless/adapter.py Normal file
View File

@ -0,0 +1,30 @@
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.conf import settings
from django.urls import reverse
class CustomAccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request):
allow_signups = super().is_open_for_signup(request)
# Override with setting, otherwise default to super.
return getattr(settings, "ACCOUNT_ALLOW_SIGNUPS", allow_signups)
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request, sociallogin):
allow_signups = super().is_open_for_signup(request, sociallogin)
# Override with setting, otherwise default to super.
return getattr(settings, "SOCIALACCOUNT_ALLOW_SIGNUPS", allow_signups)
def get_connect_redirect_url(self, request, socialaccount):
"""
Returns the default URL to redirect to after successfully
connecting a social account.
"""
url = reverse("base")
return url
def populate_user(self, request, sociallogin, data):
# TODO: If default global permissions are implemented, should also be here
return super().populate_user(request, sociallogin, data) # pragma: no cover

View File

@ -1,5 +1,6 @@
import logging
from allauth.socialaccount.models import SocialAccount
from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
@ -105,10 +106,30 @@ class GroupSerializer(serializers.ModelSerializer):
)
class SocialAccountSerializer(serializers.ModelSerializer):
name = serializers.SerializerMethodField()
class Meta:
model = SocialAccount
fields = (
"id",
"provider",
"name",
)
def get_name(self, obj):
return obj.get_provider_account().to_str()
class ProfileSerializer(serializers.ModelSerializer):
email = serializers.EmailField(allow_null=False)
password = ObfuscatedUserPasswordField(required=False, allow_null=False)
auth_token = serializers.SlugRelatedField(read_only=True, slug_field="key")
social_accounts = SocialAccountSerializer(
many=True,
read_only=True,
source="socialaccount_set",
)
class Meta:
model = User
@ -118,6 +139,8 @@ class ProfileSerializer(serializers.ModelSerializer):
"first_name",
"last_name",
"auth_token",
"social_accounts",
"has_usable_password",
)

View File

@ -303,6 +303,9 @@ INSTALLED_APPS = [
"django_filters",
"django_celery_results",
"guardian",
"allauth",
"allauth.account",
"allauth.socialaccount",
*env_apps,
]
@ -339,6 +342,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
# Optional to enable compression
@ -350,6 +354,7 @@ ROOT_URLCONF = "paperless.urls"
FORCE_SCRIPT_NAME = os.getenv("PAPERLESS_FORCE_SCRIPT_NAME")
BASE_URL = (FORCE_SCRIPT_NAME or "") + "/"
LOGIN_URL = BASE_URL + "accounts/login/"
LOGIN_REDIRECT_URL = "/dashboard"
LOGOUT_REDIRECT_URL = os.getenv("PAPERLESS_LOGOUT_REDIRECT_URL")
WSGI_APPLICATION = "paperless.wsgi.application"
@ -410,8 +415,28 @@ CHANNEL_LAYERS = {
AUTHENTICATION_BACKENDS = [
"guardian.backends.ObjectPermissionBackend",
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
]
ACCOUNT_LOGOUT_ON_GET = True
ACCOUNT_DEFAULT_HTTP_PROTOCOL = os.getenv(
"PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL",
"https",
)
ACCOUNT_ADAPTER = "paperless.adapter.CustomAccountAdapter"
ACCOUNT_ALLOW_SIGNUPS = __get_boolean("PAPERLESS_ACCOUNT_ALLOW_SIGNUPS")
SOCIALACCOUNT_ADAPTER = "paperless.adapter.CustomSocialAccountAdapter"
SOCIALACCOUNT_ALLOW_SIGNUPS = __get_boolean(
"PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS",
"yes",
)
SOCIALACCOUNT_AUTO_SIGNUP = __get_boolean("PAPERLESS_SOCIAL_AUTO_SIGNUP")
SOCIALACCOUNT_PROVIDERS = json.loads(
os.getenv("PAPERLESS_SOCIALACCOUNT_PROVIDERS", "{}"),
)
AUTO_LOGIN_USERNAME = os.getenv("PAPERLESS_AUTO_LOGIN_USERNAME")
if AUTO_LOGIN_USERNAME:

View File

@ -0,0 +1,43 @@
from allauth.account.adapter import get_adapter
from allauth.socialaccount.adapter import get_adapter as get_social_adapter
from django.conf import settings
from django.test import TestCase
from django.urls import reverse
class TestCustomAccountAdapter(TestCase):
def test_is_open_for_signup(self):
adapter = get_adapter()
# Test when ACCOUNT_ALLOW_SIGNUPS is True
settings.ACCOUNT_ALLOW_SIGNUPS = True
self.assertTrue(adapter.is_open_for_signup(None))
# Test when ACCOUNT_ALLOW_SIGNUPS is False
settings.ACCOUNT_ALLOW_SIGNUPS = False
self.assertFalse(adapter.is_open_for_signup(None))
class TestCustomSocialAccountAdapter(TestCase):
def test_is_open_for_signup(self):
adapter = get_social_adapter()
# Test when SOCIALACCOUNT_ALLOW_SIGNUPS is True
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = True
self.assertTrue(adapter.is_open_for_signup(None, None))
# Test when SOCIALACCOUNT_ALLOW_SIGNUPS is False
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = False
self.assertFalse(adapter.is_open_for_signup(None, None))
def test_get_connect_redirect_url(self):
adapter = get_social_adapter()
request = None
socialaccount = None
# Test the default URL
expected_url = reverse("base")
self.assertEqual(
adapter.get_connect_redirect_url(request, socialaccount),
expected_url,
)

View File

@ -41,10 +41,12 @@ from documents.views import WorkflowTriggerViewSet
from documents.views import WorkflowViewSet
from paperless.consumers import StatusConsumer
from paperless.views import ApplicationConfigurationViewSet
from paperless.views import DisconnectSocialAccountView
from paperless.views import FaviconView
from paperless.views import GenerateAuthTokenView
from paperless.views import GroupViewSet
from paperless.views import ProfileView
from paperless.views import SocialAccountProvidersView
from paperless.views import UserViewSet
from paperless_mail.views import MailAccountTestView
from paperless_mail.views import MailAccountViewSet
@ -132,6 +134,14 @@ urlpatterns = [
name="bulk_edit_object_permissions",
),
path("profile/generate_auth_token/", GenerateAuthTokenView.as_view()),
path(
"profile/disconnect_social_account/",
DisconnectSocialAccountView.as_view(),
),
path(
"profile/social_account_providers/",
SocialAccountProvidersView.as_view(),
),
re_path(
"^profile/",
ProfileView.as_view(),
@ -192,7 +202,7 @@ urlpatterns = [
),
# TODO: with localization, this is even worse! :/
# login, logout
path("accounts/", include("django.contrib.auth.urls")),
path("accounts/", include("allauth.urls")),
# Root of the Frontend
re_path(
r".*",

View File

@ -1,10 +1,13 @@
import os
from collections import OrderedDict
from allauth.socialaccount.adapter import get_adapter
from allauth.socialaccount.models import SocialAccount
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.db.models.functions import Lower
from django.http import HttpResponse
from django.http import HttpResponseBadRequest
from django.views.generic import View
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.authtoken.models import Token
@ -14,6 +17,7 @@ from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import DjangoObjectPermissions
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from documents.permissions import PaperlessObjectPermissions
@ -168,3 +172,54 @@ class ApplicationConfigurationViewSet(ModelViewSet):
serializer_class = ApplicationConfigurationSerializer
permission_classes = (IsAuthenticated, DjangoObjectPermissions)
class DisconnectSocialAccountView(GenericAPIView):
"""
Disconnects a social account provider from the user account
"""
permission_classes = [IsAuthenticated]
def post(self, request, *args, **kwargs):
user = self.request.user
try:
account = user.socialaccount_set.get(pk=request.data["id"])
account_id = account.id
account.delete()
return Response(account_id)
except SocialAccount.DoesNotExist:
return HttpResponseBadRequest("Social account not found")
class SocialAccountProvidersView(APIView):
"""
List of social account providers
"""
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs):
adapter = get_adapter()
providers = adapter.list_providers(request)
resp = [
{"name": p.name, "login_url": p.get_login_url(request, process="connect")}
for p in providers
if p.id != "openid"
]
for openid_provider in filter(lambda p: p.id == "openid", providers):
resp += [
{
"name": b["name"],
"login_url": openid_provider.get_login_url(
request,
process="connect",
openid=b["openid_url"],
),
}
for b in openid_provider.get_brands()
]
return Response(sorted(resp, key=lambda p: p["name"]))