diff --git a/src/app/adf-services/df-service-details/df-service-details.component.ts b/src/app/adf-services/df-service-details/df-service-details.component.ts
index 58695482..e6dfb191 100644
--- a/src/app/adf-services/df-service-details/df-service-details.component.ts
+++ b/src/app/adf-services/df-service-details/df-service-details.component.ts
@@ -27,6 +27,7 @@ import { TranslocoPipe } from '@ngneat/transloco';
import { DfArrayFieldComponent } from 'src/app/shared/components/df-field-array/df-array-field.component';
import { DfDynamicFieldComponent } from 'src/app/shared/components/df-dynamic-field/df-dynamic-field.component';
import { DfAceEditorComponent } from 'src/app/shared/components/df-ace-editor/df-ace-editor.component';
+import { DfSecurityConfigComponent } from 'src/app/shared/components/df-security-config/df-security-config.component';
import { ConfigSchema, ServiceType } from 'src/app/shared/types/service';
import {
@@ -67,10 +68,7 @@ import { MatIconModule } from '@angular/material/icon';
import { HttpClient } from '@angular/common/http';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { DfThemeService } from 'src/app/shared/services/df-theme.service';
-import {
- MatButtonToggleModule,
- MatButtonToggleGroup,
-} from '@angular/material/button-toggle';
+import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { readAsText } from '../../shared/utilities/file';
import { DfSnackbarService } from 'src/app/shared/services/df-snackbar.service';
import { BASE_URL } from 'src/app/shared/constants/urls';
@@ -86,17 +84,8 @@ import { MatCardModule } from '@angular/material/card';
import { TitleCasePipe } from '@angular/common';
import { MatDividerModule } from '@angular/material/divider';
import { DfSystemService } from 'src/app/shared/services/df-system.service';
-import { DfUserDataService } from 'src/app/shared/services/df-user-data.service';
-import { DfPaywallService } from 'src/app/shared/services/df-paywall.service';
import { DfPaywallModal } from 'src/app/shared/components/df-paywall-modal/df-paywall-modal.component';
-// Add this interface before the @Component decorator
-interface ComponentOption {
- label: string;
- value: string;
- selected: boolean;
-}
-
// Add these interfaces at the bottom of the file with the other interfaces
interface RoleResponse {
resource: Array<{
@@ -115,11 +104,6 @@ interface AppResponse {
}>;
}
-interface CategorizedField {
- basic: ConfigSchema[];
- advanced: ConfigSchema[];
-}
-
interface ServiceResponse {
resource: Array<{
id: number;
@@ -165,6 +149,7 @@ interface ServiceResponse {
MatCardModule,
TitleCasePipe,
MatDividerModule,
+ DfSecurityConfigComponent,
],
})
export class DfServiceDetailsComponent implements OnInit {
@@ -188,15 +173,7 @@ export class DfServiceDetailsComponent implements OnInit {
systemEvents: Array<{ label: string; value: string }>;
content = '';
@ViewChild('stepper') stepper!: MatStepper;
- @ViewChild('accessLevelGroup') accessLevelGroup!: MatButtonToggleGroup;
showSecurityConfig = false;
- selectedAccessType: string = '';
- selectedComponent: string = '';
- selectedAccessLevel: string = '';
- showComponentSelection = false;
- componentList: ComponentOption[] = [];
- componentSearch = '';
- selectedComponents: ComponentOption[] = [];
currentServiceId: number | null = null;
constructor(
private activatedRoute: ActivatedRoute,
@@ -824,121 +801,6 @@ export class DfServiceDetailsComponent implements OnInit {
this.serviceDefinitionType = value;
}
- onAccessTypeChange(event: any) {
- this.selectedComponent = '';
- this.showComponentSelection = this.selectedAccessType !== 'all';
- // Here you would typically load the appropriate components
- // This is just example data
- this.componentList = [
- { label: 'Component 1', value: 'comp1', selected: false },
- { label: 'Component 2', value: 'comp2', selected: false },
- { label: 'Component 3', value: 'comp3', selected: false },
- ];
- }
-
- onComponentSelect(component: any) {
- // If component is a string (from dropdown/select)
- if (typeof component === 'string') {
- this.selectedComponent = component;
- this.componentList.forEach(c => (c.selected = c.value === component));
- }
- // If component is an object (from card selection)
- else {
- this.selectedComponent = component.value;
- this.componentList.forEach(
- c => (c.selected = c.value === component.value)
- );
- }
- }
-
- isSecurityConfigValid(): boolean {
- const state = {
- accessType: this.selectedAccessType,
- accessLevel: this.selectedAccessLevel,
- component: this.selectedComponent,
- };
-
- if (this.selectedAccessType === 'all') {
- return (
- Boolean(this.selectedAccessLevel) && this.selectedComponent === '*'
- );
- }
-
- return (
- Boolean(this.selectedAccessType) &&
- Boolean(this.selectedComponent) &&
- Boolean(this.selectedAccessLevel) &&
- this.selectedComponent.includes('/*')
- ); // Ensure wildcard is present
- }
-
- selectAccessType(type: string) {
- this.selectedAccessType = type;
- this.selectedComponent = '';
- this.showComponentSelection = type !== 'all';
-
- if (type === 'all') {
- this.componentList = [];
- this.selectedComponent = '*'; // Set a default component for 'all' access
- this.selectedAccessLevel = 'full'; // Automatically set full access for 'all' type
- return;
- }
-
- const serviceName = this.serviceForm.get('name')?.value;
- switch (type) {
- case 'tables':
- this.selectedComponent = '_table/*';
- this.componentList = ['_table'].map(suffix => ({
- label: `${serviceName}${suffix}`,
- value: `${suffix}/*`,
- selected: true,
- }));
- break;
- case 'procedures':
- this.selectedComponent = '_proc/*';
- this.componentList = ['_proc'].map(suffix => ({
- label: `${serviceName}${suffix}`,
- value: `${suffix}/*`,
- selected: true,
- }));
- break;
- case 'functions':
- this.selectedComponent = '_func/*';
- this.componentList = ['_func'].map(suffix => ({
- label: `${serviceName}${suffix}`,
- value: `${suffix}/*`,
- selected: true,
- }));
- break;
- default:
- this.componentList = [];
- }
- }
-
- onAccessLevelChange(level: string) {
- this.selectedAccessLevel = level;
- }
-
- get filteredComponents() {
- return this.componentList.filter(comp =>
- comp.label.toLowerCase().includes(this.componentSearch.toLowerCase())
- );
- }
-
- isComponentSelected(component: any) {
- return component.selected;
- }
-
- onComponentSelectionChange(component: any) {
- if (component.selected) {
- this.selectedComponents.push(component);
- } else {
- this.selectedComponents = this.selectedComponents.filter(
- c => c.value !== component.value
- );
- }
- }
-
navigateToRoles(event: Event) {
event.preventDefault();
// Navigate to roles tab
@@ -1024,187 +886,6 @@ export class DfServiceDetailsComponent implements OnInit {
}
}
- saveSecurityConfig() {
- if (!this.isSecurityConfigValid()) {
- return;
- }
-
- if (!this.currentServiceId) {
- this.snackBar.open('No service ID found. Please try again.', 'Close', {
- duration: 3000,
- });
- return;
- }
-
- const serviceName = this.serviceForm.get('name')?.value;
- const formattedName = this.formatServiceName(serviceName);
- const roleName = `${serviceName}_auto_role`;
-
- const rolePayload = {
- resource: [
- {
- name: roleName,
- description: `Auto-generated role for service ${serviceName}`,
- is_active: true,
- role_service_access_by_role_id: [
- {
- service_id: this.currentServiceId,
- component: this.selectedComponent,
- verb_mask: this.getAccessLevel(this.selectedAccessLevel),
- requestor_mask: 3,
- filters: [],
- filter_op: 'AND',
- },
- ],
- user_to_app_to_role_by_role_id: [],
- },
- ],
- };
-
- // Create role and chain with app creation using proper RxJS operators
- this.systemService
- .post('role', rolePayload)
- .pipe(
- catchError(error => {
- return throwError(() => error);
- }),
- switchMap((roleResponse: any) => {
- if (!roleResponse?.resource?.[0]?.id) {
- return throwError(() => new Error('Invalid role response'));
- }
- const createdRoleId = roleResponse.resource[0].id;
-
- const appPayload = {
- resource: [
- {
- name: `${serviceName}_app`,
- description: `Auto-generated app for service ${serviceName}`,
- type: '0',
- role_id: createdRoleId,
- is_active: true,
- url: null,
- storage_service_id: null,
- storage_container: null,
- path: null,
- },
- ],
- };
-
- return this.systemService
- .post('app?fields=*&related=role_by_role_id', appPayload)
- .pipe(
- catchError(error => {
- this.snackBar.open(
- `Error creating app: ${
- error.error?.message || error.message || 'Unknown error'
- }`,
- 'Close',
- { duration: 5000 }
- );
- return throwError(() => error);
- }),
- map((appResponse: any) => {
- if (!appResponse?.resource?.[0]) {
- throw new Error('App response missing resource array');
- }
-
- const app = appResponse.resource[0];
-
- if (!app.apiKey) {
- throw new Error('App response missing apiKey');
- }
-
- return {
- apiKey: app.apiKey,
- formattedName: formattedName,
- };
- }),
- catchError(error => {
- return throwError(() => error);
- })
- );
- }),
- map((appResponse: any) => {
- if (!appResponse?.apiKey) {
- throw new Error('Invalid app response');
- }
- return {
- apiKey: appResponse.apiKey,
- formattedName: formattedName,
- };
- })
- )
- .subscribe({
- next: result => {
- // Attempt to copy API key to clipboard
- if (navigator.clipboard) {
- navigator.clipboard
- .writeText(result.apiKey)
- .then(() => {
- this.snackbarService.openSnackBar(
- 'API Created and API Key copied to clipboard',
- 'success'
- );
- })
- .catch(() => {
- this.snackbarService.openSnackBar(
- 'API Created, but failed to copy API Key',
- 'success'
- );
- });
- } else {
- this.snackbarService.openSnackBar(
- 'API Created, but failed to copy API Key',
- 'success'
- );
- }
-
- // Navigate to API docs
- this.router
- .navigateByUrl(
- `/api-connections/api-docs/${result.formattedName}`,
- {
- replaceUrl: true,
- }
- )
- .then(success => {
- if (!success) {
- this.router.navigate(
- ['api-connections', 'api-docs', result.formattedName],
- {
- replaceUrl: true,
- }
- );
- }
- });
- },
- error: error => {
- // Show error message using DfSnackbarService
- this.snackbarService.openSnackBar(
- 'Error saving security configuration',
- 'error'
- );
- },
- });
- }
-
- private getAccessLevel(level: string): number {
- switch (level) {
- case 'read':
- return 1; // GET
- case 'write':
- return 7; // GET (1) + POST (2) + PUT/PATCH (4) = 7
- case 'full':
- return 15; // All permissions (GET + POST + PUT + DELETE)
- default:
- return 0;
- }
- }
-
- onAccessLevelSelect(level: string) {
- this.selectedAccessLevel = level;
- }
-
getServiceTypeLabel(value: string): string {
const selectedType = this.serviceTypes.find(type => type.name === value);
return selectedType ? selectedType.label : value;
diff --git a/src/app/shared/components/df-api-tester/df-api-tester.component.html b/src/app/shared/components/df-api-tester/df-api-tester.component.html
new file mode 100644
index 00000000..a2da995e
--- /dev/null
+++ b/src/app/shared/components/df-api-tester/df-api-tester.component.html
@@ -0,0 +1,207 @@
+ 0">
+
+
+ Test API Authentication
+
+
+ Validate endpoint access with different authentication methods
+
+
+
+
+
+ Test your API endpoints to validate authentication and security
+ configurations.
+
+
+
+
+
+ Select Endpoint
+
+
+
+
+ {{ endpoint.title }}
+
+
+
+
+
+
+
+ Authentication Method
+
+
+
+ Session Token
+ Use current session
+
+
+
+
+ {{ key.name }}
+ {{ key.apiKey | slice: 0 : 8 }}...
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ getSelectedEndpoint()?.description }}
+
+ Endpoint:
+ {{ getSelectedEndpoint()?.endpoint }}
+ Method:
+ {{ getSelectedEndpoint()?.method }}
+ Authentication:
+ {{ getAuthenticationMethod() }}
+ Operation ID:
+ {{ getSelectedEndpoint()?.operationId }}
+
+
+
+
+
+
+
+
+
+ ✅ Authentication & Request Successful
+ 🔒 Authentication Failed
+ ✅ Authentication OK - Request Failed (Non-Auth Issue)
+
+
+
+
+
+ Status Code: {{ testResult.status }}
+
+ Result: ✅ Authentication verified and access
+ granted successfully
+
+
+ Authentication Result: 🔒 Access denied -
+ {{ testResult.error }}
+
+
+ Authentication Result: ✅ Authentication passed,
+ but request failed due to: {{ testResult.error }}
+
+
+
+
+
+
🔒 Authentication Help:
+
+ -
+ Try selecting a different API key from the dropdown above
+
+ -
+ The selected API key may not have access to this endpoint
+
+ -
+ Check if the service has proper role-based access configured
+
+ - Verify the API key is active and not expired
+
+
+
+
+
+
✅ Authentication Status: Passed
+
+ Good news! Your authentication is working
+ correctly. The request failed for other reasons:
+
+
+ -
+ The endpoint might require specific parameters or request body
+
+ - The service might be temporarily unavailable
+ - The endpoint might have validation rules that weren't met
+ - Check the error message above for specific details
+
+
+
+
+
+
+
diff --git a/src/app/shared/components/df-api-tester/df-api-tester.component.scss b/src/app/shared/components/df-api-tester/df-api-tester.component.scss
new file mode 100644
index 00000000..e1df8074
--- /dev/null
+++ b/src/app/shared/components/df-api-tester/df-api-tester.component.scss
@@ -0,0 +1,213 @@
+// API Tester Component Styles
+mat-expansion-panel-header {
+ padding: 0 12px;
+}
+.api-tester-container {
+ margin-top: 16px;
+}
+
+.description-text {
+ color: var(--df-secondary-text-color);
+ margin-bottom: 16px;
+}
+
+.test-controls {
+ display: grid;
+ grid-template-columns: 2fr 1fr auto;
+ gap: 16px;
+ align-items: end;
+ margin: 16px 0;
+ justify-content: center;
+ align-items: baseline;
+
+ @media (max-width: 768px) {
+ grid-template-columns: 1fr;
+ gap: 12px;
+ }
+}
+
+.endpoint-select,
+.api-key-select {
+ min-width: 150px;
+}
+
+.endpoint-option,
+.method-option,
+.auth-option {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.endpoint-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.method-badge {
+ font-size: 10px;
+ font-weight: bold;
+ color: white;
+ padding: 2px 6px;
+ border-radius: 4px;
+ text-transform: uppercase;
+ min-width: 45px;
+ text-align: center;
+
+ &.large {
+ font-size: 12px;
+ padding: 4px 8px;
+ min-width: 50px;
+ }
+}
+
+.endpoint-path,
+.method-name,
+.auth-name {
+ font-weight: 500;
+ font-family: monospace;
+}
+
+.endpoint-title,
+.auth-desc {
+ font-size: 0.85em;
+ color: var(--df-secondary-text-color);
+}
+
+.test-button {
+ height: 56px;
+ min-width: 120px;
+
+ fa-icon,
+ mat-spinner {
+ margin-right: 8px;
+ }
+}
+
+.endpoint-info-card {
+ margin: 16px 0;
+ background: var(--df-surface-color);
+
+ .endpoint-info-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 8px;
+
+ h4 {
+ margin: 0;
+ color: var(--df-primary-text-color);
+ }
+ }
+
+ p {
+ margin: 0 0 12px 0;
+ color: var(--df-secondary-text-color);
+ }
+
+ .test-details {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 8px;
+ font-size: 0.9em;
+
+ span {
+ color: var(--df-secondary-text-color);
+
+ strong {
+ color: var(--df-primary-text-color);
+ }
+ }
+ }
+}
+
+.test-result-card {
+ margin: 16px 0;
+
+ &.success-result {
+ border-left: 4px solid #4caf50;
+ background: rgba(76, 175, 80, 0.05);
+ }
+
+ &.error-result {
+ border-left: 4px solid #f44336;
+ background: rgba(244, 67, 54, 0.05);
+ }
+
+ &.auth-error {
+ border-left: 4px solid #f44336;
+ background: rgba(244, 67, 54, 0.05);
+ }
+
+ &.non-auth-error {
+ border-left: 4px solid #ff9800;
+ background: rgba(255, 152, 0, 0.05);
+ }
+
+ mat-card-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ mat-card-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
+ font-size: 1.1em;
+ }
+
+ .clear-result-btn {
+ opacity: 0.7;
+
+ &:hover {
+ opacity: 1;
+ }
+ }
+ }
+
+ .result-summary {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 16px;
+
+ span {
+ font-size: 0.95em;
+
+ strong {
+ color: var(--df-primary-text-color);
+ }
+ }
+ }
+
+ .auth-guidance {
+ margin-top: 16px;
+ padding: 12px;
+ background: rgba(255, 152, 0, 0.1);
+ border-radius: 4px;
+ border-left: 3px solid #ff9800;
+
+ h5 {
+ margin: 0 0 8px 0;
+ color: var(--df-primary-text-color);
+ font-size: 0.9em;
+ }
+
+ ul {
+ margin: 0;
+ padding-left: 20px;
+
+ li {
+ font-size: 0.85em;
+ color: var(--df-secondary-text-color);
+ margin-bottom: 4px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+ }
+ }
+ }
+}
diff --git a/src/app/shared/components/df-api-tester/df-api-tester.component.ts b/src/app/shared/components/df-api-tester/df-api-tester.component.ts
new file mode 100644
index 00000000..1ea45159
--- /dev/null
+++ b/src/app/shared/components/df-api-tester/df-api-tester.component.ts
@@ -0,0 +1,317 @@
+import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { HttpClient, HttpHeaders } from '@angular/common/http';
+import { MatCardModule } from '@angular/material/card';
+import { MatSelectModule } from '@angular/material/select';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatButtonModule } from '@angular/material/button';
+import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
+import { MatExpansionModule } from '@angular/material/expansion';
+import { MatIconModule } from '@angular/material/icon';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import { FormsModule } from '@angular/forms';
+import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
+import { faPlay, faCheck, faTimes } from '@fortawesome/free-solid-svg-icons';
+
+import { DfUserDataService } from 'src/app/shared/services/df-user-data.service';
+import { DfCurrentServiceService } from 'src/app/shared/services/df-current-service.service';
+import { ApiKeysService } from '../../../adf-api-docs/services/api-keys.service';
+import { ApiKeyInfo } from 'src/app/shared/types/api-keys';
+import { ApiDocJson } from 'src/app/shared/types/files';
+import { BASE_URL } from 'src/app/shared/constants/urls';
+import {
+ SESSION_TOKEN_HEADER,
+ API_KEY_HEADER,
+} from 'src/app/shared/constants/http-headers';
+
+interface TestEndpoint {
+ endpoint: string;
+ method: string;
+ title: string;
+ description: string;
+ operationId?: string;
+}
+
+interface TestResult {
+ success: boolean;
+ status: number;
+ error?: string;
+}
+
+@Component({
+ selector: 'df-api-tester',
+ templateUrl: './df-api-tester.component.html',
+ styleUrls: ['./df-api-tester.component.scss'],
+ standalone: true,
+ imports: [
+ CommonModule,
+ MatCardModule,
+ MatSelectModule,
+ MatFormFieldModule,
+ MatButtonModule,
+ MatProgressSpinnerModule,
+ MatExpansionModule,
+ MatIconModule,
+ FormsModule,
+ FontAwesomeModule,
+ ],
+})
+export class DfApiTesterComponent implements OnChanges {
+ @Input() apiDocJson: ApiDocJson;
+ @Input() serviceName: string;
+
+ // Icons
+ faPlay = faPlay;
+ faCheck = faCheck;
+ faTimes = faTimes;
+
+ // API Testing properties
+ availableEndpoints: TestEndpoint[] = [];
+ selectedEndpointIndex: number = 0;
+ selectedApiKey: string | null = null;
+ availableApiKeys: ApiKeyInfo[] = [];
+ testResult: TestResult | null = null;
+ isTesting = false;
+
+ constructor(
+ private http: HttpClient,
+ private userDataService: DfUserDataService,
+ private snackBar: MatSnackBar,
+ private apiKeysService: ApiKeysService,
+ private currentServiceService: DfCurrentServiceService
+ ) {}
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (
+ (changes['apiDocJson'] || changes['serviceName']) &&
+ this.apiDocJson &&
+ this.serviceName
+ ) {
+ this.prepareTestEndpoints();
+ this.loadApiKeys();
+ }
+ }
+
+ private prepareTestEndpoints(): void {
+ this.availableEndpoints = [];
+ if (!this.serviceName || !this.apiDocJson?.paths) {
+ return;
+ }
+
+ // Extract endpoints from apiDocJson.paths
+ Object.keys(this.apiDocJson.paths).forEach(path => {
+ const pathData = this.apiDocJson.paths[path];
+
+ // Get available HTTP methods for this path
+ const methods = ['get', 'post', 'put', 'patch', 'delete'].filter(
+ method => pathData[method] && typeof pathData[method] === 'object'
+ );
+
+ methods.forEach(method => {
+ const operation = pathData[method];
+ if (operation && operation.summary) {
+ this.availableEndpoints.push({
+ endpoint: path,
+ method: method.toUpperCase(),
+ title: operation.summary,
+ description: operation.description || operation.summary,
+ operationId: operation.operationId,
+ });
+ }
+ });
+ });
+
+ // Sort endpoints by path and method for better organization
+ this.availableEndpoints.sort((a, b) => {
+ if (a.endpoint !== b.endpoint) {
+ return a.endpoint.localeCompare(b.endpoint);
+ }
+ return a.method.localeCompare(b.method);
+ });
+
+ // Select first endpoint by default
+ if (this.availableEndpoints.length > 0) {
+ this.selectedEndpointIndex = 0;
+ }
+ }
+
+ private loadApiKeys(): void {
+ // Get API keys from the current service
+ this.currentServiceService.getCurrentServiceId().subscribe({
+ next: (serviceId: number) => {
+ this.apiKeysService.getApiKeysForService(serviceId).subscribe({
+ next: (keys: ApiKeyInfo[]) => {
+ this.availableApiKeys = keys;
+ },
+ error: (error: any) => {
+ console.error('Failed to load API keys:', error);
+ this.availableApiKeys = [];
+ },
+ });
+ },
+ error: (error: any) => {
+ console.error('Failed to get service ID:', error);
+ this.availableApiKeys = [];
+ },
+ });
+ }
+
+ testEndpoint(): void {
+ const selectedEndpoint = this.getSelectedEndpoint();
+ if (!selectedEndpoint || !this.serviceName) {
+ this.snackBar.open('Please select an endpoint to test', 'Close', {
+ duration: 3000,
+ });
+ return;
+ }
+
+ this.isTesting = true;
+ this.testResult = null;
+
+ const baseUrl = `${window.location.origin}${BASE_URL}/${this.serviceName}${selectedEndpoint.endpoint}`;
+
+ let headers = new HttpHeaders({
+ accept: 'application/json',
+ 'content-type': 'application/json',
+ });
+
+ if (this.selectedApiKey && this.selectedApiKey.trim()) {
+ // Use API key authentication
+ headers = headers.set(API_KEY_HEADER, this.selectedApiKey);
+ } else {
+ // Use session token authentication
+ const sessionToken = this.userDataService.token;
+ if (sessionToken) {
+ headers = headers.set(SESSION_TOKEN_HEADER, sessionToken);
+ }
+ }
+
+ // Prepare request options
+ const requestOptions = {
+ headers,
+ observe: 'response' as const,
+ };
+
+ // Execute request based on selected endpoint's method
+ let request;
+ switch (selectedEndpoint.method.toLowerCase()) {
+ case 'get':
+ request = this.http.get(baseUrl, requestOptions);
+ break;
+ case 'post':
+ request = this.http.post(baseUrl, {}, requestOptions);
+ break;
+ case 'put':
+ request = this.http.put(baseUrl, {}, requestOptions);
+ break;
+ case 'patch':
+ request = this.http.patch(baseUrl, {}, requestOptions);
+ break;
+ case 'delete':
+ request = this.http.delete(baseUrl, requestOptions);
+ break;
+ default:
+ this.snackBar.open('Unsupported HTTP method', 'Close', {
+ duration: 3000,
+ });
+ this.isTesting = false;
+ return;
+ }
+
+ request.subscribe({
+ next: response => {
+ this.testResult = {
+ success: true,
+ status: response.status,
+ };
+ this.isTesting = false;
+ this.snackBar.open(
+ `✅ Authentication successful! Access granted to ${selectedEndpoint.method} ${selectedEndpoint.endpoint}`,
+ 'Close',
+ {
+ duration: 4000,
+ }
+ );
+ },
+ error: error => {
+ const isAuthError = error.status === 401 || error.status === 403;
+ this.testResult = {
+ success: false,
+ status: error.status || 0,
+ error: isAuthError
+ ? 'Authentication failed - Access denied'
+ : error.error?.error?.message ||
+ error.message ||
+ 'Request failed due to non-authentication error',
+ };
+ this.isTesting = false;
+
+ if (isAuthError) {
+ this.snackBar.open(
+ '🔒 Authentication failed! Your credentials do not have access to this endpoint.',
+ 'Close',
+ {
+ duration: 5000,
+ }
+ );
+ } else {
+ this.snackBar.open(
+ `✅ Authentication successful, but request failed due to other reasons (Status: ${error.status}).`,
+ 'Close',
+ {
+ duration: 4000,
+ }
+ );
+ }
+ },
+ });
+ }
+
+ clearTestResult(): void {
+ this.testResult = null;
+ }
+
+ getSelectedEndpoint(): TestEndpoint | null {
+ return this.availableEndpoints[this.selectedEndpointIndex] || null;
+ }
+
+ getAuthenticationMethod(): string {
+ return this.selectedApiKey ? 'API Key' : 'Session Token';
+ }
+
+ onEndpointChange(): void {
+ // Clear previous test results when endpoint changes
+ this.testResult = null;
+ }
+
+ getMethodColor(method: string): string {
+ switch (method.toLowerCase()) {
+ case 'get':
+ return '#61affe';
+ case 'post':
+ return '#49cc90';
+ case 'put':
+ return '#fca130';
+ case 'patch':
+ return '#50e3c2';
+ case 'delete':
+ return '#f93e3e';
+ default:
+ return '#9b9b9b';
+ }
+ }
+
+ isAuthenticationError(): boolean {
+ return this.testResult?.status === 401 || this.testResult?.status === 403;
+ }
+
+ getResultIconColor(): string {
+ if (this.testResult?.success) {
+ return '#4caf50'; // Green for success
+ } else if (this.isAuthenticationError()) {
+ return '#f44336'; // Red for auth failure
+ } else {
+ return '#ff9800'; // Orange for non-auth failure (auth passed but request failed)
+ }
+ }
+}
diff --git a/src/app/shared/components/df-security-config/df-security-config.component.html b/src/app/shared/components/df-security-config/df-security-config.component.html
new file mode 100644
index 00000000..12ea5634
--- /dev/null
+++ b/src/app/shared/components/df-security-config/df-security-config.component.html
@@ -0,0 +1,58 @@
+
+
Security Configuration
+
+
+
+
+
+
+
+
+
+ Read Only
+
+
+
+
+ Read & Write
+
+
+
+
+ Full Access
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/app/shared/components/df-security-config/df-security-config.component.scss b/src/app/shared/components/df-security-config/df-security-config.component.scss
new file mode 100644
index 00000000..af1f509a
--- /dev/null
+++ b/src/app/shared/components/df-security-config/df-security-config.component.scss
@@ -0,0 +1,165 @@
+.security-config-wrapper {
+ padding: 24px;
+ max-width: 1200px;
+ margin: 0 auto;
+
+ h3 {
+ margin-bottom: 24px;
+ font-size: 24px;
+ font-weight: 600;
+ color: #1976d2;
+ text-align: center;
+ }
+}
+
+.security-cards-container {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: 20px;
+ margin-bottom: 32px;
+
+ // Responsive breakpoints
+ @media (max-width: 768px) {
+ grid-template-columns: 1fr;
+ gap: 16px;
+ }
+
+ @media (min-width: 769px) and (max-width: 1024px) {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ @media (min-width: 1025px) {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+.security-option-card {
+ padding: 20px;
+ cursor: pointer;
+ border: 2px solid #e0e0e0;
+ border-radius: 12px;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
+ position: relative;
+ overflow: hidden;
+
+ &:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
+ border-color: #1976d2;
+ }
+
+ &.selected {
+ border-color: #1976d2;
+ box-shadow: 0 4px 20px rgba(25, 118, 210, 0.15);
+
+ &.read-level {
+ border-color: #2196f3;
+ background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
+ }
+
+ &.write-level {
+ border-color: #fbc02d;
+ background: linear-gradient(135deg, #fffde7 0%, #fff9c4 100%);
+ }
+
+ &.full-level {
+ border-color: #43a047;
+ background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
+ }
+ }
+
+ .card-header {
+ margin-bottom: 16px;
+
+ .card-title {
+ font-weight: 600;
+ font-size: 18px;
+ margin-bottom: 8px;
+ color: #333;
+ }
+
+ .card-description {
+ font-size: 14px;
+ color: #666;
+ line-height: 1.5;
+ }
+ }
+
+ .toggle-container {
+ .access-level-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: #666;
+ margin-bottom: 8px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+
+ .access-toggle-group {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ box-shadow: none;
+
+ .mat-button-toggle-checked {
+ color: #666;
+ }
+ .mat-button-toggle {
+ background-color: #eeeeee;
+ font-size: 12px;
+ padding: 6px 12px;
+ width: 100%;
+ border-radius: 6px;
+ transition: all 0.2s ease;
+ // Responsive breakpoints
+ @media (max-width: 768px) {
+ width: 150px;
+ }
+
+ &.mat-button-toggle-disabled {
+ opacity: 0.5;
+ pointer-events: none;
+ background-color: #f5f5f5;
+ color: #999;
+ border-color: #ddd;
+ }
+
+ .toggle-icon {
+ margin-right: 4px;
+ font-size: 14px;
+ }
+
+ &.read-toggle {
+ &.mat-button-toggle-checked {
+ background-color: #2196f3;
+ color: white;
+ }
+ }
+
+ &.write-toggle {
+ &.mat-button-toggle-checked {
+ background-color: #fbc02d;
+ color: white;
+ }
+ }
+
+ &.full-toggle {
+ &.mat-button-toggle-checked {
+ background-color: #43a047;
+ color: white;
+ }
+ }
+ }
+ }
+ }
+}
+
+.action-buttons {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ margin-top: 24px;
+ padding-top: 16px;
+ border-top: 1px solid #e0e0e0;
+}
diff --git a/src/app/shared/components/df-security-config/df-security-config.component.ts b/src/app/shared/components/df-security-config/df-security-config.component.ts
new file mode 100644
index 00000000..906a7487
--- /dev/null
+++ b/src/app/shared/components/df-security-config/df-security-config.component.ts
@@ -0,0 +1,485 @@
+import { Component, OnInit, Output, EventEmitter, Input } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { MatCardModule } from '@angular/material/card';
+import { MatButtonToggleModule } from '@angular/material/button-toggle';
+import { MatButtonModule } from '@angular/material/button';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatIconModule } from '@angular/material/icon';
+import { Router } from '@angular/router';
+import { MatSnackBar } from '@angular/material/snack-bar';
+import { DfSystemService } from 'src/app/shared/services/df-system.service';
+import { DfSnackbarService } from 'src/app/shared/services/df-snackbar.service';
+import { switchMap, catchError, map } from 'rxjs';
+import { throwError } from 'rxjs';
+import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
+import { faEye, faPen, faLockOpen } from '@fortawesome/free-solid-svg-icons';
+
+interface AccessOption {
+ key: string;
+ label: string;
+ description: string;
+ selected: boolean;
+ level: 'read' | 'write' | 'full';
+}
+
+interface SecurityConfigData {
+ accessType: string;
+ accessLevel: string;
+ component: string;
+}
+
+@Component({
+ selector: 'df-security-config',
+ standalone: true,
+ templateUrl: './df-security-config.component.html',
+ styleUrls: ['./df-security-config.component.scss'],
+ imports: [
+ CommonModule,
+ FormsModule,
+ MatCardModule,
+ MatButtonToggleModule,
+ MatButtonModule,
+ MatCheckboxModule,
+ MatIconModule,
+ FontAwesomeModule,
+ ],
+})
+export class DfSecurityConfigComponent implements OnInit {
+ @Input() serviceName: string = '';
+ @Input() serviceId: number | null = null;
+ @Input() isDatabase: boolean = false;
+
+ @Output() goBack = new EventEmitter
();
+
+ // FontAwesome icons
+ faEye = faEye;
+ faPen = faPen;
+ faLockOpen = faLockOpen;
+
+ // Multiple security configurations
+ securityConfigurations: SecurityConfigData[] = [];
+
+ // Access options for the component design
+ accessOptions: AccessOption[] = [];
+
+ constructor(
+ private router: Router,
+ private snackBar: MatSnackBar,
+ private systemService: DfSystemService,
+ private snackbarService: DfSnackbarService
+ ) {}
+
+ ngOnInit(): void {
+ this.initializeAccessOptions();
+ }
+
+ private initializeAccessOptions(): void {
+ this.accessOptions = [
+ {
+ key: 'fullAccess',
+ label: 'Full Access',
+ description: 'Grant complete access to all database components',
+ selected: false,
+ level: 'read',
+ },
+ {
+ key: 'schemaAccess',
+ label: 'Schema Access',
+ description: 'Configure access to specific database schemas',
+ selected: false,
+ level: 'read',
+ },
+ {
+ key: 'tableAccess',
+ label: 'Table Access',
+ description: 'Manage access to individual database tables',
+ selected: false,
+ level: 'read',
+ },
+ {
+ key: 'storedProcedures',
+ label: 'Stored Procedures',
+ description: 'Control access to stored procedures',
+ selected: false,
+ level: 'read',
+ },
+ {
+ key: 'functions',
+ label: 'Functions',
+ description: 'Set access levels for database functions',
+ selected: false,
+ level: 'read',
+ },
+ ];
+ }
+
+ // Original component methods
+ toggleCard(option: AccessOption): void {
+ // Special handling for full access
+ if (option.key === 'fullAccess') {
+ if (!option.selected) {
+ // When selecting full access, unselect all other options
+ this.accessOptions.forEach(opt => {
+ if (opt.key !== 'fullAccess' && opt.selected) {
+ opt.selected = false;
+ this.removeSecurityConfiguration(opt.key);
+ }
+ });
+ }
+ } else {
+ // For other options, if full access is selected, unselect it first
+ const fullAccessOption = this.accessOptions.find(
+ opt => opt.key === 'fullAccess'
+ );
+ if (fullAccessOption && fullAccessOption.selected) {
+ fullAccessOption.selected = false;
+ this.removeSecurityConfiguration(fullAccessOption.key);
+ }
+ }
+
+ option.selected = !option.selected;
+
+ // Update the security configuration based on the selected option
+ if (option.selected) {
+ // Add new configuration
+ this.addSecurityConfiguration(option);
+ } else {
+ // Remove configuration for this option
+ this.removeSecurityConfiguration(option.key);
+ }
+ }
+
+ private addSecurityConfiguration(option: AccessOption): void {
+ // Map the access option key to the corresponding access type
+ let accessType = '';
+ let component = '';
+
+ switch (option.key) {
+ case 'fullAccess':
+ accessType = 'all';
+ component = '*';
+ break;
+ case 'schemaAccess':
+ accessType = 'schema';
+ component = '_schema/*';
+ break;
+ case 'tableAccess':
+ accessType = 'tables';
+ component = '_table/*';
+ break;
+ case 'storedProcedures':
+ accessType = 'procedures';
+ component = '_proc/*';
+ break;
+ case 'functions':
+ accessType = 'functions';
+ component = '_func/*';
+ break;
+ }
+
+ // Create new security configuration
+ const newConfig: SecurityConfigData = {
+ accessType: accessType,
+ accessLevel: option.level,
+ component: component,
+ };
+
+ // Add to configurations array
+ this.securityConfigurations.push(newConfig);
+
+ console.log('Added security configuration:', newConfig);
+ console.log('All configurations:', this.securityConfigurations);
+ }
+
+ private removeSecurityConfiguration(optionKey: string): void {
+ // Find and remove the configuration for this option
+ const index = this.securityConfigurations.findIndex(config => {
+ switch (optionKey) {
+ case 'fullAccess':
+ return config.accessType === 'all';
+ case 'schemaAccess':
+ return config.accessType === 'schema';
+ case 'tableAccess':
+ return config.accessType === 'tables';
+ case 'storedProcedures':
+ return config.accessType === 'procedures';
+ case 'functions':
+ return config.accessType === 'functions';
+ default:
+ return false;
+ }
+ });
+
+ if (index !== -1) {
+ const removed = this.securityConfigurations.splice(index, 1)[0];
+ console.log('Removed security configuration:', removed);
+ console.log('Remaining configurations:', this.securityConfigurations);
+ }
+ }
+
+ onAccessLevelChange(
+ option: AccessOption,
+ level: 'read' | 'write' | 'full'
+ ): void {
+ option.level = level;
+
+ // Update the corresponding configuration in the array
+ const configIndex = this.securityConfigurations.findIndex(config => {
+ switch (option.key) {
+ case 'fullAccess':
+ return config.accessType === 'all';
+ case 'schemaAccess':
+ return config.accessType === 'schema';
+ case 'tableAccess':
+ return config.accessType === 'tables';
+ case 'storedProcedures':
+ return config.accessType === 'procedures';
+ case 'functions':
+ return config.accessType === 'functions';
+ default:
+ return false;
+ }
+ });
+
+ if (configIndex !== -1) {
+ this.securityConfigurations[configIndex].accessLevel = level;
+ console.log(
+ 'Updated access level for configuration:',
+ this.securityConfigurations[configIndex]
+ );
+ }
+ }
+
+ handleGoBack(): void {
+ console.log('Back button clicked');
+ this.goBack.emit();
+ }
+
+ isSecurityConfigValid(): boolean {
+ // Check if at least one option is selected
+ const hasSelectedOption = this.accessOptions.some(opt => opt.selected);
+
+ if (!hasSelectedOption) {
+ return false;
+ }
+
+ // Check if we have configurations
+ if (this.securityConfigurations.length === 0) {
+ return false;
+ }
+
+ // Validate each configuration
+ for (const config of this.securityConfigurations) {
+ if (!config.accessType || !config.accessLevel || !config.component) {
+ return false;
+ }
+
+ // Additional validation based on access type
+ if (config.accessType === 'all') {
+ if (config.component !== '*') {
+ return false;
+ }
+ } else {
+ // For other access types, ensure component has wildcard
+ if (!config.component.includes('/*')) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ saveSecurityConfig() {
+ if (!this.isSecurityConfigValid()) {
+ this.snackbarService.openSnackBar(
+ 'Please select at least one access option and ensure all required fields are filled',
+ 'error'
+ );
+ return;
+ }
+
+ if (!this.serviceId) {
+ this.snackBar.open('No service ID found. Please try again.', 'Close', {
+ duration: 3000,
+ });
+ return;
+ }
+
+ const formattedName = this.formatServiceName(this.serviceName);
+ const roleName = `${this.serviceName}_auto_role`;
+
+ // Create role service access entries for each configuration
+ const roleServiceAccessEntries = this.securityConfigurations.map(
+ config => ({
+ service_id: this.serviceId,
+ component: config.component,
+ verb_mask: this.getAccessLevel(config.accessLevel),
+ requestor_mask: 3,
+ filters: [],
+ filter_op: 'AND',
+ })
+ );
+
+ const rolePayload = {
+ resource: [
+ {
+ name: roleName,
+ description: `Auto-generated role for service ${this.serviceName}`,
+ is_active: true,
+ role_service_access_by_role_id: roleServiceAccessEntries,
+ user_to_app_to_role_by_role_id: [],
+ },
+ ],
+ };
+
+ console.log('Creating role with multiple configurations:', rolePayload);
+
+ // Create role and chain with app creation using proper RxJS operators
+ this.systemService
+ .post('role', rolePayload)
+ .pipe(
+ catchError(error => {
+ return throwError(() => error);
+ }),
+ switchMap((roleResponse: any) => {
+ if (!roleResponse?.resource?.[0]?.id) {
+ return throwError(() => new Error('Invalid role response'));
+ }
+ const createdRoleId = roleResponse.resource[0].id;
+
+ const appPayload = {
+ resource: [
+ {
+ name: `${this.serviceName}_app`,
+ description: `Auto-generated app for service ${this.serviceName}`,
+ type: '0',
+ role_id: createdRoleId,
+ is_active: true,
+ url: null,
+ storage_service_id: null,
+ storage_container: null,
+ path: null,
+ },
+ ],
+ };
+
+ return this.systemService
+ .post('app?fields=*&related=role_by_role_id', appPayload)
+ .pipe(
+ catchError(error => {
+ this.snackBar.open(
+ `Error creating app: ${
+ error.error?.message || error.message || 'Unknown error'
+ }`,
+ 'Close',
+ { duration: 5000 }
+ );
+ return throwError(() => error);
+ }),
+ map((appResponse: any) => {
+ if (!appResponse?.resource?.[0]) {
+ throw new Error('App response missing resource array');
+ }
+
+ const app = appResponse.resource[0];
+
+ if (!app.apiKey) {
+ throw new Error('App response missing apiKey');
+ }
+
+ return {
+ apiKey: app.apiKey,
+ formattedName: formattedName,
+ };
+ }),
+ catchError(error => {
+ return throwError(() => error);
+ })
+ );
+ }),
+ map((appResponse: any) => {
+ if (!appResponse?.apiKey) {
+ throw new Error('Invalid app response');
+ }
+ return {
+ apiKey: appResponse.apiKey,
+ formattedName: formattedName,
+ };
+ })
+ )
+ .subscribe({
+ next: result => {
+ // Attempt to copy API key to clipboard
+ if (navigator.clipboard) {
+ navigator.clipboard
+ .writeText(result.apiKey)
+ .then(() => {
+ this.snackbarService.openSnackBar(
+ `API Created with ${this.securityConfigurations.length} security configuration(s) and API Key copied to clipboard`,
+ 'success'
+ );
+ })
+ .catch(() => {
+ this.snackbarService.openSnackBar(
+ `API Created with ${this.securityConfigurations.length} security configuration(s), but failed to copy API Key`,
+ 'success'
+ );
+ });
+ } else {
+ this.snackbarService.openSnackBar(
+ `API Created with ${this.securityConfigurations.length} security configuration(s), but failed to copy API Key`,
+ 'success'
+ );
+ }
+
+ // Navigate to API docs
+ this.router
+ .navigateByUrl(
+ `/api-connections/api-docs/${result.formattedName}`,
+ {
+ replaceUrl: true,
+ }
+ )
+ .then(success => {
+ if (!success) {
+ this.router.navigate(
+ ['api-connections', 'api-docs', result.formattedName],
+ {
+ replaceUrl: true,
+ }
+ );
+ }
+ });
+ },
+ error: error => {
+ // Show error message using DfSnackbarService
+ this.snackbarService.openSnackBar(
+ 'Error saving security configuration',
+ 'error'
+ );
+ },
+ });
+ }
+
+ private getAccessLevel(level: string): number {
+ switch (level) {
+ case 'read':
+ return 1; // GET
+ case 'write':
+ return 7; // GET (1) + POST (2) + PUT/PATCH (4) = 7
+ case 'full':
+ return 15; // All permissions (GET + POST + PUT + DELETE)
+ default:
+ return 0;
+ }
+ }
+
+ private formatServiceName(name: string): string {
+ return name
+ .toLowerCase()
+ .replace(/\s+/g, '')
+ .replace(/[^a-z0-9_-]/g, '');
+ }
+}