From b886ccdaa5d8974b87344bb8dab5a44fcb481a45 Mon Sep 17 00:00:00 2001 From: Priyanshu Date: Mon, 20 Jul 2026 20:38:34 +0530 Subject: [PATCH] feat(cloud): implement Secure Send sharing flow (create, list, revoke) --- .../src/app/models/dtos/SecureSendLinkDto.ts | 18 ++ .../src/app/pages/cloud/cloud.component.html | 233 +++++++++++++++++- .../src/app/pages/cloud/cloud.component.scss | 51 ++++ .../app/pages/cloud/cloud.component.spec.ts | 160 +++++++++++- .../src/app/pages/cloud/cloud.component.ts | 126 ++++++++++ .../src/app/services/cloud.service.spec.ts | 104 ++++++++ frontend/src/app/services/cloud.service.ts | 100 +++++++- 7 files changed, 783 insertions(+), 9 deletions(-) create mode 100644 frontend/src/app/models/dtos/SecureSendLinkDto.ts create mode 100644 frontend/src/app/services/cloud.service.spec.ts 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

tooltipPosition="bottom" (click)="scanCurrentFolder()" > + Cloud Page
+ 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. +
+ +
+ + + + 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 || '', + }; + } }