diff --git a/angular.json b/angular.json index b2e3c103..2f08828a 100644 --- a/angular.json +++ b/angular.json @@ -31,7 +31,7 @@ "src/styles.scss", "./node_modules/swagger-ui/dist/swagger-ui.css" ], - "allowedCommonJsDependencies": ["ace-builds", "flat", "minim"] + "allowedCommonJsDependencies": ["ace-builds", "flat", "minim", "prop-types", "swagger-ui"] }, "configurations": { "production": { diff --git a/src/app/adf-services/df-service-details/df-service-details.component.html b/src/app/adf-services/df-service-details/df-service-details.component.html index 0af8252a..b4bdc206 100644 --- a/src/app/adf-services/df-service-details/df-service-details.component.html +++ b/src/app/adf-services/df-service-details/df-service-details.component.html @@ -229,14 +229,15 @@

'picklist', 'multi_picklist', 'boolean', - 'file_certificate' + 'file_certificate', + 'file_certificate_api' ].includes(item.type) " [schema]="item" [formControl]="getConfigControl(item.name)" - [class.dynamic-width]="item.type !== 'file_certificate'" + [class.dynamic-width]="['file_certificate', 'file_certificate_api'].indexOf(item.type) === -1" [class.full-width]=" - item.type === 'file_certificate' + ['file_certificate', 'file_certificate_api'].indexOf(item.type) !== -1 "> Full Access

'picklist', 'multi_picklist', 'boolean', - 'file_certificate' + 'file_certificate', + 'file_certificate_api' ].includes(item.type) " color="primary" [schema]="item" [formControl]="getConfigControl(item.name)" - [class.dynamic-width]="item.type !== 'file_certificate'" + [class.dynamic-width]="['file_certificate', 'file_certificate_api'].indexOf(item.type) === -1" [class.full-width]=" - item.type === 'file_certificate' + ['file_certificate', 'file_certificate_api'].indexOf(item.type) !== -1 "> + + + + + + + + void; onTouched: () => void; @@ -69,18 +76,21 @@ export class DfDynamicFieldComponent implements OnInit, DoCheck { controlDir.valueAccessor = this; } - eventList: string[]; + eventList: string[] = []; filteredEventList: Observable; isDarkMode = this.themeService.darkMode$; ngOnInit(): void { if (this.schema.type === 'event_picklist') { - this.activedRoute.data.subscribe(({ systemEvents }) => { - this.eventList = addGroupEntries(systemEvents.resource); + this.activedRoute.data.subscribe((data: any) => { + if (data.systemEvents && data.systemEvents.resource) { + this.eventList = addGroupEntries(data.systemEvents.resource); + } }); this.filteredEventList = this.control.valueChanges.pipe( startWith(''), - map(value => { + map((value: string) => { + if (!value || !this.eventList) return []; return this.eventList.filter(event => event.toLowerCase().includes(value.toLowerCase()) ); @@ -98,6 +108,19 @@ export class DfDynamicFieldComponent implements OnInit, DoCheck { } } + ngAfterViewInit(): void { + if (this.schema?.type === 'file_certificate_api' && this.fileSelector) { + if (this.pendingFilePath) { + console.log('Applying pending file path after view init:', this.pendingFilePath); + this.fileSelector.setPath(this.pendingFilePath); + this.pendingFilePath = null; + } else if (this.control.value && typeof this.control.value === 'string') { + console.log('Setting file selector path after view init:', this.control.value); + this.fileSelector.setPath(this.control.value); + } + } + } + handleFileInput(event: Event) { const input = event.target as HTMLInputElement; if (input.files) { @@ -105,7 +128,35 @@ export class DfDynamicFieldComponent implements OnInit, DoCheck { } } + onFileSelected(file: SelectedFile | undefined) { + if (file) { + this.control.setValue(file.path); + + console.log('File selected in dynamic field:', file); + } else { + this.control.setValue(null); + } + } + writeValue(value: any): void { + console.log('Dynamic field writeValue:', value, 'Schema type:', this.schema?.type); + + if (this.schema?.type === 'file_certificate_api' && typeof value === 'string' && value) { + console.log('Setting file path value:', value); + + this.control.setValue(value, { emitEvent: false }); + + if (this.fileSelector) { + console.log('Setting path on file selector:', value); + this.fileSelector.setPath(value); + } else { + console.log('File selector not yet available, storing pending path:', value); + this.pendingFilePath = value; + } + + return; + } + this.control.setValue(value, { emitEvent: false }); } diff --git a/src/app/shared/components/df-file-selector/df-file-selector-dialog.component.html b/src/app/shared/components/df-file-selector/df-file-selector-dialog.component.html new file mode 100644 index 00000000..e673bfa9 --- /dev/null +++ b/src/app/shared/components/df-file-selector/df-file-selector-dialog.component.html @@ -0,0 +1,160 @@ +

+ + Upload Private Key File + + + Select Private Key File + + + Allowed file types: {{ data.allowedExtensions.join(', ') }} + +

+ + + +
+

Select a File Service

+
+
+
+ +
+
+
{{ fileApi.label || fileApi.name }}
+
{{ fileApi.type }}
+
+
+
+
+ + +
+ + + + +
+ + + + +
+ +
+ +
Loading files...
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
Name +
+ + {{ file.name }} +
+
Type + {{ file.type === 'folder' ? 'Folder' : (file.contentType || 'File') }} + Actions + + +
+ +
+

This directory is empty.

+ +
+
+ +
+

Upload "{{ data.fileToUpload?.name }}" to this location?

+ +
+
+
+ +
+ + +
\ No newline at end of file diff --git a/src/app/shared/components/df-file-selector/df-file-selector-dialog.component.scss b/src/app/shared/components/df-file-selector/df-file-selector-dialog.component.scss new file mode 100644 index 00000000..22734880 --- /dev/null +++ b/src/app/shared/components/df-file-selector/df-file-selector-dialog.component.scss @@ -0,0 +1,241 @@ +mat-dialog-content { + min-height: 400px; + max-height: 600px; + overflow-y: auto; +} + +h2 { + margin-bottom: 0; + + small { + display: block; + font-size: 12px; + font-weight: normal; + color: rgba(0, 0, 0, 0.54); + margin-top: 4px; + } +} + +/* File API Selection */ +.file-api-selection { + padding: 16px 0; + + h3 { + margin-top: 0; + margin-bottom: 16px; + } +} + +.file-api-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 16px; +} + +.file-api-card { + display: flex; + align-items: center; + padding: 16px; + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.12); + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } +} + +.file-api-icon { + margin-right: 16px; + color: #3f51b5; +} + +.file-api-details { + .file-api-name { + font-weight: 500; + margin-bottom: 4px; + } + + .file-api-type { + font-size: 12px; + color: rgba(0, 0, 0, 0.54); + } +} + +/* File Browser */ +.file-browser { + .navigation-bar { + display: flex; + align-items: center; + margin-bottom: 16px; + + .current-location { + margin-left: 8px; + + .service-name { + font-weight: 500; + margin-right: 8px; + } + } + } + + .action-row { + display: flex; + gap: 16px; + margin-bottom: 20px; + + .action-button { + display: flex; + align-items: center; + border: none; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + .button-content { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 4px; + margin-right: 8px; + font-weight: bold; + font-size: 12px; + } + + &:hover { + opacity: 0.9; + } + + &:active { + transform: translateY(1px); + } + } + + .create-folder-btn { + background-color: #3f51b5; + color: white; + + .button-content { + background-color: rgba(255, 255, 255, 0.2); + } + } + + .upload-file-btn { + background-color: #ff5722; + color: white; + + .button-content { + background-color: rgba(255, 255, 255, 0.2); + } + } + } +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; + + div { + margin-top: 16px; + color: rgba(0, 0, 0, 0.54); + } +} + +.file-table { + width: 100%; + + .mat-column-name { + width: 60%; + } + + .mat-column-type { + width: 20%; + } + + .mat-column-actions { + width: 20%; + text-align: right; + } + + .file-name-cell { + display: flex; + align-items: center; + + fa-icon { + margin-right: 8px; + color: #3f51b5; + } + } + + .selected-row { + background-color: rgba(63, 81, 181, 0.08); + } +} + +.empty-directory { + padding: 24px 16px; + text-align: center; + color: rgba(0, 0, 0, 0.54); + + p { + margin-bottom: 16px; + font-style: italic; + } + + button { + margin-top: 8px; + } +} + +.upload-section { + margin-top: 24px; + padding: 16px; + border-radius: 4px; + background-color: rgba(0, 0, 0, 0.04); + text-align: center; + + h3 { + margin-top: 0; + margin-bottom: 16px; + } +} + +:host-context(.dark-theme) { + small { + color: rgba(255, 255, 255, 0.6); + } + + .file-api-card { + border-color: rgba(255, 255, 255, 0.12); + + &:hover { + background-color: rgba(255, 255, 255, 0.04); + } + } + + .file-api-type { + color: rgba(255, 255, 255, 0.6); + } + + .loading-container div, + .empty-directory { + color: rgba(255, 255, 255, 0.6); + } + + .selected-row { + background-color: rgba(103, 121, 221, 0.15); + } + + .upload-section { + background-color: rgba(255, 255, 255, 0.04); + } +} \ No newline at end of file diff --git a/src/app/shared/components/df-file-selector/df-file-selector-dialog.component.ts b/src/app/shared/components/df-file-selector/df-file-selector-dialog.component.ts new file mode 100644 index 00000000..517961f6 --- /dev/null +++ b/src/app/shared/components/df-file-selector/df-file-selector-dialog.component.ts @@ -0,0 +1,554 @@ +import { Component, Inject, OnInit, ViewChild, ElementRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTabsModule } from '@angular/material/tabs'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { TranslocoPipe } from '@ngneat/transloco'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faFolderOpen, faFile, faArrowLeft, faUpload } from '@fortawesome/free-solid-svg-icons'; +import { FileApiInfo, SelectedFile } from './df-file-selector.component'; +import { HttpClient } from '@angular/common/http'; +import { FileApiService } from '../../services/df-file-api.service'; + +// Simple dialog for creating a new folder +@Component({ + selector: 'df-create-folder-dialog', + template: ` +

Create New Folder

+ + + Folder Name + + + + + + + + `, + styles: [` + .full-width { width: 100%; } + `], + standalone: true, + imports: [ + MatDialogModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + FormsModule, + CommonModule + ] +}) +export class CreateFolderDialogComponent { + folderName: string = ''; + + constructor( + public dialogRef: MatDialogRef + ) {} + + onCancel(): void { + this.dialogRef.close(); + } + + onConfirm(): void { + this.dialogRef.close(this.folderName); + } +} + +interface FileItem { + name: string; + path: string; + type: 'file' | 'folder'; + contentType?: string; + lastModified?: string; + size?: number; +} + +interface DialogData { + fileApis: FileApiInfo[]; + allowedExtensions: string[]; + uploadMode?: boolean; + fileToUpload?: File; +} + +@UntilDestroy({ checkProperties: true }) +@Component({ + selector: 'df-file-selector-dialog', + templateUrl: './df-file-selector-dialog.component.html', + styleUrls: ['./df-file-selector-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatTabsModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatProgressSpinnerModule, + MatIconModule, + MatTableModule, + MatTooltipModule, + FormsModule, + ReactiveFormsModule, + TranslocoPipe, + FontAwesomeModule + ] +}) +export class DfFileSelectorDialogComponent implements OnInit { + // Reference to the file input element + @ViewChild('fileUploadInput') fileUploadInput!: ElementRef; + + faFolderOpen = faFolderOpen; + faFile = faFile; + faArrowLeft = faArrowLeft; + faUpload = faUpload; + + selectedFileApi: FileApiInfo | null = null; + currentPath: string = ''; + files: FileItem[] = []; + navigationStack: string[] = []; + isLoading = false; + uploadInProgress = false; + + displayedColumns: string[] = ['name', 'type', 'actions']; + + selectedFile: FileItem | null = null; + + constructor( + private dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: DialogData, + private dialog: MatDialog, + private http: HttpClient, + private fileApiService: FileApiService + ) { } + + ngOnInit(): void { + // If we're in upload mode, start by showing the file APIs + if (this.data.uploadMode && this.data.fileApis.length > 0) { + this.selectFileApi(this.data.fileApis[0]); + } + } + + selectFileApi(fileApi: FileApiInfo): void { + this.selectedFileApi = fileApi; + this.currentPath = ''; + this.navigationStack = []; + this.loadFiles(); + } + + loadFiles(): void { + if (!this.selectedFileApi) return; + + this.isLoading = true; + + // Use the FileApiService instead of direct HTTP calls + this.fileApiService.listFiles(this.selectedFileApi.name, this.currentPath) + .pipe(untilDestroyed(this)) + .subscribe({ + next: (response) => { + this.isLoading = false; + + // Check if response contains an error message from our error handling + if (response.error) { + console.warn('File listing contained error:', response.error); + // Handle specific error conditions silently + if (response.error.includes('Internal Server Error')) { + console.log('Server error encountered, showing empty directory'); + this.files = []; + return; + } + } + + // Format depends on file service type + // Typically, the response is either an array of files or has a resource property + let fileList: any[] = []; + + if (Array.isArray(response)) { + fileList = response; + } else if (response.resource && Array.isArray(response.resource)) { + fileList = response.resource; + } + + this.files = fileList.map(file => ({ + name: file.name || (file.path ? file.path.split('/').pop() : ''), + path: file.path || ((this.currentPath ? this.currentPath + '/' : '') + file.name).replace('//', '/'), + type: file.type === 'folder' ? 'folder' : 'file', + contentType: file.content_type || file.contentType, + lastModified: file.last_modified || file.lastModified, + size: file.size + })); + + console.log('Processed files:', this.files); + }, + error: (err: any) => { + console.error('Error loading files:', err); + this.files = []; // Empty array instead of undefined + + // Provide a more specific error message based on the error + let errorMsg = 'Failed to load files. '; + + if (err.status === 500) { + errorMsg += 'The server encountered an internal error. Using empty directory view.'; + // We just show an empty directory without alert for 500 errors + console.warn(errorMsg); + } else if (err.status === 404) { + errorMsg += 'The specified folder does not exist.'; + alert(errorMsg); + } else if (err.status === 403 || err.status === 401) { + errorMsg += 'You do not have permission to access this location.'; + alert(errorMsg); + } else { + errorMsg += 'Please check your connection and try again.'; + alert(errorMsg); + } + + this.isLoading = false; + } + }); + } + + openFolder(file: FileItem): void { + this.navigationStack.push(this.currentPath); + this.currentPath = file.path; + this.loadFiles(); + } + + navigateBack(): void { + if (this.navigationStack.length > 0) { + this.currentPath = this.navigationStack.pop() || ''; + this.loadFiles(); + } else if (this.selectedFileApi) { + // Go back to file API selection + this.selectedFileApi = null; + this.files = []; + } + } + + selectFile(file: FileItem): void { + // Check if file extension is allowed + const fileExt = '.' + file.name.split('.').pop()?.toLowerCase(); + if (!this.data.allowedExtensions.includes(fileExt)) { + alert(`Only ${this.data.allowedExtensions.join(', ')} files are allowed.`); + return; + } + + this.selectedFile = file; + } + + confirmSelection(): void { + if (!this.selectedFile || !this.selectedFileApi) return; + + // Store reference to avoid null checks later + const fileApi = this.selectedFileApi; + const sourcePath = this.selectedFile.path; + + // Get the base storage path for the file service + // For local file service, the base path should be '/opt/dreamfactory/storage/app/' + const baseStoragePath = '/opt/dreamfactory/storage/app/'; + + // Create result with proper path based on the current selection + const result: SelectedFile = { + // Provide both the relative path and the absolute path with storage root + path: baseStoragePath + this.selectedFile.path, + relativePath: this.selectedFile.path, + fileName: this.selectedFile.name, + name: this.selectedFile.name, + serviceId: fileApi.id, + serviceName: fileApi.name + }; + + console.log('Selected file with absolute path:', result); + + // Return the selected file directly + this.dialogRef.close(result); + } + + // Upload a file in the current path + uploadFileDirectly(file: File): void { + if (!this.selectedFileApi) { + alert('Please select a file service first.'); + return; + } + + this.uploadInProgress = true; + + // Store reference to avoid null checks later + const fileApi = this.selectedFileApi; + + // Use the current path for upload + const uploadPath = this.currentPath; + + // Upload to the current path + this.performUpload(file, uploadPath); + } + + // Helper method to perform the actual upload + private performUpload(file: File, path: string): void { + if (!this.selectedFileApi) { + this.uploadInProgress = false; + return; + } + + this.uploadInProgress = true; + + // Store reference to avoid null checks later + const fileApi = this.selectedFileApi; + + console.log(`Starting upload of ${file.name} (${file.size} bytes)`); + + this.fileApiService.uploadFile( + fileApi.name, + file, + path + ) + .pipe(untilDestroyed(this)) + .subscribe({ + next: (response) => { + this.uploadInProgress = false; + console.log('Upload successful:', response); + + // Determine the relative path to the uploaded file + const relativePath = path ? `${path}/${file.name}` : file.name; + + // Get the base storage path for the file service + const baseStoragePath = '/opt/dreamfactory/storage/app/'; + + // Create result with uploaded file info + const result: SelectedFile = { + path: baseStoragePath + relativePath, + relativePath: relativePath, + fileName: file.name, + name: file.name, + serviceId: fileApi.id, + serviceName: fileApi.name + }; + + console.log('File uploaded successfully, returning:', result); + + // Reload files to show the newly uploaded file + this.loadFiles(); + + // Automatically select the uploaded file + setTimeout(() => { + const uploadedFile = this.files.find(f => f.name === file.name); + if (uploadedFile) { + this.selectedFile = uploadedFile; + } + }, 500); + }, + error: (err: any) => { + console.error('Error uploading file:', err); + this.uploadInProgress = false; + + let errorMsg = 'Failed to upload file. '; + + if (err.status === 400) { + errorMsg += 'Bad request - check if the file type is allowed or if the file is too large.'; + } else if (err.status === 401 || err.status === 403) { + errorMsg += 'Permission denied - you may not have access to upload to this location.'; + } else if (err.status === 404) { + errorMsg += 'The specified folder does not exist.'; + } else if (err.status === 413) { + errorMsg += 'The file is too large.'; + } else if (err.status === 500) { + errorMsg += err.error?.error?.message || 'Server error occurred.'; + } else { + errorMsg += 'Please try again.'; + } + + alert(errorMsg); + } + }); + } + + // For files selected via the upload button in the main component + uploadFile(): void { + if (!this.data.fileToUpload || !this.selectedFileApi) return; + + this.uploadInProgress = true; + + // Store reference to avoid null checks later + const fileApi = this.selectedFileApi; + + // Use the current path for upload + const uploadPath = this.currentPath; + + // Upload to the current path + this.performUploadAndClose(this.data.fileToUpload, uploadPath); + } + + // Helper method to perform the upload and close the dialog + private performUploadAndClose(file: File, path: string): void { + if (!this.selectedFileApi) { + this.uploadInProgress = false; + return; + } + + this.uploadInProgress = true; + + // Store reference to avoid null checks later + const fileApi = this.selectedFileApi; + + console.log(`Starting upload of ${file.name} (${file.size} bytes)`); + + this.fileApiService.uploadFile( + fileApi.name, + file, + path + ) + .pipe(untilDestroyed(this)) + .subscribe({ + next: (response) => { + this.uploadInProgress = false; + console.log('Upload successful:', response); + + // Determine the relative path to the uploaded file + const relativePath = path ? `${path}/${file.name}` : file.name; + + // Get the base storage path for the file service + const baseStoragePath = '/opt/dreamfactory/storage/app/'; + + // Create result with uploaded file info including absolute path + const result: SelectedFile = { + path: baseStoragePath + relativePath, + relativePath: relativePath, + fileName: file.name, + name: file.name, + serviceId: fileApi.id, + serviceName: fileApi.name + }; + + console.log('File uploaded successfully, returning with absolute path:', result); + this.dialogRef.close(result); + }, + error: (err: any) => { + console.error('Error uploading file:', err); + this.uploadInProgress = false; + + let errorMsg = 'Failed to upload file. '; + + if (err.status === 400) { + errorMsg += 'Bad request - check if the file type is allowed or if the file is too large.'; + } else if (err.status === 401 || err.status === 403) { + errorMsg += 'Permission denied - you may not have access to upload to this location.'; + } else if (err.status === 404) { + errorMsg += 'The specified folder does not exist.'; + } else if (err.status === 413) { + errorMsg += 'The file is too large.'; + } else if (err.status === 500) { + errorMsg += err.error?.error?.message || 'Server error occurred.'; + } else { + errorMsg += 'Please try again.'; + } + + alert(errorMsg); + } + }); + } + + // Show dialog to create a new folder + showCreateFolderDialog(): void { + const dialogRef = this.dialog.open(CreateFolderDialogComponent, { + width: '350px' + }); + + dialogRef.afterClosed().subscribe(folderName => { + if (folderName && this.selectedFileApi) { + this.createFolder(folderName); + } + }); + } + + // Create a new folder in the current path + createFolder(folderName: string): void { + if (!this.selectedFileApi) return; + + this.isLoading = true; + + this.fileApiService.createDirectory( + this.selectedFileApi.name, + this.currentPath, + folderName + ) + .pipe(untilDestroyed(this)) + .subscribe({ + next: () => { + console.log('Folder created successfully'); + // Reload files to show the new folder + this.loadFiles(); + }, + error: (err: any) => { + console.error('Error creating folder:', err); + alert('Failed to create folder. Please try again.'); + this.isLoading = false; + } + }); + } + + cancel(): void { + this.dialogRef.close(); + } + + // Trigger the file input click programmatically + triggerFileUpload(): void { + if (this.fileUploadInput) { + this.fileUploadInput.nativeElement.click(); + } + } + + // Handle file selection from the input element + handleFileUpload(event: Event): void { + const input = event.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + const file = input.files[0]; + + // Log detailed information about the file + console.log(`File selected: ${file.name}`); + console.log(`File size: ${file.size} bytes`); + console.log(`File type: ${file.type}`); + + // Check file extension to identify sensitive key files + const isPEMFile = file.name.endsWith('.pem') || file.name.endsWith('.p8') || file.name.endsWith('.key'); + + if (isPEMFile) { + console.log('Handling private key file with special care for Snowflake authentication'); + } + + // Read the file content to verify it's not empty + const reader = new FileReader(); + reader.onload = (e) => { + const content = e.target?.result; + console.log(`File content read successfully, content length: ${content ? (content as ArrayBuffer).byteLength : 0} bytes`); + + // Continue with file validation + // Validate file extension + const extension = '.' + file.name.split('.').pop()?.toLowerCase(); + if (!this.data.allowedExtensions.includes(extension)) { + alert(`Only ${this.data.allowedExtensions.join(', ')} files are allowed`); + return; + } + + // Upload the file directly with verified content + this.uploadFileDirectly(file); + }; + + reader.onerror = (e) => { + console.error('Error reading file:', e); + alert('Error reading file content. Please try again with another file.'); + }; + + // Start reading the file as an array buffer + reader.readAsArrayBuffer(file); + } + } +} \ No newline at end of file diff --git a/src/app/shared/components/df-file-selector/df-file-selector.component.html b/src/app/shared/components/df-file-selector/df-file-selector.component.html new file mode 100644 index 00000000..135d61fd --- /dev/null +++ b/src/app/shared/components/df-file-selector/df-file-selector.component.html @@ -0,0 +1,60 @@ +
+
+ {{ label }} +
+
+ +
+
+
+ +
+ +
+ No file services configured. Contact your administrator. +
+
+ +
+
+ + +
+
{{ selectedFile.fileName || selectedFile.name }}
+ + +
+ Service: {{ selectedFile.serviceName }} +
+ + +
+
Full Absolute Path:
+
+
{{ selectedFile.path }}
+
+ + +
+ Service Relative Path: + {{ selectedFile.relativePath }} +
+
+
+
+ +
+ + + +
+
+
+
\ No newline at end of file diff --git a/src/app/shared/components/df-file-selector/df-file-selector.component.scss b/src/app/shared/components/df-file-selector/df-file-selector.component.scss new file mode 100644 index 00000000..49298f34 --- /dev/null +++ b/src/app/shared/components/df-file-selector/df-file-selector.component.scss @@ -0,0 +1,229 @@ +.file-selector-container { + width: 100%; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + padding: 16px; + margin-bottom: 16px; +} + +.file-selector-header { + margin-bottom: 16px; +} + +.file-selector-label { + font-size: 16px; + font-weight: 500; + margin-right: 8px; +} + +.file-selector-description { + font-size: 14px; + color: rgba(0, 0, 0, 0.6); + + a { + color: #3f51b5; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.file-selector-content { + width: 100%; +} + +.file-selector-empty { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 0; +} + +.file-selector-actions { + display: flex; + justify-content: center; + margin-bottom: 16px; +} + +.select-file-button { + padding: 8px 24px; + font-size: 14px; + + fa-icon { + margin-right: 8px; + } +} + +.file-selector-selected { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + background-color: rgba(0, 0, 0, 0.04); + border-radius: 4px; +} + +.selected-file-info { + display: flex; + align-items: center; + gap: 12px; +} + +.file-icon { + font-size: 24px; + color: #3f51b5; +} + +.file-details { + display: flex; + flex-direction: column; +} + +.file-name { + font-weight: 500; + margin-bottom: 4px; +} + +.file-path-container { + margin-top: 12px; + padding: 4px; + border-radius: 4px; +} + +.file-path-header { + font-weight: 600; + margin-bottom: 6px; + font-size: 15px; + color: rgba(0, 0, 0, 0.87); +} + +.file-path-section { + display: flex; + margin-bottom: 8px; + flex-wrap: wrap; + padding: 12px; + background-color: rgba(0, 0, 0, 0.05); + border-radius: 4px; + border: 1px solid rgba(0, 0, 0, 0.15); + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05); +} + +.file-path-label { + font-weight: 600; + margin-right: 8px; + color: rgba(0, 0, 0, 0.87); + font-size: 14px; +} + +.file-path-value { + font-size: 14px; + color: rgba(0, 0, 0, 0.87); + word-break: break-all; + flex: 1; + font-family: monospace; + background-color: rgba(255, 255, 255, 0.5); + padding: 4px 8px; + border-radius: 3px; + border: 1px solid rgba(0, 0, 0, 0.1); +} + +.file-service { + font-size: 12px; + color: rgba(0, 0, 0, 0.87); +} + +.file-actions { + display: flex; + gap: 12px; + align-items: center; +} + +.clear-button { + background: none; + border: none; + color: #f44336; + cursor: pointer; + font-size: 14px; + padding: 0; + font-weight: 500; + + &:hover { + text-decoration: underline; + } +} + +.no-apis-message { + color: rgba(0, 0, 0, 0.6); + font-style: italic; +} + +.relative-path-section { + display: flex; + margin-top: 6px; + font-size: 12px; + color: rgba(0, 0, 0, 0.6); +} + +.relative-path-label { + font-weight: 600; + margin-right: 8px; +} + +.relative-path-value { + font-family: monospace; +} + +/* Dark theme adjustments */ +:host-context(.dark-theme) { + .file-selector-container { + border-color: rgba(255, 255, 255, 0.12); + } + + .file-selector-description, + .no-apis-message { + color: rgba(255, 255, 255, 0.6); + + a { + color: #9fa8da; + } + } + + .file-name, + .file-service { + color: rgba(255, 255, 255, 0.87); + } + + .file-path-header { + color: rgba(255, 255, 255, 0.9); + } + + .file-selector-selected { + background-color: rgba(255, 255, 255, 0.04); + } + + .clear-button { + color: #ef9a9a; + } + + .file-path-section { + background-color: rgba(255, 255, 255, 0.07); + border-color: rgba(255, 255, 255, 0.15); + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2); + } + + .file-path-label { + color: rgba(255, 255, 255, 0.9); + } + + .file-path-value { + color: rgba(255, 255, 255, 0.9); + background-color: rgba(0, 0, 0, 0.2); + border-color: rgba(255, 255, 255, 0.1); + } + + .relative-path-section { + color: rgba(255, 255, 255, 0.6); + } +} \ No newline at end of file diff --git a/src/app/shared/components/df-file-selector/df-file-selector.component.ts b/src/app/shared/components/df-file-selector/df-file-selector.component.ts new file mode 100644 index 00000000..59413ab3 --- /dev/null +++ b/src/app/shared/components/df-file-selector/df-file-selector.component.ts @@ -0,0 +1,195 @@ +import { Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatDialogModule, MatDialog } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { TranslocoPipe } from '@ngneat/transloco'; +import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faFile, faFolderOpen, faCheck, faUpload } from '@fortawesome/free-solid-svg-icons'; +import { DfFileSelectorDialogComponent } from './df-file-selector-dialog.component'; +import { FileApiService } from '../../services/df-file-api.service'; +import { BASE_SERVICE_TOKEN } from '../../constants/tokens'; +import { DfBaseCrudService } from '../../services/df-base-crud.service'; +import { GenericListResponse } from '../../types/generic-http'; +import { MatIconModule } from '@angular/material/icon'; + +export interface FileApiInfo { + id: number; + name: string; + label: string; + type: string; +} + +export interface SelectedFile { + path: string; // Full absolute path for the Snowflake config (includes storage root) + relativePath?: string; // The relative path within the file service + fileName: string; // Just the filename + name?: string; // Alias for fileName for template compatibility + serviceId: number; // The ID of the file service + serviceName: string; // The name of the file service +} + +@UntilDestroy({ checkProperties: true }) +@Component({ + selector: 'df-file-selector', + templateUrl: './df-file-selector.component.html', + styleUrls: ['./df-file-selector.component.scss'], + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + FormsModule, + ReactiveFormsModule, + TranslocoPipe, + MatTooltipModule, + FontAwesomeModule, + MatIconModule + ] +}) +export class DfFileSelectorComponent implements OnInit { + @Input() label: string = 'Private Key File'; + @Input() description: string = ''; + @Input() allowedExtensions: string[] = ['.pem', '.p8', '.key']; + @Input() initialValue: string = ''; + @Output() fileSelected = new EventEmitter(); + + faFile = faFile; + faFolderOpen = faFolderOpen; + faCheck = faCheck; + faUpload = faUpload; + + selectedFile: SelectedFile | undefined = undefined; + fileApis: FileApiInfo[] = []; + isLoading = false; + + constructor( + private dialog: MatDialog, + private fileApiService: FileApiService + ) {} + + ngOnInit(): void { + this.loadFileApis(); + + // If initialValue is set, try to parse it + if (this.initialValue) { + this.parseInitialValue(); + } + + // Create a fallback service entry immediately, in case the API call takes too long + // or fails completely + this.ensureFallbackService(); + } + + // Ensure there's always at least one file service available + private ensureFallbackService(): void { + if (this.fileApis.length === 0) { + console.log('Creating fallback file service entry'); + this.fileApis = [{ + id: 1, + name: 'files', + label: 'Local Files', + type: 'local_file' + }]; + } + } + + loadFileApis(): void { + this.isLoading = true; + + // Ensure fallback is in place immediately + this.ensureFallbackService(); + + // Use the FileApiService to get the list of file services + this.fileApiService.getFileServices() + .pipe(untilDestroyed(this)) + .subscribe({ + next: (response: { resource: FileApiInfo[] }) => { + if (response && response.resource && response.resource.length > 0) { + this.fileApis = response.resource; + } else { + // If we get an empty or invalid response, ensure fallback + this.ensureFallbackService(); + } + this.isLoading = false; + }, + error: (error: any) => { + console.error('Error loading file APIs:', error); + + // Ensure fallback on error + this.ensureFallbackService(); + this.isLoading = false; + } + }); + } + + openFileSelector(): void { + // Ensure fallback before opening dialog + this.ensureFallbackService(); + + const dialogRef = this.dialog.open(DfFileSelectorDialogComponent, { + width: '800px', + data: { + fileApis: this.fileApis, + allowedExtensions: this.allowedExtensions + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.selectedFile = result; + this.fileSelected.emit(this.selectedFile); + } + }); + } + + clearSelection(): void { + this.selectedFile = undefined; + this.fileSelected.emit(undefined); + } + + // Update the parseInitialValue method to accept an optional path parameter + private parseInitialValue(providedPath?: string): void { + // Attempt to parse the initial value if it's provided + try { + const pathToUse = providedPath || this.initialValue; + + if (pathToUse) { + console.log('Parsing path value:', pathToUse); + + // Extract the filename from the path + const parts = pathToUse.split('/'); + const fileName = parts[parts.length - 1]; + + // We don't have full information but we can set what we know + this.selectedFile = { + path: pathToUse, + fileName: fileName, + name: fileName, // Add name property for template compatibility + serviceId: 0, // Unknown + serviceName: 'Unknown' + }; + + console.log('Generated selected file:', this.selectedFile); + } + } catch (e) { + console.error('Failed to parse path value:', e); + } + } + + // Add this method to set the path externally if needed + setPath(path: string): void { + if (path) { + console.log('Setting path manually:', path); + this.parseInitialValue(path); + } + } +} \ No newline at end of file diff --git a/src/app/shared/services/df-file-api.service.ts b/src/app/shared/services/df-file-api.service.ts new file mode 100644 index 00000000..6edf8ba5 --- /dev/null +++ b/src/app/shared/services/df-file-api.service.ts @@ -0,0 +1,497 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpEventType } from '@angular/common/http'; +import { Observable, catchError, map, tap, filter } from 'rxjs'; +import { DfUserDataService } from './df-user-data.service'; +import { SESSION_TOKEN_HEADER } from '../constants/http-headers'; + +export interface GenericListResponse { + resource: T[]; + meta?: { + count: number; + limit: number; + offset: number; + }; +} + +export interface FileService { + id: number; + name: string; + label: string; + type: string; +} + +export interface FileItem { + name: string; + path: string; + type: string; + contentType?: string; + lastModified?: string; + size?: number; +} + +@Injectable({ + providedIn: 'root' +}) +export class FileApiService { + // Array of service names that should be excluded from file selection + private excludedServices = ['logs', 'log']; + + constructor( + private http: HttpClient, + private userDataService: DfUserDataService + ) { } + + /** + * Check if a file service should be included in the selector + * @param service The file service to check + * @returns True if the service should be included, false otherwise + */ + private isSelectableFileService(service: FileService): boolean { + // Exclude services with names containing 'log' + if (this.excludedServices.some(exclude => + service.name.toLowerCase().includes(exclude) || + service.label.toLowerCase().includes(exclude))) { + return false; + } + + return true; + } + + /** + * Get the HTTP headers for authenticated requests + */ + private getHeaders() { + const headers: Record = {}; + const token = this.userDataService.token; + + if (token) { + headers[SESSION_TOKEN_HEADER] = token; + } + + console.log('Auth headers:', headers); + return headers; + } + + /** + * Get a list of all file services + */ + getFileServices(): Observable> { + console.log('Getting file services, session token:', this.userDataService.token); + + // Default hardcoded services to use as fallback + const defaultServices: GenericListResponse = { + resource: [ + { + id: 3, + name: 'files', + label: 'Local File Storage', + type: 'local_file' + } + ] + }; + + // If no session token, immediately return the default services + if (!this.userDataService.token) { + console.warn('No session token available, using hardcoded file services'); + return new Observable>(observer => { + observer.next(defaultServices); + observer.complete(); + }); + } + + // Create an observable that will immediately emit the default services + // This ensures we always have something to return, even if the HTTP request fails + return new Observable>(observer => { + // First emit the default services to ensure the UI is responsive + observer.next(defaultServices); + + // Then try to get the actual services from the API + // Using direct URL format that matches the server expectation + // Notice the format change from filter[type] to filter=type= + this.http.get>('api/v2/system/service', { + params: { + 'filter': 'type=local_file', + 'fields': 'id,name,label,type' + }, + headers: this.getHeaders() + }).pipe( + map(response => { + if (!response || !response.resource || !Array.isArray(response.resource)) { + console.warn('Invalid response format from API, using default services'); + return defaultServices; + } + + // Filter out non-selectable services + response.resource = response.resource.filter(service => + this.isSelectableFileService(service) + ); + + // If no services are left after filtering, use defaults + if (response.resource.length === 0) { + console.warn('No valid file services found in API response, using defaults'); + return defaultServices; + } + + return response; + }), + catchError(error => { + console.error('Error fetching file services:', error); + console.warn('API call failed, using default file services'); + return new Observable>(innerObserver => { + innerObserver.next(defaultServices); + innerObserver.complete(); + }); + }) + ).subscribe({ + next: (apiResponse) => { + // Only emit if different from the default (to avoid duplicate emissions) + if (JSON.stringify(apiResponse) !== JSON.stringify(defaultServices)) { + observer.next(apiResponse); + } + observer.complete(); + }, + error: () => { + // In case of any unexpected error, complete the observable + observer.complete(); + } + }); + }); + } + + /** + * List files in a directory + * @param serviceName The name of the file service + * @param path The path to list (optional) + */ + listFiles(serviceName: string, path: string = ''): Observable { + // Return empty list if service name is missing + if (!serviceName) { + console.warn('No service name provided for listFiles, returning empty list'); + return new Observable(observer => { + observer.next({ resource: [] }); + observer.complete(); + }); + } + + const url = path ? `api/v2/${serviceName}/${path}` : `api/v2/${serviceName}`; + console.log(`Listing files from ${url}`); + + // Set specific parameters for file listing + const params: Record = {}; + // Ask for content-type to help identify file types + params['include_properties'] = 'content_type'; + // Add standard fields + params['fields'] = 'name,path,type,content_type,last_modified,size'; + + return this.http.get(url, { + headers: this.getHeaders(), + params: params + }).pipe( + tap(response => console.log('Files response:', response)), + catchError(error => { + console.error(`Error fetching files from ${url}:`, error); + + // Create a helpful message based on the error + let errorMessage = 'Error loading files. '; + + if (error.status === 500) { + errorMessage += 'The server encountered an internal error. This might be a temporary issue.'; + } else if (error.status === 404) { + errorMessage += 'The specified folder does not exist.'; + } else if (error.status === 403 || error.status === 401) { + errorMessage += 'You do not have permission to access this location.'; + } else { + errorMessage += 'Please check your connection and try again.'; + } + + // Log the error message for debugging + console.warn(errorMessage); + + // Return an empty resource array to avoid UI errors + return new Observable(observer => { + observer.next({ + resource: [], + error: errorMessage + }); + observer.complete(); + }); + }) + ); + } + + /** + * Upload a file + * @param serviceName The name of the file service + * @param file The file to upload + * @param path The path to upload to (optional) + */ + uploadFile(serviceName: string, file: File, path: string = ''): Observable { + // Build the URL properly including the filename + let url: string; + if (path) { + // Ensure path doesn't end with slash and append filename + const cleanPath = path.replace(/\/$/, ''); + url = `api/v2/${serviceName}/${cleanPath}/${file.name}`; + } else { + url = `api/v2/${serviceName}/${file.name}`; + } + + console.log(`Uploading file: ${file.name}, size: ${file.size} bytes, type: ${file.type}`); + console.log(`To URL: ${url}`); + + // Check if this is a private key file that needs special handling + const isPEMFile = file.name.endsWith('.pem') || file.name.endsWith('.p8') || file.name.endsWith('.key'); + + if (isPEMFile) { + console.log('Detected private key file - using binary upload method for proper content preservation'); + return this.uploadBinaryFile(url, file); + } + + // Get authentication headers + const headers = this.getHeaders(); + + // Use a more direct XMLHttpRequest approach with explicit binary handling + return new Observable(observer => { + // Create a new XMLHttpRequest + const xhr = new XMLHttpRequest(); + + // Set up progress tracking + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percentDone = Math.round(100 * event.loaded / event.total); + console.log(`Upload progress: ${percentDone}%`); + observer.next({ type: 'progress', progress: percentDone }); + } + }; + + // Handle various events + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + let response; + try { + response = JSON.parse(xhr.responseText); + } catch (e) { + response = xhr.responseText; + } + console.log('Upload complete with response:', response); + observer.next(response); + observer.complete(); + } else { + let errorResponse; + try { + errorResponse = JSON.parse(xhr.responseText); + } catch (e) { + errorResponse = { error: xhr.statusText }; + } + console.error(`Error uploading file: ${xhr.status} ${xhr.statusText}`, errorResponse); + observer.error({ status: xhr.status, error: errorResponse }); + } + }; + + xhr.onerror = () => { + console.error('Network error during file upload'); + observer.error({ status: 0, error: 'Network error during file upload' }); + }; + + xhr.ontimeout = () => { + console.error('Timeout during file upload'); + observer.error({ status: 408, error: 'Request timeout' }); + }; + + // Open the request (POST for file upload) + xhr.open('POST', url, true); + + // Add authentication and other needed headers + Object.keys(headers).forEach(key => { + xhr.setRequestHeader(key, headers[key]); + }); + + // Create FormData for the file + const formData = new FormData(); + formData.append('file', file); + + // Log the file size again right before sending + console.log(`Sending file with size: ${file.size} bytes`); + + // Send the request with the file data + xhr.send(formData); + + // Return an unsubscribe function + return () => { + if (xhr && xhr.readyState !== 4) { + xhr.abort(); + } + }; + }); + } + + /** + * Upload a binary file (like PEM, P8, or private key files) using binary transmission + * to ensure content is preserved properly + * @param url The URL to upload to + * @param file The file to upload as binary + */ + private uploadBinaryFile(url: string, file: File): Observable { + console.log(`Uploading binary file: ${file.name}, size: ${file.size} bytes`); + + // Get authentication headers + const headers = this.getHeaders(); + + return new Observable(observer => { + // First read the file + const reader = new FileReader(); + + reader.onload = (event) => { + const content = event.target?.result; + + if (!content) { + observer.error({ status: 500, error: 'Failed to read file content' }); + return; + } + + console.log(`File content read successfully, content length: ${(content as ArrayBuffer).byteLength} bytes`); + + // Create a new XMLHttpRequest for binary upload + const xhr = new XMLHttpRequest(); + + // Set up progress tracking + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const percentDone = Math.round(100 * event.loaded / event.total); + console.log(`Upload progress: ${percentDone}%`); + observer.next({ type: 'progress', progress: percentDone }); + } + }; + + // Handle various events + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + let response; + try { + response = JSON.parse(xhr.responseText); + } catch (e) { + response = xhr.responseText; + } + console.log('Binary upload complete with response:', response); + observer.next(response); + observer.complete(); + } else { + let errorResponse; + try { + errorResponse = JSON.parse(xhr.responseText); + } catch (e) { + errorResponse = { error: xhr.statusText }; + } + console.error(`Error uploading binary file: ${xhr.status} ${xhr.statusText}`, errorResponse); + observer.error({ status: xhr.status, error: errorResponse }); + } + }; + + xhr.onerror = () => { + console.error('Network error during binary file upload'); + observer.error({ status: 0, error: 'Network error during binary file upload' }); + }; + + xhr.ontimeout = () => { + console.error('Timeout during binary file upload'); + observer.error({ status: 408, error: 'Request timeout' }); + }; + + // Open the request (POST for file upload) + xhr.open('POST', url, true); + + // Add authentication headers + Object.keys(headers).forEach(key => { + xhr.setRequestHeader(key, headers[key]); + }); + + // Add content type header for binary data + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + + // Send the binary data directly + xhr.send(content); + + // Return an unsubscribe function + return () => { + if (xhr && xhr.readyState !== 4) { + xhr.abort(); + } + }; + }; + + reader.onerror = (error) => { + console.error('Error reading file:', error); + observer.error({ status: 500, error: 'Failed to read file: ' + (error.target?.error?.message || 'Unknown error') }); + }; + + // Read the file as an ArrayBuffer (binary content) + reader.readAsArrayBuffer(file); + }); + } + + /** + * Get file content + * @param serviceName The name of the file service + * @param path The path to the file + */ + getFileContent(serviceName: string, path: string): Observable { + const url = `api/v2/${serviceName}/${path}`; + console.log(`Getting file content from ${url}`); + + return this.http.get(url, { + responseType: 'blob', + headers: this.getHeaders() + }).pipe( + catchError(error => { + console.error(`Error getting file content from ${url}:`, error); + throw error; + }) + ); + } + + /** + * Delete a file + * @param serviceName The name of the file service + * @param path The path to the file + */ + deleteFile(serviceName: string, path: string): Observable { + const url = `api/v2/${serviceName}/${path}`; + console.log(`Deleting file at ${url}`); + + return this.http.delete(url, { headers: this.getHeaders() }).pipe( + tap(response => console.log('Delete response:', response)), + catchError(error => { + console.error(`Error deleting file at ${url}:`, error); + throw error; + }) + ); + } + + /** + * Create a directory + * @param serviceName The name of the file service + * @param path The path where to create the directory + * @param name The name of the directory + */ + createDirectory(serviceName: string, path: string, name: string): Observable { + const payload = { + resource: [ + { + name: name, + type: 'folder' + } + ] + }; + + const url = path ? `api/v2/${serviceName}/${path}` : `api/v2/${serviceName}`; + console.log(`Creating directory at ${url}`, payload); + + return this.http.post(url, payload, { headers: this.getHeaders() }).pipe( + tap(response => console.log('Create directory response:', response)), + catchError(error => { + console.error(`Error creating directory at ${url}:`, error); + throw error; + }) + ); + } +} \ No newline at end of file diff --git a/src/app/shared/types/service.ts b/src/app/shared/types/service.ts index 7ef3205e..ad41e4db 100644 --- a/src/app/shared/types/service.ts +++ b/src/app/shared/types/service.ts @@ -21,6 +21,7 @@ export interface ConfigSchema { | 'picklist' | 'multi_picklist' | 'file_certificate' + | 'file_certificate_api' | 'verb_mask' | 'event_picklist'; description?: string;