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.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.neutralization': <FaShieldAlt />,
|
||||
'feature.trustee': <FaBriefcase />,
|
||||
'feature.realestate': <FaBuilding />,
|
||||
'feature.chatworkflow': <FaPlay />,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ import { TeamsbotDashboardView } from './views/teamsbot/TeamsbotDashboardView';
|
|||
import { TeamsbotSessionView } from './views/teamsbot/TeamsbotSessionView';
|
||||
import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView';
|
||||
|
||||
// Neutralization Views
|
||||
import { NeutralizationView } from './views/neutralization';
|
||||
|
||||
import styles from './FeatureView.module.css';
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -135,6 +138,10 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
|||
sessions: TeamsbotSessionView,
|
||||
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' },
|
||||
]
|
||||
},
|
||||
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