added neutralization view

This commit is contained in:
Ida Dittrich 2026-02-23 11:21:32 +01:00
parent f312cd41b1
commit 1ff776c503
7 changed files with 990 additions and 0 deletions

View 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;
}

View file

@ -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 />,

View file

@ -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,
},
}; };
// ============================================================================= // =============================================================================

View 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 &amp; 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 &quot;Resolve Text&quot; 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;

View 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);
}

View file

@ -0,0 +1 @@
export { NeutralizationView } from './NeutralizationView';

View file

@ -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' },
]
},
}; };
// ============================================================================= // =============================================================================