]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: OIDC & social authentication (#5190)
authorMoritz Pflanzer <moritz@pflanzer.eu>
Thu, 8 Feb 2024 16:15:38 +0000 (17:15 +0100)
committerGitHub <noreply@github.com>
Thu, 8 Feb 2024 16:15:38 +0000 (16:15 +0000)
---------

Co-authored-by: Moritz Pflanzer <moritz@chickadee-engineering.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
33 files changed:
Pipfile
Pipfile.lock
docs/advanced_usage.md
docs/configuration.md
src-ui/messages.xlf
src-ui/src/app/components/app-frame/app-frame.component.spec.ts
src-ui/src/app/components/app-frame/app-frame.component.ts
src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html
src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.spec.ts
src-ui/src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts
src-ui/src/app/data/user-profile.ts
src-ui/src/app/services/django-messages.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/django-messages.service.ts [new file with mode: 0644]
src-ui/src/app/services/profile.service.spec.ts
src-ui/src/app/services/profile.service.ts
src/documents/templates/account/login.html [moved from src/documents/templates/registration/login.html with 83% similarity]
src/documents/templates/account/password_reset.html [moved from src/documents/templates/registration/password_reset_form.html with 96% similarity]
src/documents/templates/account/password_reset_done.html [moved from src/documents/templates/registration/password_reset_done.html with 100% similarity]
src/documents/templates/account/password_reset_from_key.html [moved from src/documents/templates/registration/password_reset_confirm.html with 92% similarity]
src/documents/templates/account/password_reset_from_key_done.html [moved from src/documents/templates/registration/password_reset_complete.html with 99% similarity]
src/documents/templates/index.html
src/documents/templates/socialaccount/authentication_error.html [moved from src/documents/templates/registration/logged_out.html with 93% similarity]
src/documents/templates/socialaccount/login.html [new file with mode: 0644]
src/documents/templates/socialaccount/signup.html [new file with mode: 0644]
src/documents/tests/test_api_profile.py
src/documents/tests/test_management_exporter.py
src/locale/en_US/LC_MESSAGES/django.po
src/paperless/adapter.py [new file with mode: 0644]
src/paperless/serialisers.py
src/paperless/settings.py
src/paperless/tests/test_adapter.py [new file with mode: 0644]
src/paperless/urls.py
src/paperless/views.py

diff --git a/Pipfile b/Pipfile
index afc294412f8768ce596153d5ac3dd8643f4d9f73..6d5c7f8610bd984280713ba33392535b7723609e 100644 (file)
--- a/Pipfile
+++ b/Pipfile
@@ -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 = "*"
index ceef1c8a96b9c54a7f3c74b4577902b58e1b8c30..d177517c2d708796cb2378eef5304b9f7591e5f7 100644 (file)
             "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",
index 04626fe411df17241e39574e84226489ae74de4c..46d9c2b4b85355c402181a536e2722c0a970564e 100644 (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
index e99e0a0857c3d35840399ceee6ccd2b780e42cb9..3d1b1d1d12ff13e9df2155429f1edf87e29bebeb 100644 (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}
index 9f163e3b8165a3b41b0a689595cdd1de1b74270a..f2b356d9af0d42ef620f52ccf33d3794f11f0a58 100644 (file)
         </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">
index 64877bb09fa6e4714fdacea732264123e6f0783b..e1a553047ea962fbb50cf1463d16c50c4c12c32e 100644 (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)
+  })
 })
index cfc9740a420d98afa2ed6b8e7e6b9cff68b9916f..ab9322380556a0246456efbdeec7f51a6ca3535f 100644 (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 {
index 394ba44494dbaf190bda77acbd8d02bf36eca5b4..6b06dfa8ebc8d4af8a6787ec095fcf790570c24f 100644 (file)
         </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>
index 5deb26c8d0695f3f6d0b44292cc2815f3482e758..36888d4bd3a0735f4c34ad817f0f3a0e9279671d 100644 (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)
+  })
 })
index d89d49829aa384fdd826988dfe9188103a028542..77cba45057574d148035afc2ef72fd7a14afc233 100644 (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
+          )
+        },
+      })
+  }
 }
index 94012925a3bc143e557553e323439129cc58cb39..554f6f0e1123c5a2c17d77342cd008081e2f9cf5 100644 (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
 }
diff --git a/src-ui/src/app/services/django-messages.service.spec.ts b/src-ui/src/app/services/django-messages.service.spec.ts
new file mode 100644 (file)
index 0000000..5e95f32
--- /dev/null
@@ -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([])
+  })
+})
diff --git a/src-ui/src/app/services/django-messages.service.ts b/src-ui/src/app/services/django-messages.service.ts
new file mode 100644 (file)
index 0000000..fddbe88
--- /dev/null
@@ -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'] ?? []
+  }
+}
index f19a1312e0d24e7e3b0aa829221bd8e047a6e688..538911ac3a74720948d784b7fe3ceabe8bdc3659 100644 (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')
+  })
 })
index de5aeb7a4d08b17c29e236b605ef5dee35f7dbd5..32e06cce0e34a60f3f52fcbce4e6a89badace7ed 100644 (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/`
+    )
+  }
 }
similarity index 83%
rename from src/documents/templates/registration/login.html
rename to src/documents/templates/account/login.html
index ba57013ace7c0323e13d1de6516da9133cb6f9e2..777f65409cb6cb5fa5d8f8413d67ed5991e82a6a 100644 (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"/>
           <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">
       </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>
similarity index 96%
rename from src/documents/templates/registration/password_reset_form.html
rename to src/documents/templates/account/password_reset.html
index 9bdc229433b591883c21c08044464a76ac9f15ac..1b5de5aeb28b7501bff063f2102df1fa04daeae4 100644 (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">
similarity index 92%
rename from src/documents/templates/registration/password_reset_confirm.html
rename to src/documents/templates/account/password_reset_from_key.html
index 8f24212a7e736027c0d6c4f81392e458992061af..11b40cd22793149d14bc62ccb26ad1c18cfb047a 100644 (file)
@@ -2,6 +2,7 @@
 
 {% 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>
similarity index 99%
rename from src/documents/templates/registration/password_reset_complete.html
rename to src/documents/templates/account/password_reset_from_key_done.html
index d9b0a3b72172204e986d4dc7ff0e2fe4da2647e3..5a8370a1b2ad538445124700114e3183b767ac40 100644 (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>
index c53d1d07717de63f784b2d4fc8a3768d8dc539bb..adeeaf0d47b864b1495e49f6e7aa1c1bce43d98a 100644 (file)
                                <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>
similarity index 93%
rename from src/documents/templates/registration/logged_out.html
rename to src/documents/templates/socialaccount/authentication_error.html
index 7d7491e3fa6df3d65247ce264307ca809ce98827..1bd3d72e904e41e4ed33d8bc98a2eba7f6c21cfc 100644 (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>
diff --git a/src/documents/templates/socialaccount/login.html b/src/documents/templates/socialaccount/login.html
new file mode 100644 (file)
index 0000000..f135a77
--- /dev/null
@@ -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>
diff --git a/src/documents/templates/socialaccount/signup.html b/src/documents/templates/socialaccount/signup.html
new file mode 100644 (file)
index 0000000..ef208d8
--- /dev/null
@@ -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>
index 9e12b1ed394e4378307b9e65a58e057eaee480da..eede0d2b0f6e745e598510ab4f65cfb6fa05403e 100644 (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,
+        )
index 888572b58d2ed71520e3b1f762026b0ed06d3050..226b89694e8cd87d798061777876fa3f6eeb644b 100644 (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)
index 0c72424625bc7aca9aa307d54dc05037674c3e64..7ee0c0d8bec5188a34c098990dc1fdda90ea703f 100644 (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,152 +777,182 @@ 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/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
@@ -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 ""
 
diff --git a/src/paperless/adapter.py b/src/paperless/adapter.py
new file mode 100644 (file)
index 0000000..98b0f11
--- /dev/null
@@ -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
index b724dd451ca60b916d43d7ae7a01bd923682a714..4c9ed564114601cf2d029ab64926feeebb73be85 100644 (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",
         )
 
 
index d485415caf2bb0bf9ddcbac88e622283546c2d3a..d51ba9020c674f074ff68ecb761d4263653c102d 100644 (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:
diff --git a/src/paperless/tests/test_adapter.py b/src/paperless/tests/test_adapter.py
new file mode 100644 (file)
index 0000000..ca79cbc
--- /dev/null
@@ -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,
+        )
index d45a7bf22892f55f80899d94a2403a6bc515ffc7..74f6fc1086eb0e0e97cb3bff8e6e89646767eadd 100644 (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".*",
index 0f417b9abd13e1be2db8cc358e540570b137a33b..1151ceed5e1b057e9a5c35a9822cbf5a5f5f515d 100644 (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"]))