added neutralization view
This commit is contained in:
parent
f312cd41b1
commit
1ff776c503
7 changed files with 990 additions and 0 deletions
107
src/api/neutralizationApi.ts
Normal file
107
src/api/neutralizationApi.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
/**
|
||||||
|
* Neutralization API
|
||||||
|
*
|
||||||
|
* API functions for the Neutralization feature.
|
||||||
|
* Endpoints use /api/neutralization/*. Context headers (X-Mandate-Id, X-Instance-Id)
|
||||||
|
* are set automatically by the api interceptor when on a feature instance page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TYPES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface NeutralizationConfig {
|
||||||
|
id?: string;
|
||||||
|
mandateId: string;
|
||||||
|
featureInstanceId: string;
|
||||||
|
userId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
namesToParse: string;
|
||||||
|
sharepointSourcePath: string;
|
||||||
|
sharepointTargetPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NeutralizationResult {
|
||||||
|
neutralized_text?: string;
|
||||||
|
neutralized_file_base64?: string;
|
||||||
|
neutralized_file_name?: string;
|
||||||
|
mime_type?: string;
|
||||||
|
original_file_id?: string;
|
||||||
|
neutralized_file_id?: string;
|
||||||
|
mapping?: Record<string, string>;
|
||||||
|
attributes?: Array<{
|
||||||
|
id: string;
|
||||||
|
originalText: string;
|
||||||
|
patternType: string;
|
||||||
|
fileId?: string;
|
||||||
|
}>;
|
||||||
|
processed_info?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NeutralizationAttribute {
|
||||||
|
id: string;
|
||||||
|
mandateId: string;
|
||||||
|
featureInstanceId: string;
|
||||||
|
userId: string;
|
||||||
|
originalText: string;
|
||||||
|
fileId?: string;
|
||||||
|
patternType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getNeutralizationConfig(): Promise<NeutralizationConfig> {
|
||||||
|
const { data } = await api.get<NeutralizationConfig>('/api/neutralization/config');
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveNeutralizationConfig(
|
||||||
|
configData: Partial<NeutralizationConfig> & { enabled: boolean; namesToParse: string; sharepointSourcePath: string; sharepointTargetPath: string }
|
||||||
|
): Promise<NeutralizationConfig> {
|
||||||
|
const { data } = await api.post<NeutralizationConfig>('/api/neutralization/config', configData);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function neutralizeText(text: string, fileId?: string): Promise<NeutralizationResult> {
|
||||||
|
const { data } = await api.post<NeutralizationResult>('/api/neutralization/neutralize-text', {
|
||||||
|
text,
|
||||||
|
...(fileId && { fileId }),
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveText(text: string): Promise<{ resolved_text: string }> {
|
||||||
|
const { data } = await api.post<{ resolved_text: string }>('/api/neutralization/resolve-text', {
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNeutralizationAttributes(fileId?: string): Promise<NeutralizationAttribute[]> {
|
||||||
|
const params = fileId ? { fileId } : {};
|
||||||
|
const { data } = await api.get<NeutralizationAttribute[]>('/api/neutralization/attributes', { params });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function neutralizeFile(file: File): Promise<NeutralizationResult> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
// Do NOT set Content-Type - axios sets it with boundary for FormData
|
||||||
|
const { data } = await api.post<NeutralizationResult>('/api/neutralization/neutralize-file', formData);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processSharepointFiles(
|
||||||
|
sourcePath: string,
|
||||||
|
targetPath: string
|
||||||
|
): Promise<{ success: boolean; message?: string; [key: string]: unknown }> {
|
||||||
|
const { data } = await api.post('/api/neutralization/process-sharepoint', {
|
||||||
|
sourcePath,
|
||||||
|
targetPath,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
@ -92,7 +92,14 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'page.feature.teamsbot.sessions': <FaVideo />,
|
'page.feature.teamsbot.sessions': <FaVideo />,
|
||||||
'page.feature.teamsbot.settings': <FaCog />,
|
'page.feature.teamsbot.settings': <FaCog />,
|
||||||
|
|
||||||
|
// Feature pages - Neutralization
|
||||||
|
'page.feature.neutralization.dashboard': <FaShieldAlt />,
|
||||||
|
'page.feature.neutralization.playground': <FaShieldAlt />,
|
||||||
|
'page.feature.neutralization.config': <FaCog />,
|
||||||
|
'page.feature.neutralization.attributes': <FaDatabase />,
|
||||||
|
|
||||||
// Feature icons (for feature grouping in navigation)
|
// Feature icons (for feature grouping in navigation)
|
||||||
|
'feature.neutralization': <FaShieldAlt />,
|
||||||
'feature.trustee': <FaBriefcase />,
|
'feature.trustee': <FaBriefcase />,
|
||||||
'feature.realestate': <FaBuilding />,
|
'feature.realestate': <FaBuilding />,
|
||||||
'feature.chatworkflow': <FaPlay />,
|
'feature.chatworkflow': <FaPlay />,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,9 @@ import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
||||||
import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView';
|
import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView';
|
||||||
import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView';
|
import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView';
|
||||||
|
|
||||||
|
// Neutralization Views
|
||||||
|
import { NeutralizationView } from './views/neutralization';
|
||||||
|
|
||||||
import styles from './FeatureView.module.css';
|
import styles from './FeatureView.module.css';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -135,6 +138,10 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
sessions: TeamsbotSessionView,
|
sessions: TeamsbotSessionView,
|
||||||
settings: TeamsbotSettingsView,
|
settings: TeamsbotSettingsView,
|
||||||
},
|
},
|
||||||
|
neutralization: {
|
||||||
|
dashboard: NeutralizationView,
|
||||||
|
playground: NeutralizationView,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
517
src/pages/views/neutralization/NeutralizationView.tsx
Normal file
517
src/pages/views/neutralization/NeutralizationView.tsx
Normal file
|
|
@ -0,0 +1,517 @@
|
||||||
|
/**
|
||||||
|
* NeutralizationView
|
||||||
|
*
|
||||||
|
* Combined view for the Neutralization feature with two tabs:
|
||||||
|
* - Configuration: Enable/disable neutralization, configure names to parse, SharePoint paths.
|
||||||
|
* - Playground: Manual text neutralization and placeholder resolution.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
|
import { useFileContext } from '../../../contexts/FileContext';
|
||||||
|
import {
|
||||||
|
getNeutralizationConfig,
|
||||||
|
saveNeutralizationConfig,
|
||||||
|
processSharepointFiles,
|
||||||
|
neutralizeText,
|
||||||
|
neutralizeFile,
|
||||||
|
resolveText,
|
||||||
|
type NeutralizationConfig,
|
||||||
|
} from '../../../api/neutralizationApi';
|
||||||
|
import { Tabs } from '../../../components/UiComponents/Tabs';
|
||||||
|
import styles from './NeutralizationViews.module.css';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// CONFIGURATION TAB
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const ConfigTab: React.FC = () => {
|
||||||
|
const { instance } = useCurrentInstance();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
const [config, setConfig] = useState<NeutralizationConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [processing, setProcessing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [namesToParse, setNamesToParse] = useState('');
|
||||||
|
const [sharepointSourcePath, setSharepointSourcePath] = useState('');
|
||||||
|
const [sharepointTargetPath, setSharepointTargetPath] = useState('');
|
||||||
|
|
||||||
|
const loadConfig = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await getNeutralizationConfig();
|
||||||
|
setConfig(data);
|
||||||
|
setEnabled(data.enabled);
|
||||||
|
setNamesToParse(data.namesToParse || '');
|
||||||
|
setSharepointSourcePath(data.sharepointSourcePath || '');
|
||||||
|
setSharepointTargetPath(data.sharepointTargetPath || '');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errObj = err as { response?: { data?: { detail?: string | { msg?: string }[] } }; message?: string };
|
||||||
|
const detail = errObj.response?.data?.detail;
|
||||||
|
const message =
|
||||||
|
(typeof detail === 'string' ? detail : null) ||
|
||||||
|
(Array.isArray(detail) ? detail.map((e: { msg?: string }) => e.msg || JSON.stringify(e)).join(', ') : null) ||
|
||||||
|
errObj.message ||
|
||||||
|
'Error loading configuration';
|
||||||
|
setError(message);
|
||||||
|
showError('Error', message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [showError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig();
|
||||||
|
}, [loadConfig]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const mandateId = instance?.mandateId;
|
||||||
|
const featureInstanceId = instance?.id;
|
||||||
|
if (!mandateId || !featureInstanceId) {
|
||||||
|
throw new Error('Missing mandate or instance context');
|
||||||
|
}
|
||||||
|
await saveNeutralizationConfig({
|
||||||
|
mandateId,
|
||||||
|
featureInstanceId,
|
||||||
|
userId: config?.userId || '',
|
||||||
|
enabled,
|
||||||
|
namesToParse,
|
||||||
|
sharepointSourcePath,
|
||||||
|
sharepointTargetPath,
|
||||||
|
});
|
||||||
|
showSuccess('Saved', 'Configuration saved successfully.');
|
||||||
|
await loadConfig();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
|
||||||
|
const message =
|
||||||
|
(typeof errObj.response?.data?.detail === 'string' ? errObj.response.data.detail : null) ||
|
||||||
|
errObj.message ||
|
||||||
|
'Failed to save configuration';
|
||||||
|
setError(message);
|
||||||
|
showError('Error', message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProcessSharepoint = async () => {
|
||||||
|
if (!sharepointSourcePath.trim() || !sharepointTargetPath.trim()) {
|
||||||
|
showError('Error', 'Both SharePoint source and target paths are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProcessing(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await processSharepointFiles(sharepointSourcePath, sharepointTargetPath);
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Done', result.message || 'SharePoint files processed successfully.');
|
||||||
|
} else {
|
||||||
|
setError(result.message || 'Processing failed');
|
||||||
|
showError('Error', result.message || 'Processing failed');
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
|
||||||
|
const message =
|
||||||
|
(typeof errObj.response?.data?.detail === 'string' ? errObj.response.data.detail : null) ||
|
||||||
|
errObj.message ||
|
||||||
|
'Failed to process SharePoint files';
|
||||||
|
setError(message);
|
||||||
|
showError('Error', message);
|
||||||
|
} finally {
|
||||||
|
setProcessing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissError = () => setError(null);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className={styles.loading}>Loading configuration...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.configSection}>
|
||||||
|
<h3 className={styles.sectionTitle}>Neutralization Configuration</h3>
|
||||||
|
<p className={styles.sectionDescription}>
|
||||||
|
Configure data neutralization settings for this instance.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={styles.configCard}>
|
||||||
|
{error && (
|
||||||
|
<div className={styles.errorMessage}>
|
||||||
|
<span>{error}</span>
|
||||||
|
<button type="button" onClick={dismissError} aria-label="Dismiss">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div className={styles.checkboxRow}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="neutralization-enabled"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="neutralization-enabled">Enable Neutralization:</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<label htmlFor="names-to-parse">Names to Parse (one per line):</label>
|
||||||
|
<textarea
|
||||||
|
id="names-to-parse"
|
||||||
|
className={styles.textareaField}
|
||||||
|
value={namesToParse}
|
||||||
|
onChange={(e) => setNamesToParse(e.target.value)}
|
||||||
|
placeholder="Enter names to parse, one per line..."
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<label htmlFor="sharepoint-source">SharePoint Source Path:</label>
|
||||||
|
<div className={styles.inputWithButton}>
|
||||||
|
<input
|
||||||
|
id="sharepoint-source"
|
||||||
|
type="text"
|
||||||
|
value={sharepointSourcePath}
|
||||||
|
onChange={(e) => setSharepointSourcePath(e.target.value)}
|
||||||
|
placeholder="Enter SharePoint source path..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => {}}
|
||||||
|
title="Browse (not yet implemented)"
|
||||||
|
>
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<label htmlFor="sharepoint-target">SharePoint Target Path:</label>
|
||||||
|
<div className={styles.inputWithButton}>
|
||||||
|
<input
|
||||||
|
id="sharepoint-target"
|
||||||
|
type="text"
|
||||||
|
value={sharepointTargetPath}
|
||||||
|
onChange={(e) => setSharepointTargetPath(e.target.value)}
|
||||||
|
placeholder="Enter SharePoint target path..."
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => {}}
|
||||||
|
title="Browse (not yet implemented)"
|
||||||
|
>
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.buttonRow}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save Configuration'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={handleProcessSharepoint}
|
||||||
|
disabled={processing || !sharepointSourcePath.trim() || !sharepointTargetPath.trim()}
|
||||||
|
>
|
||||||
|
{processing ? 'Processing...' : 'Neutralize Sharepoint Files'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// PLAYGROUND TAB
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const PlaygroundTab: React.FC = () => {
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
const { refetch: refetchFiles, handleFileDownload } = useFileContext();
|
||||||
|
|
||||||
|
const [inputText, setInputText] = useState('');
|
||||||
|
const [neutralizedText, setNeutralizedText] = useState('');
|
||||||
|
const [neutralizing, setNeutralizing] = useState(false);
|
||||||
|
const [resolving, setResolving] = useState(false);
|
||||||
|
const [fileResult, setFileResult] = useState<{
|
||||||
|
base64?: string;
|
||||||
|
fileName?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
fileId?: string;
|
||||||
|
} | null>(null);
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleNeutralize = async () => {
|
||||||
|
if (!inputText.trim()) {
|
||||||
|
showError('Error', 'Please enter text to neutralize.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNeutralizing(true);
|
||||||
|
setFileResult(null);
|
||||||
|
try {
|
||||||
|
const result = await neutralizeText(inputText);
|
||||||
|
setNeutralizedText(result.neutralized_text || '');
|
||||||
|
showSuccess('Done', 'Text neutralized successfully.');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
|
||||||
|
const message =
|
||||||
|
(typeof errObj.response?.data?.detail === 'string' ? errObj.response.data.detail : null) ||
|
||||||
|
errObj.message ||
|
||||||
|
'Failed to neutralize text';
|
||||||
|
showError('Error', message);
|
||||||
|
} finally {
|
||||||
|
setNeutralizing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setNeutralizing(true);
|
||||||
|
setFileResult(null);
|
||||||
|
setNeutralizedText('');
|
||||||
|
try {
|
||||||
|
const result = await neutralizeFile(file);
|
||||||
|
const base64Data = result.neutralized_file_base64 || (typeof result.neutralized_bytes === 'string' ? result.neutralized_bytes : null);
|
||||||
|
const fileName = result.neutralized_file_name || `neutralized_${file.name}`;
|
||||||
|
if (base64Data) {
|
||||||
|
setFileResult({
|
||||||
|
base64: base64Data,
|
||||||
|
fileName,
|
||||||
|
mimeType: result.mime_type || 'application/octet-stream',
|
||||||
|
fileId: result.neutralized_file_id,
|
||||||
|
});
|
||||||
|
setNeutralizedText('');
|
||||||
|
showSuccess('Done', 'File neutralized. Download below or find it in your Files.');
|
||||||
|
} else if (result.neutralized_text !== undefined) {
|
||||||
|
setNeutralizedText(result.neutralized_text || '');
|
||||||
|
setFileResult(result.neutralized_file_id ? { fileName, fileId: result.neutralized_file_id } : { fileName });
|
||||||
|
showSuccess('Done', result.neutralized_file_id ? 'File neutralized. Download or find in Files.' : 'File neutralized.');
|
||||||
|
} else {
|
||||||
|
const err = (result.processed_info as { error?: string })?.error || 'No result returned';
|
||||||
|
console.warn('[Neutralization] Unexpected result:', result);
|
||||||
|
showError('Error', err);
|
||||||
|
}
|
||||||
|
if (result.original_file_id || result.neutralized_file_id) {
|
||||||
|
refetchFiles();
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
|
||||||
|
const message =
|
||||||
|
(typeof errObj.response?.data?.detail === 'string' ? errObj.response.data.detail : null) ||
|
||||||
|
errObj.message ||
|
||||||
|
'Failed to neutralize file';
|
||||||
|
showError('Error', message);
|
||||||
|
} finally {
|
||||||
|
setNeutralizing(false);
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
if (!fileResult?.fileName) return;
|
||||||
|
if (fileResult.fileId) {
|
||||||
|
try {
|
||||||
|
await handleFileDownload(fileResult.fileId, fileResult.fileName);
|
||||||
|
} catch {
|
||||||
|
showError('Error', 'Failed to download file');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fileResult.base64) {
|
||||||
|
try {
|
||||||
|
const bin = atob(fileResult.base64);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
const blob = new Blob([bytes], { type: fileResult.mimeType || 'application/octet-stream' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileResult.fileName;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
showError('Error', 'Failed to download file');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (neutralizedText) {
|
||||||
|
const blob = new Blob([neutralizedText], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileResult.fileName;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showError('Error', 'No file data available to download. The neutralized file may not have been generated.');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResolve = async () => {
|
||||||
|
if (!neutralizedText.trim()) {
|
||||||
|
showError('Error', 'No neutralized text to resolve.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResolving(true);
|
||||||
|
try {
|
||||||
|
const result = await resolveText(neutralizedText);
|
||||||
|
setNeutralizedText(result.resolved_text || '');
|
||||||
|
showSuccess('Done', 'Text resolved successfully.');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
|
||||||
|
const message =
|
||||||
|
(typeof errObj.response?.data?.detail === 'string' ? errObj.response.data.detail : null) ||
|
||||||
|
errObj.message ||
|
||||||
|
'Failed to resolve text';
|
||||||
|
showError('Error', message);
|
||||||
|
} finally {
|
||||||
|
setResolving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setNeutralizedText('');
|
||||||
|
setFileResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.playgroundSection}>
|
||||||
|
<h3 className={styles.sectionTitle}>Manual Text & File Neutralization</h3>
|
||||||
|
<p className={styles.sectionDescription}>
|
||||||
|
Enter text or upload a file (PDF, DOCX, XLSX, PPTX, TXT, CSV, JSON) to neutralize sensitive data.
|
||||||
|
Use "Resolve Text" to convert placeholders back to the original text.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={styles.playgroundCard}>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<label>Upload file to neutralize:</label>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.docx,.xlsx,.xlsm,.pptx,.txt,.csv,.json"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
<div className={styles.buttonRow}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={neutralizing}
|
||||||
|
>
|
||||||
|
{neutralizing ? 'Processing...' : 'Upload & Neutralize File'}
|
||||||
|
</button>
|
||||||
|
{fileResult && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={handleDownload}
|
||||||
|
>
|
||||||
|
Download {fileResult.fileName || 'Neutralized File'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{fileResult?.fileId && (
|
||||||
|
<p className={styles.sectionDescription} style={{ marginTop: 4, marginBottom: 0 }}>
|
||||||
|
File saved to your Files. You can also download it from Basisdaten → Files.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<label htmlFor="input-text">Or paste text:</label>
|
||||||
|
<textarea
|
||||||
|
id="input-text"
|
||||||
|
className={styles.textareaField}
|
||||||
|
value={inputText}
|
||||||
|
onChange={(e) => setInputText(e.target.value)}
|
||||||
|
placeholder="Enter text to neutralize..."
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.buttonRow}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={handleNeutralize}
|
||||||
|
disabled={neutralizing}
|
||||||
|
>
|
||||||
|
{neutralizing ? 'Neutralizing...' : 'Neutralize Text'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={handleResolve}
|
||||||
|
disabled={resolving || !neutralizedText.trim()}
|
||||||
|
>
|
||||||
|
{resolving ? 'Resolving...' : 'Resolve Text'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<label htmlFor="neutralized-text">Neutralized Text:</label>
|
||||||
|
<textarea
|
||||||
|
id="neutralized-text"
|
||||||
|
className={styles.textareaField}
|
||||||
|
value={neutralizedText}
|
||||||
|
onChange={(e) => setNeutralizedText(e.target.value)}
|
||||||
|
placeholder="Neutralized text will appear here (or use Download for binary files)...."
|
||||||
|
rows={8}
|
||||||
|
readOnly={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(neutralizedText || fileResult) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.clearLink}
|
||||||
|
onClick={handleClear}
|
||||||
|
>
|
||||||
|
Clear Result
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MAIN VIEW
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'config', label: 'Configuration', content: <ConfigTab /> },
|
||||||
|
{ id: 'playground', label: 'Playground', content: <PlaygroundTab /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const NeutralizationView: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<Tabs tabs={TABS} defaultTabId="playground" />
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NeutralizationView;
|
||||||
340
src/pages/views/neutralization/NeutralizationViews.module.css
Normal file
340
src/pages/views/neutralization/NeutralizationViews.module.css
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
/**
|
||||||
|
* Neutralization Views Shared Styles
|
||||||
|
* Based on TrusteeViews for consistency
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Section layout */
|
||||||
|
.configSection,
|
||||||
|
.playgroundSection {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionDescription {
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form layout */
|
||||||
|
.configCard {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formRow label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox */
|
||||||
|
.checkboxRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxRow input[type='checkbox'] {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
accent-color: var(--primary-color, #2563eb);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkboxRow label {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input with browse button */
|
||||||
|
.inputWithButton {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWithButton input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWithButton input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color, #2563eb);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWithButton button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--border-color, #d0d0d0);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inputWithButton button:hover {
|
||||||
|
background: var(--surface-color, #f5f5f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea */
|
||||||
|
.textareaField {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textareaField:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color, #2563eb);
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textareaField::placeholder {
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.buttonRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryButton {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--primary-color, #2563eb);
|
||||||
|
color: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryButton:hover:not(:disabled) {
|
||||||
|
background: var(--primary-hover, #1d4ed8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryButton:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryButton {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--border-color, #d0d0d0);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryButton:hover:not(:disabled) {
|
||||||
|
background: var(--surface-color, #f5f5f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondaryButton:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error message */
|
||||||
|
.errorMessage {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--error-light, #fef2f2);
|
||||||
|
border: 1px solid var(--error-color, #dc2626);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--error-color, #dc2626);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorMessage button {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clear link (destructive) */
|
||||||
|
.clearLink {
|
||||||
|
color: var(--error-color, #dc2626);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearLink:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Data table */
|
||||||
|
.dataTable {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTable th,
|
||||||
|
.dataTable td {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTable th {
|
||||||
|
background: var(--surface-color, #f8f9fa);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTable td {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataTable tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monospace {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 200px;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Playground layout */
|
||||||
|
.playgroundCard {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--bg-primary, #ffffff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme */
|
||||||
|
:global(.dark-theme) .configCard,
|
||||||
|
:global(.dark-theme) .playgroundCard {
|
||||||
|
background: var(--surface-dark, #1a1a1a);
|
||||||
|
border-color: var(--border-dark, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .sectionTitle {
|
||||||
|
color: var(--text-primary-dark, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .sectionDescription,
|
||||||
|
:global(.dark-theme) .formRow label {
|
||||||
|
color: var(--text-secondary-dark, #aaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .inputWithButton input,
|
||||||
|
:global(.dark-theme) .textareaField {
|
||||||
|
background: var(--surface-dark, #2a2a2a);
|
||||||
|
border-color: var(--border-dark, #444);
|
||||||
|
color: var(--text-primary-dark, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .inputWithButton button,
|
||||||
|
:global(.dark-theme) .secondaryButton {
|
||||||
|
background: var(--surface-dark, #1a1a1a);
|
||||||
|
border-color: var(--border-dark, #444);
|
||||||
|
color: var(--text-primary-dark, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .secondaryButton:hover:not(:disabled) {
|
||||||
|
background: var(--surface-dark, #2a2a2a);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .errorMessage {
|
||||||
|
background: var(--error-dark, #450a0a);
|
||||||
|
border-color: var(--error-color, #dc2626);
|
||||||
|
color: var(--error-light, #fef2f2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .dataTable {
|
||||||
|
background: var(--surface-dark, #1a1a1a);
|
||||||
|
border-color: var(--border-dark, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .dataTable th {
|
||||||
|
background: var(--surface-dark, #2a2a2a);
|
||||||
|
color: var(--text-secondary-dark, #aaa);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .dataTable th,
|
||||||
|
:global(.dark-theme) .dataTable td {
|
||||||
|
border-bottom-color: var(--border-dark, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark-theme) .dataTable td {
|
||||||
|
color: var(--text-primary-dark, #ffffff);
|
||||||
|
}
|
||||||
1
src/pages/views/neutralization/index.ts
Normal file
1
src/pages/views/neutralization/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { NeutralizationView } from './NeutralizationView';
|
||||||
|
|
@ -279,6 +279,17 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
{ code: 'logs', label: { de: 'Protokolle', en: 'Logs' }, path: 'logs' },
|
{ code: 'logs', label: { de: 'Protokolle', en: 'Logs' }, path: 'logs' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
neutralization: {
|
||||||
|
code: 'neutralization',
|
||||||
|
label: { de: 'Neutralisierung', en: 'Neutralization', fr: 'Neutralisation' },
|
||||||
|
icon: 'shield_check',
|
||||||
|
views: [
|
||||||
|
{ code: 'dashboard', label: { de: 'Neutralisierung testen', en: 'Test Neutralization', fr: 'Tester neutralisation' }, path: 'playground' },
|
||||||
|
{ code: 'playground', label: { de: 'Neutralisierung testen', en: 'Test Neutralization', fr: 'Tester neutralisation' }, path: 'playground' },
|
||||||
|
{ code: 'config', label: { de: 'Einstellungen', en: 'Settings', fr: 'Paramètres' }, path: 'config' },
|
||||||
|
{ code: 'attributes', label: { de: 'Attribute', en: 'Attributes', fr: 'Attributs' }, path: 'attributes' },
|
||||||
|
]
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue