diff --git a/frontend/src/app/models/dtos/SecureSendLinkDto.ts b/frontend/src/app/models/dtos/SecureSendLinkDto.ts
new file mode 100644
index 00000000..b3ac0a9f
--- /dev/null
+++ b/frontend/src/app/models/dtos/SecureSendLinkDto.ts
@@ -0,0 +1,18 @@
+export interface SecureSendLinkDto {
+ id: string;
+ filePath: string;
+ fileName: string;
+ shareUrl?: string;
+ token?: string;
+ expiresAt: string | Date;
+ hasPassword?: boolean;
+ isRevoked?: boolean;
+ revokedAt?: string | Date | null;
+ createdAt: string | Date;
+}
+
+export interface CreateSecureSendRequestDto {
+ filePath: string;
+ expiresAt: string;
+ password?: string;
+}
diff --git a/frontend/src/app/pages/cloud/cloud.component.html b/frontend/src/app/pages/cloud/cloud.component.html
index 2bd46bb2..4bf8ea2d 100644
--- a/frontend/src/app/pages/cloud/cloud.component.html
+++ b/frontend/src/app/pages/cloud/cloud.component.html
@@ -26,6 +26,16 @@
Cloud Page
-
Cloud Page
>
+
+
+
+
+ Create an expiring link to share
+ {{ selectedFileForShare.name }} safely.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Notice: This share link is displayed only once.
+ Please copy and save it now.
+
+
+
+
+
+
+
+
+
+
+
+ Loading share links...
+
+
+
+ No active share links found.
+
+
+ 0"
+ class="cloud-table-wrap"
+ >
+
+
+
+ | File |
+ Created |
+ Expires |
+ Security |
+ Status |
+ Action |
+
+
+
+
+ | {{ link.fileName }} |
+ {{ link.createdAt | date: "short" }} |
+ {{ link.expiresAt | date: "short" }} |
+
+
+
+ {{ link.hasPassword ? "Password" : "Public" }}
+
+ |
+
+
+ {{ link.isRevoked ? "Revoked" : "Active" }}
+
+ |
+
+
+ Revoked
+ |
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/app/pages/cloud/cloud.component.scss b/frontend/src/app/pages/cloud/cloud.component.scss
index 376a00c7..99413602 100644
--- a/frontend/src/app/pages/cloud/cloud.component.scss
+++ b/frontend/src/app/pages/cloud/cloud.component.scss
@@ -786,3 +786,54 @@
word-break: break-all;
white-space: pre-wrap;
}
+
+/* Secure Send Styles */
+.cloud-info-box {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.5rem;
+ padding: 0.75rem;
+ background: rgba(245, 158, 11, 0.1);
+ border: 1px solid rgba(245, 158, 11, 0.3);
+ border-radius: 0.375rem;
+ color: #d97706;
+
+ i {
+ font-size: 1rem;
+ margin-top: 0.1rem;
+ }
+}
+
+.cloud-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.15rem 0.5rem;
+ border-radius: 9999px;
+ font-size: 0.7rem;
+ font-weight: 500;
+
+ &.badge-protected {
+ background: rgba(99, 102, 241, 0.15);
+ color: #6366f1;
+ border: 1px solid rgba(99, 102, 241, 0.3);
+ }
+
+ &.badge-public {
+ background: rgba(107, 114, 128, 0.15);
+ color: #9ca3af;
+ border: 1px solid rgba(107, 114, 128, 0.3);
+ }
+
+ &.badge-active {
+ background: rgba(16, 185, 129, 0.15);
+ color: #10b981;
+ border: 1px solid rgba(16, 185, 129, 0.3);
+ }
+
+ &.badge-revoked {
+ background: rgba(239, 68, 68, 0.15);
+ color: #ef4444;
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ }
+}
diff --git a/frontend/src/app/pages/cloud/cloud.component.spec.ts b/frontend/src/app/pages/cloud/cloud.component.spec.ts
index 45863cbb..a3b6a663 100644
--- a/frontend/src/app/pages/cloud/cloud.component.spec.ts
+++ b/frontend/src/app/pages/cloud/cloud.component.spec.ts
@@ -1,15 +1,17 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { of, throwError, Subject } from 'rxjs';
import { CloudComponent } from './cloud.component';
import { CloudService } from '../../services/cloud.service';
+import { UiToastService } from '../../core/services/ui-toast.service';
import { ScanJobDto } from '../../models/dtos/ScanJobDto';
+import { SecureSendLinkDto } from '../../models/dtos/SecureSendLinkDto';
/**
* Exercises the folder virus-scan state machine end to end with a mocked
- * CloudService and Jasmine's fake clock driving the poll timer. Covers the
- * running/completed transitions, disabled/unreachable-scanner detection, the
- * clean result, rate-limit and expired-job errors, and that polling stops on a
- * terminal status or when the dialog closes (including a close that races the
- * initial start request).
+ * CloudService and Jasmine's fake clock driving the poll timer.
*/
describe('CloudComponent virus scan', () => {
let component: CloudComponent;
@@ -179,8 +181,6 @@ describe('CloudComponent virus scan', () => {
component.scanCurrentFolder();
jasmine.clock().tick(1200); // first poll -> 429
- // a throttled poll says nothing about the scan: it must not be reported as
- // failed, and the dialog must stay in its running state
expect(component.scanning).toBeTrue();
expect(component.scanError).toBeUndefined();
@@ -242,3 +242,149 @@ describe('CloudComponent virus scan', () => {
expect(cloudMock.getScanJob).not.toHaveBeenCalled();
});
});
+
+describe('CloudComponent Secure Send Flow', () => {
+ let component: CloudComponent;
+ let fixture: ComponentFixture;
+ let cloudServiceSpy: jasmine.SpyObj;
+ let toastSpy: jasmine.SpyObj;
+
+ beforeEach(async () => {
+ cloudServiceSpy = jasmine.createSpyObj('CloudService', [
+ 'getRootFolder',
+ 'getFolderByPath',
+ 'getFolderContent',
+ 'createSecureSendLink',
+ 'listSecureSendLinks',
+ 'revokeSecureSendLink',
+ ]);
+
+ cloudServiceSpy.getRootFolder.and.returnValue(
+ of({ id: 'root', name: 'Root', path: '/' } as any),
+ );
+ cloudServiceSpy.getFolderContent.and.returnValue(
+ of({ content: [], totalElements: 0, totalPages: 0, page: 0 } as any),
+ );
+
+ toastSpy = jasmine.createSpyObj('UiToastService', [
+ 'success',
+ 'error',
+ 'info',
+ 'warn',
+ ]);
+
+ await TestBed.configureTestingModule({
+ imports: [
+ CloudComponent,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ NoopAnimationsModule,
+ ],
+ providers: [
+ { provide: CloudService, useValue: cloudServiceSpy },
+ { provide: UiToastService, useValue: toastSpy },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(CloudComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should open create share link dialog for a file', () => {
+ component.openCreateShareDialog('/file.txt', 'file.txt');
+ expect(component.selectedFileForShare).toEqual({
+ path: '/file.txt',
+ name: 'file.txt',
+ });
+ expect(component.showCreateShareDialog).toBeTrue();
+ expect(component.shareExpiryMinutes).toBe(1440);
+ });
+
+ it('should create a secure send link and open generated URL dialog on success', () => {
+ component.selectedFileForShare = { path: '/doc.pdf', name: 'doc.pdf' };
+ component.shareExpiryMinutes = 60;
+ component.sharePassword = 'pass';
+
+ const mockLink: SecureSendLinkDto = {
+ id: 's1',
+ filePath: '/doc.pdf',
+ fileName: 'doc.pdf',
+ shareUrl: 'http://localhost/share/s1',
+ expiresAt: '2026-07-21T00:00:00Z',
+ createdAt: '2026-07-20T00:00:00Z',
+ };
+
+ cloudServiceSpy.createSecureSendLink.and.returnValue(of(mockLink));
+
+ component.submitCreateShareLink();
+
+ expect(cloudServiceSpy.createSecureSendLink).toHaveBeenCalledWith(
+ '/doc.pdf',
+ 60,
+ 'pass',
+ );
+ expect(component.createdShareUrl).toBe('http://localhost/share/s1');
+ expect(component.showCreateShareDialog).toBeFalse();
+ expect(component.showGeneratedLinkDialog).toBeTrue();
+ expect(toastSpy.success).toHaveBeenCalledWith(
+ 'Share link created',
+ 'Link for "doc.pdf" created successfully.',
+ );
+ });
+
+ it('should handle rate limit error (HTTP 429) when creating share link', () => {
+ component.selectedFileForShare = { path: '/doc.pdf', name: 'doc.pdf' };
+ cloudServiceSpy.createSecureSendLink.and.returnValue(
+ throwError(() => ({ status: 429, message: 'Too Many Requests' })),
+ );
+
+ component.submitCreateShareLink();
+
+ expect(toastSpy.error).toHaveBeenCalledWith(
+ 'Rate limit reached',
+ 'Too many share links created. Please wait before trying again.',
+ );
+ });
+
+ it('should load active share links in dialog', () => {
+ const mockLinks: SecureSendLinkDto[] = [
+ {
+ id: 'link1',
+ filePath: '/a.txt',
+ fileName: 'a.txt',
+ expiresAt: '2026-07-25T00:00:00Z',
+ createdAt: '2026-07-20T00:00:00Z',
+ },
+ ];
+
+ cloudServiceSpy.listSecureSendLinks.and.returnValue(of(mockLinks));
+
+ component.openSharedLinksDialog();
+
+ expect(component.showSharedLinksDialog).toBeTrue();
+ expect(component.sharedLinks).toEqual(mockLinks);
+ });
+
+ it('should revoke a share link and update UI state', () => {
+ const link: SecureSendLinkDto = {
+ id: 'link1',
+ filePath: '/a.txt',
+ fileName: 'a.txt',
+ expiresAt: '2026-07-25T00:00:00Z',
+ createdAt: '2026-07-20T00:00:00Z',
+ isRevoked: false,
+ };
+
+ cloudServiceSpy.revokeSecureSendLink.and.returnValue(of(void 0));
+
+ component.revokeShareLink(link);
+
+ expect(cloudServiceSpy.revokeSecureSendLink).toHaveBeenCalledWith('link1');
+ expect(link.isRevoked).toBeTrue();
+ expect(toastSpy.success).toHaveBeenCalledWith(
+ 'Link revoked',
+ 'Share link for "a.txt" was revoked.',
+ );
+ });
+});
diff --git a/frontend/src/app/pages/cloud/cloud.component.ts b/frontend/src/app/pages/cloud/cloud.component.ts
index 74c01908..14877f09 100644
--- a/frontend/src/app/pages/cloud/cloud.component.ts
+++ b/frontend/src/app/pages/cloud/cloud.component.ts
@@ -28,6 +28,7 @@ import { FolderContentItemDto } from '../../models/dtos/FolderContentItemDto';
import { SearchResultDto } from '../../models/dtos/SearchResultDto';
import { ScanJobDto } from '../../models/dtos/ScanJobDto';
import { FileScanResultDto } from '../../models/dtos/FileScanResultDto';
+import { SecureSendLinkDto } from '../../models/dtos/SecureSendLinkDto';
import { CloudService } from '../../services/cloud.service';
import { finalize, firstValueFrom } from 'rxjs';
import { UiToastService } from '../../core/services/ui-toast.service';
@@ -93,6 +94,26 @@ export class CloudComponent implements OnInit, OnDestroy {
showCreateFolderDialog = false;
showRenameFolderDialog = false;
showRenameFileDialog = false;
+ showCreateShareDialog = false;
+ showGeneratedLinkDialog = false;
+ showSharedLinksDialog = false;
+
+ selectedFileForShare: { path: string; name: string } | null = null;
+ shareExpiryMinutes = 1440;
+ sharePassword = '';
+ createdShareUrl = '';
+ creatingShareLink = false;
+ sharedLinks: SecureSendLinkDto[] = [];
+ loadingSharedLinks = false;
+ revokingLinkId: string | null = null;
+
+ expiryOptions = [
+ { label: '1 Hour', value: 60 },
+ { label: '1 Day', value: 1440 },
+ { label: '7 Days', value: 10080 },
+ { label: '30 Days', value: 43200 },
+ ];
+
newFolderName = '';
renameFolderName = '';
renameFileName = '';
@@ -1002,6 +1023,111 @@ export class CloudComponent implements OnInit, OnDestroy {
this.previewHtml = '';
}
+ openCreateShareDialog(filePath: string, fileName: string) {
+ this.selectedFileForShare = { path: filePath, name: fileName };
+ this.shareExpiryMinutes = 1440;
+ this.sharePassword = '';
+ this.showCreateShareDialog = true;
+ }
+
+ submitCreateShareLink() {
+ if (!this.selectedFileForShare) return;
+
+ this.creatingShareLink = true;
+ const { path, name } = this.selectedFileForShare;
+
+ this.cloudService
+ .createSecureSendLink(
+ path,
+ Number(this.shareExpiryMinutes),
+ this.sharePassword,
+ )
+ .pipe(finalize(() => (this.creatingShareLink = false)))
+ .subscribe({
+ next: (link) => {
+ this.createdShareUrl = link.shareUrl || '';
+ this.showCreateShareDialog = false;
+ this.showGeneratedLinkDialog = true;
+ this.toast.success(
+ 'Share link created',
+ `Link for "${name}" created successfully.`,
+ );
+ },
+ error: (err: unknown) => {
+ const status = (err as { status?: number })?.status;
+ if (status === 429) {
+ this.toast.error(
+ 'Rate limit reached',
+ 'Too many share links created. Please wait before trying again.',
+ );
+ } else {
+ this.toast.error(
+ 'Share failed',
+ this.getErrorMessage(err) || 'Could not create share link.',
+ );
+ }
+ },
+ });
+ }
+
+ copyShareUrlToClipboard() {
+ if (!this.createdShareUrl) return;
+ navigator.clipboard.writeText(this.createdShareUrl).then(
+ () => {
+ this.toast.success('Copied!', 'Share link copied to clipboard.');
+ },
+ () => {
+ this.toast.error('Copy failed', 'Please manually copy the URL.');
+ },
+ );
+ }
+
+ openSharedLinksDialog() {
+ this.showSharedLinksDialog = true;
+ this.loadSharedLinks();
+ }
+
+ loadSharedLinks() {
+ this.loadingSharedLinks = true;
+ this.cloudService
+ .listSecureSendLinks()
+ .pipe(finalize(() => (this.loadingSharedLinks = false)))
+ .subscribe({
+ next: (links) => {
+ this.sharedLinks = links;
+ },
+ error: (err) => {
+ this.toast.error(
+ 'Error loading links',
+ this.getErrorMessage(err) || 'Could not fetch active share links.',
+ );
+ },
+ });
+ }
+
+ revokeShareLink(link: SecureSendLinkDto) {
+ this.revokingLinkId = link.id;
+ this.cloudService
+ .revokeSecureSendLink(link.id)
+ .pipe(finalize(() => (this.revokingLinkId = null)))
+ .subscribe({
+ next: () => {
+ link.isRevoked = true;
+ link.revokedAt = new Date().toISOString();
+ this.toast.success(
+ 'Link revoked',
+ `Share link for "${link.fileName}" was revoked.`,
+ );
+ },
+ error: (err) => {
+ this.toast.error(
+ 'Revocation failed',
+ this.getErrorMessage(err) || 'Could not revoke share link.',
+ );
+ },
+ });
+ }
+
isMarkdownFile(fileName: string): boolean {
if (!fileName) return false;
const ext = fileName.split('.').pop()?.toLowerCase();
diff --git a/frontend/src/app/services/cloud.service.spec.ts b/frontend/src/app/services/cloud.service.spec.ts
new file mode 100644
index 00000000..66c45df5
--- /dev/null
+++ b/frontend/src/app/services/cloud.service.spec.ts
@@ -0,0 +1,104 @@
+import { TestBed } from '@angular/core/testing';
+import {
+ HttpClientTestingModule,
+ HttpTestingController,
+} from '@angular/common/http/testing';
+import { CloudService } from './cloud.service';
+
+describe('CloudService Secure Send', () => {
+ let service: CloudService;
+ let httpMock: HttpTestingController;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule],
+ providers: [CloudService],
+ });
+ service = TestBed.inject(CloudService);
+ httpMock = TestBed.inject(HttpTestingController);
+ });
+
+ afterEach(() => {
+ httpMock.verify();
+ });
+
+ it('should send POST request to create a secure send link with ISO instant expiresAt', () => {
+ const mockResponse = {
+ id: 'share-123',
+ filePath: '/document.pdf',
+ fileName: 'document.pdf',
+ url: 'http://localhost/share/token123',
+ expiresAt: '2026-07-21T12:00:00Z',
+ passwordProtected: true,
+ revoked: false,
+ };
+
+ service
+ .createSecureSendLink('/document.pdf', 1440, 'secret')
+ .subscribe((res) => {
+ expect(res.id).toBe('share-123');
+ expect(res.fileName).toBe('document.pdf');
+ expect(res.hasPassword).toBeTrue();
+ expect(res.shareUrl).toBe('http://localhost/share/token123');
+ });
+
+ const req = httpMock.expectOne(`${service.apiUrl}/secure-sends`);
+ expect(req.request.method).toBe('POST');
+ expect(req.request.body.filePath).toBe('/document.pdf');
+ expect(req.request.body.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
+ expect(req.request.body.password).toBe('secret');
+ req.flush(mockResponse);
+ });
+
+ it('should map passwordProtected true from response to hasPassword true', () => {
+ const mockResponse = {
+ id: 'share-456',
+ filePath: '/secret.txt',
+ fileName: 'secret.txt',
+ url: 'http://localhost/share/token456',
+ expiresAt: '2026-07-21T12:00:00Z',
+ passwordProtected: true,
+ revoked: false,
+ };
+
+ service.listSecureSendLinks().subscribe((links) => {
+ expect(links.length).toBe(1);
+ expect(links[0].hasPassword).toBeTrue();
+ expect(links[0].isRevoked).toBeFalse();
+ });
+
+ const req = httpMock.expectOne(`${service.apiUrl}/secure-sends`);
+ expect(req.request.method).toBe('GET');
+ req.flush([mockResponse]);
+ });
+
+ it('should list active secure send links', () => {
+ const mockList = [
+ {
+ id: 'link-1',
+ filePath: '/test.png',
+ expiresAt: '2026-07-22T00:00:00Z',
+ passwordProtected: false,
+ revoked: false,
+ },
+ ];
+
+ service.listSecureSendLinks().subscribe((links) => {
+ expect(links.length).toBe(1);
+ expect(links[0].id).toBe('link-1');
+ expect(links[0].hasPassword).toBeFalse();
+ });
+
+ const req = httpMock.expectOne(`${service.apiUrl}/secure-sends`);
+ expect(req.request.method).toBe('GET');
+ req.flush(mockList);
+ });
+
+ it('should send DELETE request to revoke a secure send link', () => {
+ service.revokeSecureSendLink('link-1').subscribe();
+
+ const req = httpMock.expectOne(`${service.apiUrl}/secure-sends/link-1`);
+ expect(req.request.method).toBe('DELETE');
+ req.flush(null);
+ });
+});
diff --git a/frontend/src/app/services/cloud.service.ts b/frontend/src/app/services/cloud.service.ts
index 9a0fb73c..cedc446e 100644
--- a/frontend/src/app/services/cloud.service.ts
+++ b/frontend/src/app/services/cloud.service.ts
@@ -1,14 +1,39 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
-import { Observable } from 'rxjs';
+import { Observable, map } from 'rxjs';
import { FolderDto } from '../models/dtos/FolderDto';
import { FolderContentItemDto } from '../models/dtos/FolderContentItemDto';
import { PageResponseDto } from '../models/dtos/PageResponseDto';
import { TrashEntryDto } from '../models/dtos/TrashEntryDto';
import { SearchResultDto } from '../models/dtos/SearchResultDto';
import { ScanJobDto } from '../models/dtos/ScanJobDto';
+import {
+ SecureSendLinkDto,
+ CreateSecureSendRequestDto,
+} from '../models/dtos/SecureSendLinkDto';
import { environment } from '../../environments/environment';
+interface RawSecureSendResponse {
+ id?: string;
+ linkId?: string;
+ token?: string;
+ shareToken?: string;
+ filePath?: string;
+ path?: string;
+ fileName?: string;
+ name?: string;
+ url?: string;
+ shareUrl?: string;
+ expiresAt?: string;
+ passwordProtected?: boolean;
+ hasPassword?: boolean;
+ protected?: boolean;
+ revoked?: boolean;
+ isRevoked?: boolean;
+ revokedAt?: string | null;
+ createdAt?: string;
+}
+
@Injectable({
providedIn: 'root',
})
@@ -186,4 +211,77 @@ export class CloudService {
`${this.apiUrl}/files/scan/${encodeURIComponent(jobId)}`,
);
}
+
+ createSecureSendLink(
+ filePath: string,
+ expiryMinutes = 1440,
+ password?: string,
+ ): Observable {
+ const normPath = this.normalizePath(filePath);
+ const expiresAt = new Date(
+ Date.now() + expiryMinutes * 60_000,
+ ).toISOString();
+ const payload: CreateSecureSendRequestDto = {
+ filePath: normPath,
+ expiresAt,
+ password: password && password.trim() ? password.trim() : undefined,
+ };
+
+ return this.http
+ .post(`${this.apiUrl}/secure-sends`, payload)
+ .pipe(map((res) => this.mapSecureSendLink(res, normPath, password)));
+ }
+
+ listSecureSendLinks(): Observable {
+ return this.http
+ .get(`${this.apiUrl}/secure-sends`)
+ .pipe(
+ map((resList) =>
+ Array.isArray(resList)
+ ? resList.map((item) => this.mapSecureSendLink(item))
+ : [],
+ ),
+ );
+ }
+
+ revokeSecureSendLink(id: string): Observable {
+ const encodedId = encodeURIComponent(id);
+ return this.http.delete(`${this.apiUrl}/secure-sends/${encodedId}`);
+ }
+
+ private mapSecureSendLink(
+ res: RawSecureSendResponse,
+ defaultPath = '',
+ password?: string,
+ ): SecureSendLinkDto {
+ const rawPath = res?.filePath || res?.path || defaultPath;
+ const fileName =
+ res?.fileName ||
+ res?.name ||
+ (rawPath ? rawPath.substring(rawPath.lastIndexOf('/') + 1) : 'File');
+ const id = res?.id || res?.linkId || res?.token || '';
+ const token = res?.token || res?.shareToken || id;
+
+ const origin = typeof window !== 'undefined' ? window.location.origin : '';
+ const shareUrl =
+ res?.url || res?.shareUrl || (token ? `${origin}/share/${token}` : '');
+
+ return {
+ id,
+ filePath: rawPath,
+ fileName,
+ shareUrl,
+ token,
+ expiresAt: res?.expiresAt || '',
+ hasPassword: Boolean(
+ res?.passwordProtected ??
+ res?.hasPassword ??
+ res?.protected ??
+ !!password,
+ ),
+ isRevoked: Boolean(res?.revoked ?? res?.isRevoked ?? false),
+ revokedAt: res?.revokedAt || null,
+ createdAt: res?.createdAt || '',
+ };
+ }
}
|