From c869deed0eec131c450951c1d5e47fb0cdd58d79 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Sat, 5 Sep 2026 17:24:41 -0700
Subject: [PATCH] OK, basic share link management page
---
.../share-link-list.component.html | 98 +++++++++++++++
.../share-link-list.component.spec.ts | 106 ++++++++++++++++
.../share-link-list.component.ts | 117 ++++++++++++++++++
.../share-links/share-links.component.html | 4 +-
.../share-links/share-links.component.spec.ts | 8 ++
.../share-links/share-links.component.ts | 8 +-
6 files changed, 337 insertions(+), 4 deletions(-)
create mode 100644 src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.html
create mode 100644 src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.spec.ts
create mode 100644 src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.ts
diff --git a/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.html b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.html
new file mode 100644
index 000000000..701e52310
--- /dev/null
+++ b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.html
@@ -0,0 +1,98 @@
+
+ @if (loading()) {
+
+
+
Loading share links…
+
+ }
+ @if (!loading() && error()) {
+
{{ error() }}
+ }
+ @if (!loading() && !error() && links().length === 0) {
+
+ No document share links currently exist.
+
+ }
+ @if (!loading() && !error() && links().length > 0) {
+
+
+
+
+ | Document |
+ Created |
+ Expires |
+ File version |
+ Actions |
+
+
+
+ @for (link of links(); track link.id) {
+
+ |
+
+ Document #{{ link.document }}
+
+ |
+ {{ link.created | date: 'short' }} |
+
+ @if (link.expiration) {
+ {{ link.expiration | date: 'short' }}
+ } @else {
+ Never
+ }
+ |
+ {{ fileVersionLabel(link.file_version) }} |
+
+
+ Copied!
+
+
+
+ Delete share link
+
+
+
+ |
+
+ }
+
+
+
+ @if (total() > pageSize) {
+
+
+
+ }
+ }
+
diff --git a/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.spec.ts b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.spec.ts
new file mode 100644
index 000000000..ed4e51974
--- /dev/null
+++ b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.spec.ts
@@ -0,0 +1,106 @@
+import { Clipboard } from '@angular/cdk/clipboard'
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+import { RouterTestingModule } from '@angular/router/testing'
+import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
+import { of, throwError } from 'rxjs'
+import { FileVersion, ShareLink } from 'src/app/data/share-link'
+import { ShareLinkService } from 'src/app/services/rest/share-link.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { ShareLinkListComponent } from './share-link-list.component'
+
+describe('ShareLinkListComponent', () => {
+ let component: ShareLinkListComponent
+ let fixture: ComponentFixture
+ let service: jest.Mocked>
+ let clipboard: Clipboard
+ let toastService: jest.Mocked>
+
+ const link = {
+ id: 1,
+ document: 42,
+ slug: 'share-slug',
+ created: new Date().toISOString(),
+ expiration: null,
+ file_version: FileVersion.Archive,
+ } as ShareLink
+
+ beforeEach(() => {
+ service = {
+ list: jest.fn().mockReturnValue(of({ count: 1, results: [link] })),
+ delete: jest.fn().mockReturnValue(of(true)),
+ }
+ toastService = {
+ showInfo: jest.fn(),
+ showError: jest.fn(),
+ }
+
+ TestBed.configureTestingModule({
+ imports: [
+ ShareLinkListComponent,
+ NgxBootstrapIconsModule.pick(allIcons),
+ RouterTestingModule,
+ ],
+ providers: [
+ { provide: ShareLinkService, useValue: service },
+ { provide: ToastService, useValue: toastService },
+ ],
+ })
+
+ fixture = TestBed.createComponent(ShareLinkListComponent)
+ component = fixture.componentInstance
+ clipboard = TestBed.inject(Clipboard)
+ })
+
+ afterEach(() => {
+ jest.clearAllTimers()
+ jest.useRealTimers()
+ })
+
+ it('loads and renders document share links', () => {
+ fixture.detectChanges()
+
+ expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true)
+ expect(component.links()).toEqual([link])
+ expect(fixture.nativeElement.textContent).toContain('Document #42')
+ })
+
+ it('loads another page', () => {
+ fixture.detectChanges()
+ component.setPage(2)
+
+ expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
+ })
+
+ it('shows local copy feedback without a toast', () => {
+ jest.useFakeTimers()
+ jest.spyOn(clipboard, 'copy').mockReturnValue(true)
+ fixture.detectChanges()
+
+ component.copy(link)
+ fixture.detectChanges()
+
+ expect(component.copiedID()).toBe(link.id)
+ expect(fixture.nativeElement.querySelector('.badge.show')).not.toBeNull()
+ expect(toastService.showInfo).not.toHaveBeenCalled()
+
+ jest.advanceTimersByTime(3000)
+ expect(component.copiedID()).toBeNull()
+ })
+
+ it('deletes a link and reloads the list', () => {
+ fixture.detectChanges()
+ component.delete(link)
+
+ expect(service.delete).toHaveBeenCalledWith(link)
+ expect(service.list).toHaveBeenCalledTimes(2)
+ expect(toastService.showInfo).toHaveBeenCalled()
+ })
+
+ it('shows an error when loading fails', () => {
+ service.list.mockReturnValue(throwError(() => new Error('load failed')))
+ fixture.detectChanges()
+
+ expect(component.error()).toContain('Failed to load share links.')
+ expect(toastService.showError).toHaveBeenCalled()
+ })
+})
diff --git a/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.ts b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.ts
new file mode 100644
index 000000000..ab1bc44a6
--- /dev/null
+++ b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.ts
@@ -0,0 +1,117 @@
+import { Clipboard } from '@angular/cdk/clipboard'
+import { CommonModule } from '@angular/common'
+import { Component, OnInit, inject, signal } from '@angular/core'
+import { RouterModule } from '@angular/router'
+import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
+import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
+import { takeUntil } from 'rxjs'
+import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
+import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
+import { FileVersion, ShareLink } from 'src/app/data/share-link'
+import { SHARE_LINK_BUNDLE_FILE_VERSION_LABELS } from 'src/app/data/share-link-bundle'
+import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import {
+ PermissionAction,
+ PermissionType,
+} from 'src/app/services/permissions.service'
+import { ShareLinkService } from 'src/app/services/rest/share-link.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { environment } from 'src/environments/environment'
+
+@Component({
+ selector: 'pngx-share-link-list',
+ templateUrl: './share-link-list.component.html',
+ imports: [
+ CommonModule,
+ ConfirmButtonComponent,
+ IfPermissionsDirective,
+ NgbPaginationModule,
+ NgxBootstrapIconsModule,
+ RouterModule,
+ ],
+})
+export class ShareLinkListComponent
+ extends LoadingComponentWithPermissions
+ implements OnInit
+{
+ private readonly clipboard = inject(Clipboard)
+ private readonly shareLinkService = inject(ShareLinkService)
+ private readonly toastService = inject(ToastService)
+
+ readonly links = signal([])
+ readonly total = signal(0)
+ readonly page = signal(1)
+ readonly copiedID = signal(null)
+ readonly error = signal(null)
+ readonly pageSize = 25
+ readonly PermissionAction = PermissionAction
+ readonly PermissionType = PermissionType
+
+ ngOnInit(): void {
+ this.reload()
+ }
+
+ reload(): void {
+ this.loading.set(true)
+ this.error.set(null)
+ this.shareLinkService
+ .list(this.page(), this.pageSize, 'created', true)
+ .pipe(takeUntil(this.unsubscribeNotifier))
+ .subscribe({
+ next: (results) => {
+ this.links.set(results.results)
+ this.total.set(results.count)
+ this.loading.set(false)
+ },
+ error: (error) => {
+ this.loading.set(false)
+ this.error.set($localize`Failed to load share links.`)
+ this.toastService.showError(
+ $localize`Error retrieving share links.`,
+ error
+ )
+ },
+ })
+ }
+
+ setPage(page: number): void {
+ this.page.set(page)
+ this.reload()
+ }
+
+ getShareUrl(link: ShareLink): string {
+ const apiURL = new URL(environment.apiBaseUrl)
+ return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
+ link.slug
+ }`
+ }
+
+ fileVersionLabel(version: FileVersion): string {
+ return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
+ }
+
+ copy(link: ShareLink): void {
+ if (this.clipboard.copy(this.getShareUrl(link))) {
+ this.copiedID.set(link.id)
+ setTimeout(() => this.copiedID.set(null), 3000)
+ }
+ }
+
+ delete(link: ShareLink): void {
+ this.shareLinkService.delete(link).subscribe({
+ next: () => {
+ if (this.links().length === 1 && this.page() > 1) {
+ this.page.update((page) => page - 1)
+ }
+ this.toastService.showInfo($localize`Share link deleted.`)
+ this.reload()
+ },
+ error: (error) => {
+ this.toastService.showError(
+ $localize`Error deleting share link.`,
+ error
+ )
+ },
+ })
+ }
+}
diff --git a/src-ui/src/app/components/manage/share-links/share-links.component.html b/src-ui/src/app/components/manage/share-links/share-links.component.html
index 6d96349fb..0dfe2c909 100644
--- a/src-ui/src/app/components/manage/share-links/share-links.component.html
+++ b/src-ui/src/app/components/manage/share-links/share-links.component.html
@@ -16,9 +16,7 @@
-
- Manage links to individual documents here.
-
+
}
diff --git a/src-ui/src/app/components/manage/share-links/share-links.component.spec.ts b/src-ui/src/app/components/manage/share-links/share-links.component.spec.ts
index 084170f52..dfee17ae4 100644
--- a/src-ui/src/app/components/manage/share-links/share-links.component.spec.ts
+++ b/src-ui/src/app/components/manage/share-links/share-links.component.spec.ts
@@ -9,6 +9,7 @@ import {
PermissionType,
} from 'src/app/services/permissions.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
+import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { ToastService } from 'src/app/services/toast.service'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ShareLinksComponent, ShareLinksNavIDs } from './share-links.component'
@@ -46,6 +47,13 @@ describe('ShareLinksComponent', () => {
delete: jest.fn(),
},
},
+ {
+ provide: ShareLinkService,
+ useValue: {
+ list: jest.fn().mockReturnValue(of({ count: 0, results: [] })),
+ delete: jest.fn(),
+ },
+ },
{
provide: ToastService,
useValue: { showInfo: jest.fn(), showError: jest.fn() },
diff --git a/src-ui/src/app/components/manage/share-links/share-links.component.ts b/src-ui/src/app/components/manage/share-links/share-links.component.ts
index c1979af30..fed59bbf5 100644
--- a/src-ui/src/app/components/manage/share-links/share-links.component.ts
+++ b/src-ui/src/app/components/manage/share-links/share-links.component.ts
@@ -8,6 +8,7 @@ import {
} from 'src/app/services/permissions.service'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ShareLinkBundleListComponent } from './share-link-bundle-list/share-link-bundle-list.component'
+import { ShareLinkListComponent } from './share-link-list/share-link-list.component'
export enum ShareLinksNavIDs {
DocumentLinks = 'documents',
@@ -17,7 +18,12 @@ export enum ShareLinksNavIDs {
@Component({
selector: 'pngx-share-links',
templateUrl: './share-links.component.html',
- imports: [NgbNavModule, PageHeaderComponent, ShareLinkBundleListComponent],
+ imports: [
+ NgbNavModule,
+ PageHeaderComponent,
+ ShareLinkBundleListComponent,
+ ShareLinkListComponent,
+ ],
})
export class ShareLinksComponent {
private readonly route = inject(ActivatedRoute)