# 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 = "*"
"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",
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
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/)
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}
</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>
</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">
</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">
<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">
</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>
<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">
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'
let permissionsService: PermissionsService
let remoteVersionService: RemoteVersionService
let toastService: ToastService
+ let messagesService: DjangoMessagesService
let openDocumentsService: OpenDocumentsService
let searchService: SearchService
let documentListViewService: DocumentListViewService
RemoteVersionService,
IfPermissionsDirective,
ToastService,
+ DjangoMessagesService,
OpenDocumentsService,
SearchService,
NgbModal,
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)
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)
+ })
})
} 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'
public tasksService: TasksService,
private readonly toastService: ToastService,
private modalService: NgbModal,
- permissionsService: PermissionsService
+ public permissionsService: PermissionsService,
+ private djangoMessagesService: DjangoMessagesService
) {
super()
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 {
</div>
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
</div>
+ @if (socialAccounts?.length > 0) {
+ <div class="mb-3">
+ <p i18n>Connected social accounts</p>
+ <ul class="list-group">
+ @for (account of socialAccounts; track account.id) {
+ <li class="list-group-item"
+ ngbPopover="Set a password before disconnecting social account."
+ i18n-ngbPopover
+ [disablePopover]="hasUsablePassword"
+ triggers="mouseenter:mouseleave">
+ {{account.name}} ({{account.provider}})
+ <button
+ type="button"
+ class="btn btn-outline-danger btn-sm ms-2 align-baseline"
+ [disabled]="!hasUsablePassword && socialAccounts.length === 1"
+ (click)="disconnectSocialAccount(account.id)"
+ i18n-title title="Disconnect {{ account.name }} social account">
+ <ng-container i18n>Disconnect</ng-container> <i-bs name="trash"></i-bs>
+ </button>
+ </li>
+ }
+ </ul>
+ <div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
+ </div>
+ }
+ @if (socialAccountProviders?.length > 0) {
+ <div class="mb-3">
+ <p i18n>Connect new social account</p>
+ <div class="list-group">
+ @for (provider of socialAccountProviders; track provider.name) {
+ <a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
+ {{provider.name}} <i-bs class="pb-1 ps-1" name="box-arrow-up-right"></i-bs>
+ </a>
+ }
+ </div>
+ </div>
+ }
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
NgbAccordionModule,
NgbActiveModal,
NgbModalModule,
+ NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap'
import { HttpClientModule } from '@angular/common/http'
import { TextComponent } from '../input/text/text.component'
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
NgbModalModule,
NgbAccordionModule,
NgxBootstrapIconsModule.pick(allIcons),
+ NgbPopoverModule,
],
})
profileService = TestBed.inject(ProfileService)
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()
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)
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({
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')
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()
)
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)
+ })
})
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'
private newPassword: string
private passwordConfirm: string
public showPasswordConfirm: boolean = false
+ public hasUsablePassword: boolean = false
private currentEmail: string
private newEmail: string
public copied: boolean = false
+ public socialAccounts: SocialAccount[] = []
+ public socialAccountProviders: SocialAccountProvider[] = []
+
constructor(
private profileService: ProfileService,
public activeModal: NgbActiveModal,
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
})
}
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
+ )
+ },
+ })
+ }
}
+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
}
--- /dev/null
+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([])
+ })
+})
--- /dev/null
+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'] ?? []
+ }
+}
)
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')
+ })
})
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({
{}
)
}
+
+ 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/`
+ )
+ }
}
</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"/>
<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">
{% 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">
</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>
{% load static %}
{% load i18n %}
+{% load allauth %}
<html lang="en">
<head>
</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"/>
{% 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">
{% load static %}
{% load i18n %}
+{% load allauth %}
<html lang="en">
<head>
<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">
{% 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>
</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>
<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>
{% 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">
<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>
--- /dev/null
+<!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>
--- /dev/null
+<!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>
+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
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/"
)
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:
THEN:
- Profile is returned
"""
-
response = self.client.get(self.ENDPOINT)
self.assertEqual(response.status_code, status.HTTP_200_OK)
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:
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,
+ )
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(
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)
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(
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()
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)
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"
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/index.html:79
-msgid "Paperless-ngx is loading..."
+#: documents/templates/account/login.html:14
+msgid "Paperless-ngx sign in"
msgstr ""
-#: documents/templates/index.html:80
-msgid "Still here?! Hmm, something might be wrong."
+#: documents/templates/account/login.html:47
+msgid "Please sign in."
msgstr ""
-#: documents/templates/index.html:80
-msgid "Here's a link to the docs."
+#: documents/templates/account/login.html:50
+msgid "Your username and password didn't match. Please try again."
msgstr ""
-#: documents/templates/registration/logged_out.html:14
-msgid "Paperless-ngx signed out"
+#: documents/templates/account/login.html:54
+msgid "Share link was not found."
msgstr ""
-#: documents/templates/registration/logged_out.html:40
-msgid "You have been successfully logged out. Bye!"
+#: documents/templates/account/login.html:58
+msgid "Share link has expired."
msgstr ""
-#: documents/templates/registration/logged_out.html:41
-msgid "Sign in again"
+#: documents/templates/account/login.html:61
+#: documents/templates/socialaccount/signup.html:56
+msgid "Username"
msgstr ""
-#: documents/templates/registration/login.html:14
-msgid "Paperless-ngx sign in"
+#: documents/templates/account/login.html:62
+msgid "Password"
msgstr ""
-#: documents/templates/registration/login.html:41
-msgid "Please sign in."
+#: documents/templates/account/login.html:72
+msgid "Sign in"
msgstr ""
-#: documents/templates/registration/login.html:44
-msgid "Your username and password didn't match. Please try again."
+#: documents/templates/account/login.html:76
+msgid "Forgot your password?"
msgstr ""
-#: documents/templates/registration/login.html:48
-msgid "Share link was not found."
+#: documents/templates/account/login.html:83
+msgid "or sign in via"
msgstr ""
-#: documents/templates/registration/login.html:52
-msgid "Share link has expired."
+#: documents/templates/account/password_reset.html:15
+msgid "Paperless-ngx reset password request"
msgstr ""
-#: documents/templates/registration/login.html:55
-msgid "Username"
+#: 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/registration/login.html:56
-msgid "Password"
+#: documents/templates/account/password_reset.html:46
+msgid "An error occurred. Please try again."
msgstr ""
-#: documents/templates/registration/login.html:66
-msgid "Sign in"
+#: documents/templates/account/password_reset.html:49
+#: documents/templates/socialaccount/signup.html:57
+msgid "Email"
msgstr ""
-#: documents/templates/registration/login.html:70
-msgid "Forgot your password?"
+#: documents/templates/account/password_reset.html:56
+msgid "Send me instructions!"
msgstr ""
-#: documents/templates/registration/password_reset_complete.html:14
-msgid "Paperless-ngx reset password complete"
+#: documents/templates/account/password_reset_done.html:14
+msgid "Paperless-ngx reset password sent"
msgstr ""
-#: documents/templates/registration/password_reset_complete.html:40
-msgid "Password reset complete."
+#: documents/templates/account/password_reset_done.html:40
+msgid "Check your inbox."
msgstr ""
-#: documents/templates/registration/password_reset_complete.html:42
-#, python-format
+#: documents/templates/account/password_reset_done.html:41
msgid ""
-"Your new password has been set. You can now <a href=\"%(login_url)s\">log "
-"in</a>"
+"We've emailed you instructions for setting your password. You should receive "
+"the email shortly!"
msgstr ""
-#: documents/templates/registration/password_reset_confirm.html:14
+#: documents/templates/account/password_reset_from_key.html:15
msgid "Paperless-ngx reset password confirmation"
msgstr ""
-#: documents/templates/registration/password_reset_confirm.html:42
+#: 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/registration/password_reset_confirm.html:46
+#: documents/templates/account/password_reset_from_key.html:50
msgid "Passwords did not match or too weak. Try again."
msgstr ""
-#: documents/templates/registration/password_reset_confirm.html:49
+#: documents/templates/account/password_reset_from_key.html:53
msgid "New Password"
msgstr ""
-#: documents/templates/registration/password_reset_confirm.html:50
+#: documents/templates/account/password_reset_from_key.html:54
msgid "Confirm Password"
msgstr ""
-#: documents/templates/registration/password_reset_confirm.html:61
+#: documents/templates/account/password_reset_from_key.html:65
msgid "Change my password"
msgstr ""
-#: documents/templates/registration/password_reset_confirm.html:65
-msgid "request a new password reset"
+#: documents/templates/account/password_reset_from_key_done.html:14
+msgid "Paperless-ngx reset password complete"
msgstr ""
-#: documents/templates/registration/password_reset_done.html:14
-msgid "Paperless-ngx reset password sent"
+#: documents/templates/account/password_reset_from_key_done.html:40
+msgid "Password reset complete."
msgstr ""
-#: documents/templates/registration/password_reset_done.html:40
-msgid "Check your inbox."
+#: 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/registration/password_reset_done.html:41
-msgid ""
-"We've emailed you instructions for setting your password. You should receive "
-"the email shortly!"
+#: documents/templates/index.html:79
+msgid "Paperless-ngx is loading..."
msgstr ""
-#: documents/templates/registration/password_reset_form.html:14
-msgid "Paperless-ngx reset password request"
+#: documents/templates/index.html:80
+msgid "Still here?! Hmm, something might be wrong."
msgstr ""
-#: documents/templates/registration/password_reset_form.html:41
+#: documents/templates/index.html:80
+msgid "Here's a link to the docs."
+msgstr ""
+
+#: documents/templates/socialaccount/authentication_error.html:15
+#: documents/templates/socialaccount/login.html:15
+msgid "Paperless-ngx social account sign in"
+msgstr ""
+
+#: documents/templates/socialaccount/authentication_error.html:43
+#, python-format
msgid ""
-"Enter your email address below, and we'll email instructions for setting a "
-"new one."
+"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_form.html:44
-msgid "An error occurred. Please try again."
+#: 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_form.html:47
-msgid "Email"
+#: documents/templates/socialaccount/login.html:47
+msgid "Continue"
msgstr ""
-#: documents/templates/registration/password_reset_form.html:54
-msgid "Send me instructions!"
+#: documents/templates/socialaccount/signup.html:14
+msgid "Paperless-ngx social account sign up"
+msgstr ""
+
+#: documents/templates/socialaccount/signup.html:53
+#, python-format
+msgid ""
+"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/socialaccount/signup.html:72
+msgid "Sign up"
msgstr ""
#: documents/validators.py:17
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 ""
--- /dev/null
+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
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
)
+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
"first_name",
"last_name",
"auth_token",
+ "social_accounts",
+ "has_usable_password",
)
"django_filters",
"django_celery_results",
"guardian",
+ "allauth",
+ "allauth.account",
+ "allauth.socialaccount",
*env_apps,
]
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
+ "allauth.account.middleware.AccountMiddleware",
]
# Optional to enable compression
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"
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:
--- /dev/null
+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,
+ )
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
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(),
),
# 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".*",
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
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
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"]))