Feature: Share links (#3996)

* Implement share links

Basic implementation of share links

Make certain share link fields not editable, automatically grant permissions on migrate

Updated styling, error messages from expired / deleted links

frontend code linting, reversable sharelink migration

testing coverage

Update translation strings

No links message

* Consolidate file response methods

* improvements to share links on mobile devices

* Refactor share links file_version

* Add docs for share links

* Apply suggestions from code review

* When filtering share links, use the timezone aware now()

* Removes extra call to setup directories for usage in testing

* FIx copied badge display on some browsers

* Move copy to ngx-clipboard library

---------

Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
This commit is contained in:
shamoon
2023-09-14 13:32:43 -07:00
committed by GitHub
parent d04c533fc0
commit ef9d01fefe
35 changed files with 1740 additions and 454 deletions

View File

@@ -94,6 +94,7 @@ import { PermissionsFilterDropdownComponent } from './components/common/permissi
import { UsernamePipe } from './pipes/username.pipe'
import { LogoComponent } from './components/common/logo/logo.component'
import { IsNumberPipe } from './pipes/is-number.pipe'
import { ShareLinksDropdownComponent } from './components/common/share-links-dropdown/share-links-dropdown.component'
import localeAf from '@angular/common/locales/af'
import localeAr from '@angular/common/locales/ar'
@@ -231,6 +232,7 @@ function initializeApp(settings: SettingsService) {
UsernamePipe,
LogoComponent,
IsNumberPipe,
ShareLinksDropdownComponent,
],
imports: [
BrowserModule,

View File

@@ -1,7 +1,6 @@
import {
ComponentFixture,
TestBed,
discardPeriodicTasks,
fakeAsync,
tick,
} from '@angular/core/testing'

View File

@@ -0,0 +1,61 @@
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary me-2" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#link" />
</svg>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Share Links</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="shareLinksDropdown" class="shadow share-links-dropdown">
<ul class="list-group list-group-flush">
<li *ngIf="!shareLinks || shareLinks.length === 0" class="list-group-item fst-italic small text-center text-secondary" i18n>
No existing links
</li>
<li class="list-group-item" *ngFor="let link of shareLinks">
<div class="input-group input-group-sm w-100">
<input type="text" class="form-control" aria-label="Share link" [value]="getShareUrl(link)" readonly>
<span *ngIf="link.expiration" class="input-group-text">
{{ getDaysRemaining(link) }}
</span>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="copy(link)">
<svg class="buttonicon" fill="currentColor">
<use *ngIf="copied !== link.id" xlink:href="assets/bootstrap-icons.svg#clipboard-fill" />
<use *ngIf="copied === link.id" xlink:href="assets/bootstrap-icons.svg#clipboard-check-fill" />
</svg><span class="visually-hidden" i18n>Copy</span>
</button>
<button *ngIf="canShare(link)" type="button" class="btn btn-sm btn-outline-primary" (click)="share(link)">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#box-arrow-up" />
</svg><span class="visually-hidden" i18n>Share</span>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete(link)">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg><span class="visually-hidden" i18n>Delete</span>
</button>
</div>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied === link.id" i18n>Copied!</span>
</li>
<li class="list-group-item pt-3 pb-2">
<div class="input-group input-group-sm w-100">
<div class="form-check form-switch ms-auto">
<input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [(ngModel)]="archiveVersion">
<label class="form-check-label small" for="versionSwitch" i18n>Share archive version</label>
</div>
</div>
<div class="input-group input-group-sm w-100 mt-2">
<label class="input-group-text" for="addLink">Expires:</label>
<select class="form-select form-select-sm" [(ngModel)]="expirationDays">
<option *ngFor="let option of EXPIRATION_OPTIONS" [ngValue]="option.value">{{ option.label }}</option>
</select>
<button class="btn btn-sm btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading">
<div *ngIf="loading" class="spinner-border spinner-border-sm me-2" role="status"></div>
<svg *ngIf="!loading" class="buttonicon me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus" />
</svg>
<ng-container i18n>Create</ng-container>
</button>
</div>
</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,14 @@
.share-links-dropdown {
min-width: 350px;
// correct position on mobile
@media (max-width: 575.98px) {
&.show {
margin-left: -175px !important;
}
}
}
.copied-badge {
right: 7.5em;
}

View File

@@ -0,0 +1,195 @@
import {
HttpTestingController,
HttpClientTestingModule,
} from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { of, throwError } from 'rxjs'
import {
PaperlessFileVersion,
PaperlessShareLink,
} from 'src/app/data/paperless-share-link'
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { ShareLinksDropdownComponent } from './share-links-dropdown.component'
import { ClipboardService } from 'ngx-clipboard'
describe('ShareLinksDropdownComponent', () => {
let component: ShareLinksDropdownComponent
let fixture: ComponentFixture<ShareLinksDropdownComponent>
let shareLinkService: ShareLinkService
let toastService: ToastService
let httpController: HttpTestingController
let clipboardService: ClipboardService
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ShareLinksDropdownComponent],
imports: [HttpClientTestingModule, FormsModule, ReactiveFormsModule],
})
fixture = TestBed.createComponent(ShareLinksDropdownComponent)
shareLinkService = TestBed.inject(ShareLinkService)
toastService = TestBed.inject(ToastService)
httpController = TestBed.inject(HttpTestingController)
clipboardService = TestBed.inject(ClipboardService)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support refresh to retrieve links', () => {
const getSpy = jest.spyOn(shareLinkService, 'getLinksForDocument')
component.documentId = 99
const now = new Date()
const expiration7days = new Date()
expiration7days.setDate(now.getDate() + 7)
getSpy.mockReturnValue(
of([
{
id: 1,
slug: '1234slug',
created: now.toISOString(),
document: 99,
file_version: PaperlessFileVersion.Archive,
expiration: expiration7days.toISOString(),
},
{
id: 1,
slug: '1234slug',
created: now.toISOString(),
document: 99,
file_version: PaperlessFileVersion.Original,
expiration: null,
},
])
)
component.refresh()
expect(getSpy).toHaveBeenCalled()
fixture.detectChanges()
expect(component.shareLinks).toHaveLength(2)
})
it('should show error on refresh if needed', () => {
const toastSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(shareLinkService, 'getLinksForDocument')
.mockReturnValueOnce(throwError(() => new Error('Unable to get links')))
component.documentId = 99
component.refresh()
fixture.detectChanges()
expect(toastSpy).toHaveBeenCalled()
})
it('should support link creation then refresh & copy url', fakeAsync(() => {
const createSpy = jest.spyOn(shareLinkService, 'createLinkForDocument')
component.documentId = 99
component.expirationDays = 7
component.archiveVersion = false
const expiration = new Date()
expiration.setDate(expiration.getDate() + 7)
const copySpy = jest.spyOn(clipboardService, 'copy')
const refreshSpy = jest.spyOn(component, 'refresh')
component.createLink()
expect(createSpy).toHaveBeenCalledWith(99, 'original', expiration)
httpController.expectOne(`${environment.apiBaseUrl}share_links/`).flush({
id: 1,
slug: '1234slug',
document: 99,
expiration: expiration.toISOString(),
})
fixture.detectChanges()
tick(3000)
expect(copySpy).toHaveBeenCalled()
expect(refreshSpy).toHaveBeenCalled()
}))
it('should show error on link creation if needed', () => {
component.documentId = 99
component.expirationDays = 7
const expiration = new Date()
expiration.setDate(expiration.getDate() + 7)
const toastSpy = jest.spyOn(toastService, 'showError')
component.createLink()
httpController
.expectOne(`${environment.apiBaseUrl}share_links/`)
.flush(
{ error: 'Share link error' },
{ status: 500, statusText: 'error' }
)
fixture.detectChanges()
expect(toastSpy).toHaveBeenCalled()
})
it('should support delete links & refresh', () => {
const deleteSpy = jest.spyOn(shareLinkService, 'delete')
deleteSpy.mockReturnValue(of(true))
const refreshSpy = jest.spyOn(component, 'refresh')
component.delete({ id: 12 } as PaperlessShareLink)
fixture.detectChanges()
expect(deleteSpy).toHaveBeenCalledWith({ id: 12 })
expect(refreshSpy).toHaveBeenCalled()
})
it('should show error on delete if needed', () => {
const toastSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(shareLinkService, 'delete')
.mockReturnValueOnce(throwError(() => new Error('Unable to delete link')))
component.delete(null)
fixture.detectChanges()
expect(toastSpy).toHaveBeenCalled()
})
it('should format days remaining', () => {
const now = new Date()
const expiration7days = new Date()
expiration7days.setDate(now.getDate() + 7)
const expiration1day = new Date()
expiration1day.setDate(now.getDate() + 1)
expect(
component.getDaysRemaining({
expiration: expiration7days.toISOString(),
} as PaperlessShareLink)
).toEqual('7 days')
expect(
component.getDaysRemaining({
expiration: expiration1day.toISOString(),
} as PaperlessShareLink)
).toEqual('1 day')
})
// coverage
it('should support share', () => {
const link = { slug: '12345slug' } as PaperlessShareLink
if (!('share' in navigator))
Object.defineProperty(navigator, 'share', { value: (obj: any) => {} })
// const navigatorSpy = jest.spyOn(navigator, 'share')
component.share(link)
// expect(navigatorSpy).toHaveBeenCalledWith({ url: component.getShareUrl(link) })
})
})

View File

@@ -0,0 +1,149 @@
import { Component, Input, OnInit } from '@angular/core'
import { first } from 'rxjs'
import {
PaperlessShareLink,
PaperlessFileVersion,
} from 'src/app/data/paperless-share-link'
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { ClipboardService } from 'ngx-clipboard'
@Component({
selector: 'app-share-links-dropdown',
templateUrl: './share-links-dropdown.component.html',
styleUrls: ['./share-links-dropdown.component.scss'],
})
export class ShareLinksDropdownComponent implements OnInit {
EXPIRATION_OPTIONS = [
{ label: $localize`1 day`, value: 1 },
{ label: $localize`7 days`, value: 7 },
{ label: $localize`30 days`, value: 30 },
{ label: $localize`Never`, value: null },
]
@Input()
title = $localize`Share Links`
_documentId: number
@Input()
set documentId(id: number) {
if (id !== undefined) {
this._documentId = id
this.refresh()
}
}
@Input()
disabled: boolean = false
shareLinks: PaperlessShareLink[]
loading: boolean = false
copied: number
expirationDays: number = 7
archiveVersion: boolean = true
constructor(
private shareLinkService: ShareLinkService,
private toastService: ToastService,
private clipboardService: ClipboardService
) {}
ngOnInit(): void {
if (this._documentId !== undefined) this.refresh()
}
refresh() {
if (this._documentId === undefined) return
this.loading = true
this.shareLinkService
.getLinksForDocument(this._documentId)
.pipe(first())
.subscribe({
next: (results) => {
this.loading = false
this.shareLinks = results
},
error: (e) => {
this.toastService.showError(
$localize`Error retrieving links`,
10000,
e
)
},
})
}
getShareUrl(link: PaperlessShareLink): string {
return `${environment.apiBaseUrl.replace('api', 'share')}${link.slug}`
}
getDaysRemaining(link: PaperlessShareLink): string {
const days: number = Math.ceil(
(Date.parse(link.expiration) - Date.now()) / (1000 * 60 * 60 * 24)
)
return days === 1 ? $localize`1 day` : $localize`${days} days`
}
copy(link: PaperlessShareLink) {
this.clipboardService.copy(this.getShareUrl(link))
this.copied = link.id
setTimeout(() => {
this.copied = null
}, 3000)
}
canShare(link: PaperlessShareLink): boolean {
return (
navigator?.canShare && navigator.canShare({ url: this.getShareUrl(link) })
)
}
share(link: PaperlessShareLink) {
navigator.share({ url: this.getShareUrl(link) })
}
delete(link: PaperlessShareLink) {
this.shareLinkService.delete(link).subscribe({
next: () => {
this.refresh()
},
error: (e) => {
this.toastService.showError($localize`Error deleting link`, 10000, e)
},
})
}
createLink() {
let expiration
if (this.expirationDays) {
expiration = new Date()
expiration.setDate(expiration.getDate() + this.expirationDays)
}
this.loading = true
this.shareLinkService
.createLinkForDocument(
this._documentId,
this.archiveVersion
? PaperlessFileVersion.Archive
: PaperlessFileVersion.Original,
expiration
)
.subscribe({
next: (result) => {
this.loading = false
this.copy(result)
this.refresh()
},
error: (e) => {
this.loading = false
this.toastService.showError($localize`Error creating link`, 10000, e)
},
})
}
}

View File

@@ -10,6 +10,7 @@ import { ComponentFixture } from '@angular/core/testing'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { of } from 'rxjs'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { ClipboardService } from 'ngx-clipboard'
const toasts = [
{
@@ -41,6 +42,7 @@ describe('ToastsComponent', () => {
let component: ToastsComponent
let fixture: ComponentFixture<ToastsComponent>
let toastService: ToastService
let clipboardService: ClipboardService
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -57,9 +59,10 @@ describe('ToastsComponent', () => {
}).compileComponents()
fixture = TestBed.createComponent(ToastsComponent)
component = fixture.componentInstance
toastService = TestBed.inject(ToastService)
clipboardService = TestBed.inject(ClipboardService)
component = fixture.componentInstance
fixture.detectChanges()
})
@@ -114,7 +117,7 @@ describe('ToastsComponent', () => {
'Error 2 message details'
)
const copySpy = jest.spyOn(navigator.clipboard, 'writeText')
const copySpy = jest.spyOn(clipboardService, 'copy')
component.copyError(toasts[2].error)
expect(copySpy).toHaveBeenCalled()

View File

@@ -1,6 +1,7 @@
import { Component, OnDestroy, OnInit } from '@angular/core'
import { Subscription } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ClipboardService } from 'ngx-clipboard'
@Component({
selector: 'app-toasts',
@@ -8,7 +9,10 @@ import { Toast, ToastService } from 'src/app/services/toast.service'
styleUrls: ['./toasts.component.scss'],
})
export class ToastsComponent implements OnInit, OnDestroy {
constructor(private toastService: ToastService) {}
constructor(
private toastService: ToastService,
private clipboardService: ClipboardService
) {}
private subscription: Subscription
@@ -45,7 +49,7 @@ export class ToastsComponent implements OnInit, OnDestroy {
}
public copyError(error: any) {
navigator.clipboard.writeText(JSON.stringify(error))
this.clipboardService.copy(JSON.stringify(error))
this.copied = true
setTimeout(() => {
this.copied = false

View File

@@ -5,16 +5,15 @@
<div class="input-group-text" i18n>of {{previewNumPages}}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger me-2 ms-auto" (click)="delete()" [disabled]="!userIsOwner" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<button type="button" class="btn btn-sm btn-outline-danger me-4" (click)="delete()" [disabled]="!userIsOwner" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#trash" />
</svg><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
</button>
<div class="btn-group me-2">
<a [href]="downloadUrl" class="btn btn-sm btn-outline-primary">
<svg class="buttonicon" fill="currentColor">
<svg class="buttonicon me-md-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#download" />
</svg><span class="d-none d-lg-inline ps-1" i18n>Download</span>
</a>
@@ -25,20 +24,31 @@
<a ngbDropdownItem [href]="downloadOriginalUrl" i18n>Download original</a>
</div>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="redoOcr()" [disabled]="!userCanEdit">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-counterclockwise" />
</svg><span class="d-none d-lg-inline ps-1" i18n>Redo OCR</span>
</button>
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary me-2" id="actionsDropdown" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#three-dots" />
</svg>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
<button ngbDropdownItem (click)="redoOcr()" [disabled]="!userCanEdit">
<svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-counterclockwise" />
</svg><span class="ps-1" i18n>Redo OCR</span>
</button>
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="moreLike()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#diagram-3" />
</svg><span class="d-none d-lg-inline ps-1" i18n>More like this</span>
</button>
<button ngbDropdownItem (click)="moreLike()">
<svg class="buttonicon-sm" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#diagram-3" />
</svg><span class="ps-1" i18n>More like this</span>
</button>
</div>
</div>
<app-share-links-dropdown [documentId]="documentId" [disabled]="!userIsOwner" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"></app-share-links-dropdown>
<button type="button" class="btn btn-sm btn-outline-primary me-2" i18n-title title="Close" (click)="close()">
<svg class="buttonicon" fill="currentColor">

View File

@@ -66,6 +66,7 @@ import { TextComponent } from '../common/input/text/text.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component'
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
import { DocumentDetailComponent } from './document-detail.component'
import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
const doc: PaperlessDocument = {
id: 3,
@@ -134,6 +135,7 @@ describe('DocumentDetailComponent', () => {
ConfirmDialogComponent,
PdfViewerComponent,
SafeUrlPipe,
ShareLinksDropdownComponent,
],
providers: [
DocumentTitlePipe,

View File

@@ -63,6 +63,7 @@ import { EditDialogMode } from '../common/edit-dialog/edit-dialog.component'
import { ObjectWithId } from 'src/app/data/object-with-id'
import { FilterRule } from 'src/app/data/filter-rule'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
enum DocumentDetailNavIDs {
Details = 1,

View File

@@ -0,0 +1,18 @@
import { ObjectWithPermissions } from './object-with-permissions'
export enum PaperlessFileVersion {
Archive = 'archive',
Original = 'original',
}
export interface PaperlessShareLink extends ObjectWithPermissions {
created: string // Date
expiration?: string // Date
slug: string
document: number // PaperlessDocument
file_version: string
}

View File

@@ -248,6 +248,10 @@ describe('PermissionsService', () => {
'view_log',
'view_comment',
'change_frontendsettings',
'add_sharelink',
'view_sharelink',
'change_sharelink',
'delete_sharelink',
],
{
username: 'testuser',

View File

@@ -24,6 +24,7 @@ export enum PermissionType {
User = '%s_user',
Group = '%s_group',
Admin = '%s_logentry',
ShareLink = '%s_sharelink',
}
@Injectable({

View File

@@ -10,7 +10,7 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
constructor(
protected http: HttpClient,
private resourceName: string
protected resourceName: string
) {}
protected getResourceUrl(id: number = null, action: string = null): string {

View File

@@ -0,0 +1,42 @@
import { HttpTestingController } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { Subscription } from 'rxjs'
import { environment } from 'src/environments/environment'
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
import { ShareLinkService } from './share-link.service'
let httpTestingController: HttpTestingController
let service: ShareLinkService
let subscription: Subscription
const endpoint = 'share_links'
// run common tests
commonAbstractPaperlessServiceTests(endpoint, ShareLinkService)
describe(`Additional service tests for ShareLinkService`, () => {
beforeEach(() => {
// Dont need to setup again
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(ShareLinkService)
})
afterEach(() => {
subscription?.unsubscribe()
httpTestingController.verify()
})
it('should support creating link for document', () => {
subscription = service.createLinkForDocument(0).subscribe()
httpTestingController
.expectOne(`${environment.apiBaseUrl}${endpoint}/`)
.flush({})
})
it('should support get links for a document', () => {
subscription = service.getLinksForDocument(0).subscribe()
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/0/${endpoint}/`)
.flush({})
})
})

View File

@@ -0,0 +1,36 @@
import { Injectable } from '@angular/core'
import {
PaperlessShareLink,
PaperlessFileVersion,
} from 'src/app/data/paperless-share-link'
import { AbstractNameFilterService } from './abstract-name-filter-service'
import { HttpClient } from '@angular/common/http'
import { Observable } from 'rxjs'
@Injectable({
providedIn: 'root',
})
export class ShareLinkService extends AbstractNameFilterService<PaperlessShareLink> {
constructor(http: HttpClient) {
super(http, 'share_links')
}
getLinksForDocument(documentId: number): Observable<PaperlessShareLink[]> {
return this.http.get<PaperlessShareLink[]>(
`${this.baseUrl}documents/${documentId}/${this.resourceName}/`
)
}
createLinkForDocument(
documentId: number,
file_version: PaperlessFileVersion = PaperlessFileVersion.Archive,
expiration: Date = null
) {
this.clearCache()
return this.http.post<PaperlessShareLink>(this.getResourceUrl(), {
document: documentId,
file_version,
expiration: expiration?.toISOString(),
})
}
}