Compare commits

...
Author SHA1 Message Date
shamoon ca7c31878e DRY 2026-08-25 21:24:54 -07:00
shamoon c11190bb4d watermark thing 2026-08-25 21:15:37 -07:00
shamoon 57d04f23cd Hmm, shadow, gradient? 2026-08-25 19:49:16 -07:00
shamoon 595a3ffa03 A little welcome widget love 2026-08-25 19:43:46 -07:00
GitHub Actions 35d40b51cb Auto translate strings 2026-08-25 21:37:07 +00:00
shamoonandGitHub 78025df405 Performance: fetch note authors with prefetch instead of one query each (#13790) 2026-08-25 14:35:22 -07:00
shamoon e24db7023a Tweak: adjust brand leaf opacity 2026-08-25 10:44:49 -07:00
shamoon f5ddc14588 Fix: also check global change_mailaccount with test 2026-08-25 10:44:49 -07:00
13 changed files with 150 additions and 30 deletions
@@ -10,16 +10,12 @@
tourAnchor="tour.intro">
@if (!hasCustomBranding) {
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
<svg class="brand-mark brand-mark-slim d-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
</svg>
<svg pngx-brand-mark class="brand-mark brand-mark-slim d-none" width="1.5em" height="1.5em"></svg>
} @else {
@if (customAppLogo) {
<img class="brand-logo" [src]="customAppLogo" alt="" />
} @else {
<svg class="brand-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
</svg>
<svg pngx-brand-mark class="brand-mark" width="1.5em" height="1.5em"></svg>
}
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
<span class="brand-title text-truncate">{{ appTitle }}</span>
@@ -417,7 +417,7 @@ main {
:host ::ng-deep .navbar-official-logo {
.leaf {
fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 70%, var(--bs-primary)) !important;
fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 85%, var(--bs-primary)) !important;
}
.text {
@@ -45,6 +45,7 @@ import { TasksService } from 'src/app/services/tasks.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { ChatComponent } from '../chat/chat/chat.component'
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
import { LogoComponent } from '../common/logo/logo.component'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
@@ -61,6 +62,7 @@ const SCROLL_THRESHOLD = 16
imports: [
GlobalSearchComponent,
LogoComponent,
BrandMarkComponent,
DocumentTitlePipe,
IfPermissionsDirective,
ToastsDropdownComponent,
@@ -0,0 +1 @@
<svg:path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>

After

Width:  |  Height:  |  Size: 408 B

@@ -0,0 +1,16 @@
import { Component } from '@angular/core'
/**
* The paperless-ngx leaf mark, applied as an attribute on an <svg>
*/
@Component({
selector: 'svg[pngx-brand-mark]',
templateUrl: './brand-mark.component.html',
host: {
'[attr.xmlns]': "'http://www.w3.org/2000/svg'",
'[attr.viewBox]': "'0 0 1000 1000'",
'[attr.fill]': "'currentColor'",
'[attr.aria-hidden]': "'true'",
},
})
export class BrandMarkComponent {}
@@ -1,10 +1,18 @@
<ngb-alert class="pe-3" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
<h4 class="alert-heading"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
<p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p>
<p i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p>
<hr>
<div class="d-flex align-items-end">
<p class="lead fs-6 m-0"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p>
<button class="btn btn-primary ms-auto flex-shrink-0" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> &rarr;</button>
<ngb-alert class="welcome-widget bg-light shadow-sm pe-3 position-relative overflow-hidden" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
<svg pngx-brand-mark class="welcome-watermark d-none d-md-block position-absolute top-50 translate-middle-y text-primary pe-none" width="26rem" height="26rem"></svg>
<div class="d-flex align-items-start gap-3 gap-md-4 position-relative">
<svg pngx-brand-mark class="d-none d-sm-block flex-shrink-0 text-primary" width="3rem" height="3rem"></svg>
<div class="flex-grow-1">
<h4 class="alert-heading text-primary fw-semibold"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
<div class="welcome-copy text-dark">
<p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p>
<p class="mb-0" i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p>
</div>
<hr class="opacity-100">
<div class="d-flex flex-column flex-sm-row align-items-sm-center gap-3">
<p class="m-0 text-secondary"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p>
<button class="btn btn-primary ms-sm-auto flex-shrink-0 align-self-start align-self-sm-auto" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> &rarr;</button>
</div>
</div>
</div>
</ngb-alert>
@@ -0,0 +1,16 @@
.welcome-widget {
border-color: color-mix(in srgb, var(--bs-primary) 50%, transparent);
hr {
border-top-color: color-mix(in srgb, var(--bs-primary) 25%, transparent);
}
}
.welcome-copy {
max-width: 68ch;
}
.welcome-watermark {
right: -6rem;
opacity: .07;
}
@@ -1,12 +1,13 @@
import { Component, EventEmitter, Output, inject } from '@angular/core'
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
import { BrandMarkComponent } from '../../../common/logo/brand-mark/brand-mark.component'
@Component({
selector: 'pngx-welcome-widget',
templateUrl: './welcome-widget.component.html',
styleUrls: ['./welcome-widget.component.scss'],
imports: [NgbAlertModule],
imports: [NgbAlertModule, BrandMarkComponent],
})
export class WelcomeWidgetComponent {
readonly tourService = inject(TourService)
+2 -1
View File
@@ -1133,7 +1133,8 @@ class DocumentViewSet(
"custom_fields",
queryset=CustomFieldInstance.objects.select_related("field"),
),
"notes",
# NotesSerializer nests the author, this avoids query per note
Prefetch("notes", queryset=Note.objects.select_related("user")),
)
)
+11 -11
View File
@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-24 21:43+0000\n"
"POT-Creation-Date: 2026-08-25 21:36+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -1580,7 +1580,7 @@ msgid "workflow runs"
msgstr ""
#: documents/serialisers.py:523 documents/serialisers.py:875
#: documents/serialisers.py:2827 documents/views.py:311 documents/views.py:2612
#: documents/serialisers.py:2827 documents/views.py:311 documents/views.py:2613
#: paperless_mail/serialisers.py:155
msgid "Insufficient permissions."
msgstr ""
@@ -1621,7 +1621,7 @@ msgstr ""
msgid "Duplicate document identifiers are not allowed."
msgstr ""
#: documents/serialisers.py:2913 documents/views.py:4606
#: documents/serialisers.py:2913 documents/views.py:4607
#, python-format
msgid "Documents not found: %(ids)s"
msgstr ""
@@ -1889,36 +1889,36 @@ msgstr ""
msgid "Unable to parse URI {value}"
msgstr ""
#: documents/views.py:304 documents/views.py:2609
#: documents/views.py:304 documents/views.py:2610
msgid "Invalid more_like_id"
msgstr ""
#: documents/views.py:1586
#: documents/views.py:1587
msgid "Invalid AI configuration."
msgstr ""
#: documents/views.py:1597
#: documents/views.py:1598
msgid "AI backend request timed out."
msgstr ""
#: documents/views.py:2434 documents/views.py:2755
#: documents/views.py:2435 documents/views.py:2756
msgid "Specify only one of text, title_search, query, or more_like_id."
msgstr ""
#: documents/views.py:4619
#: documents/views.py:4620
#, python-format
msgid "Insufficient permissions to share document %(id)s."
msgstr ""
#: documents/views.py:4665
#: documents/views.py:4666
msgid "Bundle is already being processed."
msgstr ""
#: documents/views.py:4726
#: documents/views.py:4727
msgid "The share link bundle is still being prepared. Please try again later."
msgstr ""
#: documents/views.py:4736
#: documents/views.py:4737
msgid "The share link bundle is unavailable."
msgstr ""
+75
View File
@@ -253,6 +253,81 @@ class TestAPIMailAccounts(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["success"], True)
def test_mail_account_test_existing_no_global_perms(self) -> None:
"""
GIVEN:
- Existing account without an owner
- User without any mail account permissions
WHEN:
- API call is made to test the account by id
THEN:
- API returns forbidden
"""
account = MailAccountFactory(
username="admin",
password="secret",
imap_server="server.example.com",
imap_port=443,
owner=None,
)
user = User.objects.create_user(username="no_perms")
self.client.force_authenticate(user=user)
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"id": account.pk,
"imap_server": "server.example.com",
"imap_port": 443,
"imap_security": MailAccount.ImapSecurity.SSL,
"username": "admin",
"password": "******",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(response.content.decode(), "Insufficient permissions")
def test_mail_account_test_existing_object_perms_only(self) -> None:
"""
GIVEN:
- Existing account owned by another user
- User with an object level grant but no global change permission
WHEN:
- API call is made to test the account by id
THEN:
- API returns forbidden
"""
owner = User.objects.create_user(username="account_owner")
account = MailAccountFactory(
username="admin",
password="secret",
imap_server="server.example.com",
imap_port=443,
owner=owner,
)
user = User.objects.create_user(username="object_perms_only")
assign_perm("change_mailaccount", user, account)
self.client.force_authenticate(user=user)
response = self.client.post(
f"{self.ENDPOINT}test/",
json.dumps(
{
"id": account.pk,
"imap_server": "server.example.com",
"imap_port": 443,
"imap_security": MailAccount.ImapSecurity.SSL,
"username": "admin",
"password": "******",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_mail_account_test_existing_nonexistent_id_forbidden(self) -> None:
response = self.client.post(
f"{self.ENDPOINT}test/",
+3 -1
View File
@@ -2195,7 +2195,9 @@ class TestMailAccountTestView(APITestCase):
password="testpassword",
)
self.user.user_permissions.add(
*Permission.objects.filter(codename__in=["add_mailaccount"]),
*Permission.objects.filter(
codename__in=["add_mailaccount", "change_mailaccount"],
),
)
self.user.save()
self.client.force_authenticate(user=self.user)
+3 -1
View File
@@ -106,7 +106,9 @@ class MailAccountViewSet(PassUserMixin, ModelViewSet[MailAccount]):
except (TypeError, ValueError, MailAccount.DoesNotExist):
return HttpResponseForbidden("Insufficient permissions")
if not has_perms_owner_aware(
if not request.user.has_perms(
["paperless_mail.change_mailaccount"],
) or not has_perms_owner_aware(
request.user,
"change_mailaccount",
existing_account,