From 766a767d59800034d16b65b3f155c9e3ed65bc04 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Thu, 11 Jun 2026 21:26:44 +0200 Subject: [PATCH] ux cleanup panel basics --- src/api.ts | 82 ++++++++++++++----- .../FormGeneratorTable/FormGeneratorTable.tsx | 4 +- src/pages/ComplianceAuditPage.tsx | 19 +++-- src/pages/GDPR.tsx | 49 +++++------ src/pages/RagInventoryPage.tsx | 29 ++++++- src/pages/admin/AdminFeatureAccessPage.tsx | 30 ++++--- .../admin/AdminFeatureInstanceUsersPage.tsx | 26 +++--- src/pages/admin/AdminFeatureRolesPage.tsx | 6 +- src/pages/admin/AdminInvitationsPage.tsx | 26 ++++-- src/pages/admin/AdminMandateRolesPage.tsx | 33 ++++---- src/pages/admin/AdminMandatesPage.tsx | 14 +++- src/pages/admin/AdminUserMandatesPage.tsx | 17 ++-- src/pages/admin/AdminUsersPage.tsx | 20 +++-- .../wizards/AdminInvitationWizardPage.tsx | 18 +++- .../admin/wizards/AdminMandateWizardPage.tsx | 10 ++- .../admin/wizards/FeatureInstanceWizard.tsx | 26 +++--- src/pages/basedata/ConnectionsPage.tsx | 34 ++++---- src/pages/basedata/FilesPage.tsx | 5 +- src/pages/basedata/PromptsPage.tsx | 11 ++- src/pages/billing/AdminSubscriptionsPage.tsx | 19 +++-- src/pages/billing/BillingDataView.tsx | 1 + .../views/commcoach/CommcoachSettingsView.tsx | 11 ++- .../views/trustee/TrusteeDocumentsView.tsx | 28 ++++--- .../views/trustee/TrusteePositionsView.tsx | 32 +++++--- .../WorkflowTemplatesPage.tsx | 5 +- src/pages/views/workspace/WorkspaceInput.tsx | 7 +- 26 files changed, 358 insertions(+), 204 deletions(-) diff --git a/src/api.ts b/src/api.ts index 7902246..2ed2077 100644 --- a/src/api.ts +++ b/src/api.ts @@ -106,20 +106,29 @@ api.interceptors.request.use( } ); -// Add a response interceptor to handle token expiration +// Silent refresh: attempt token renewal before forcing re-login +let _isRefreshing = false; +let _refreshSubscribers: Array<(success: boolean) => void> = []; + +function _onRefreshDone(success: boolean) { + _refreshSubscribers.forEach(cb => cb(success)); + _refreshSubscribers = []; +} + api.interceptors.response.use( (response) => response, async (error) => { + const originalRequest = error.config; + if (error.response?.status === 401) { - // Don't redirect to login if the request was to a login endpoint - const isLoginEndpoint = error.config?.url?.includes('/login') || - error.config?.url?.includes('/api/local/login') || - error.config?.url?.includes('/api/msft/auth/login') || - error.config?.url?.includes('/api/google/auth/login'); - - // Don't redirect if we're on a public auth page (prevents redirect loops and allows public pages to work) + const isAuthEndpoint = originalRequest?.url?.includes('/login') || + originalRequest?.url?.includes('/api/local/login') || + originalRequest?.url?.includes('/api/local/refresh') || + originalRequest?.url?.includes('/api/msft/auth/login') || + originalRequest?.url?.includes('/api/google/auth/login'); + const pathname = window.location.pathname; - const isOnPublicAuthPage = pathname === '/login' || + const isOnPublicAuthPage = pathname === '/login' || pathname.startsWith('/login') || pathname === '/register' || pathname.startsWith('/register') || @@ -128,22 +137,55 @@ api.interceptors.response.use( pathname === '/password-reset-request' || pathname.startsWith('/password-reset-request') || pathname.startsWith('/invite'); - - if (!isLoginEndpoint && !isOnPublicAuthPage) { - // Clear local auth data (httpOnly cookies are cleared by backend) - sessionStorage.removeItem('auth_authority'); - clearUserDataCache(); - // Redirect to login - window.location.href = '/login'; + + if (isAuthEndpoint || isOnPublicAuthPage) { + return Promise.reject(error); } + + // Attempt silent refresh (only once per request) + if (!originalRequest._retryAfterRefresh) { + originalRequest._retryAfterRefresh = true; + + if (!_isRefreshing) { + _isRefreshing = true; + try { + await api.post('/api/local/refresh'); + _isRefreshing = false; + _onRefreshDone(true); + return api(originalRequest); + } catch { + _isRefreshing = false; + _onRefreshDone(false); + sessionStorage.removeItem('auth_authority'); + clearUserDataCache(); + window.location.href = '/login'; + return Promise.reject(error); + } + } else { + // Another request is already refreshing; queue this one + return new Promise((resolve, reject) => { + _refreshSubscribers.push((success: boolean) => { + if (success) { + resolve(api(originalRequest)); + } else { + reject(error); + } + }); + }); + } + } + + // Refresh already failed for this request + sessionStorage.removeItem('auth_authority'); + clearUserDataCache(); + window.location.href = '/login'; } - - // Handle rate limiting (429) - don't throw, just log and return error + + // Handle rate limiting (429) if (error.response?.status === 429) { console.warn('Rate limit exceeded (429). Please wait before making more requests.'); - // Don't cause cascading errors by throwing here } - + return Promise.reject(error); } ); diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 22432b6..417d787 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -3332,7 +3332,9 @@ export function FormGeneratorTable>({ colSpan={(selectable ? 1 : 0) + (hasActionColumn ? 1 : 0) + detectedColumns.length} style={{ textAlign: 'center', padding: '40px 16px', color: 'var(--text-muted, #64748b)' }} > - {emptyMessage || t('Keine Daten verfügbar')} + {supportsBackendPagination && !hookDataProp?.pagination + ? t('Lade Daten...') + : (emptyMessage || t('Keine Daten verfügbar'))} )} diff --git a/src/pages/ComplianceAuditPage.tsx b/src/pages/ComplianceAuditPage.tsx index 4737db4..ca15a1f 100644 --- a/src/pages/ComplianceAuditPage.tsx +++ b/src/pages/ComplianceAuditPage.tsx @@ -9,7 +9,7 @@ * Tab D: Neutralization Mappings — FormGeneratorTable + delete */ -import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, @@ -195,6 +195,10 @@ export const ComplianceAuditPage: React.FC = () => { const selectedEntryId = searchParams.get('entryId'); + const _lastAiLogParams = useRef(undefined); + const _lastAuditLogParams = useRef(undefined); + const _lastNeutParams = useRef(undefined); + // ── Mandate loader ── useEffect(() => { @@ -222,6 +226,8 @@ export const ComplianceAuditPage: React.FC = () => { const _loadAiLog = useCallback(async (paginationParams?: any) => { if (!selectedMandateId) return; + if (paginationParams) _lastAiLogParams.current = paginationParams; + else paginationParams = _lastAiLogParams.current; setAiLoading(true); try { const page = paginationParams?.page ?? 1; @@ -254,6 +260,8 @@ export const ComplianceAuditPage: React.FC = () => { const _loadAuditLog = useCallback(async (paginationParams?: any) => { if (!selectedMandateId) return; + if (paginationParams) _lastAuditLogParams.current = paginationParams; + else paginationParams = _lastAuditLogParams.current; setAuditLoading(true); try { const page = paginationParams?.page ?? 1; @@ -301,6 +309,8 @@ export const ComplianceAuditPage: React.FC = () => { const _loadNeutMappings = useCallback(async (paginationParams?: any) => { if (!selectedMandateId) return; + if (paginationParams) _lastNeutParams.current = paginationParams; + else paginationParams = _lastNeutParams.current; setNeutLoading(true); try { const page = paginationParams?.page ?? 1; @@ -369,10 +379,7 @@ export const ComplianceAuditPage: React.FC = () => { useEffect(() => { if (!selectedMandateId) return; - if (activeTab === 'ai-log') void _loadAiLog(); - else if (activeTab === 'audit-log') void _loadAuditLog(); - else if (activeTab === 'stats') void _loadStats({ dateFrom: statsPeriod.fromDate, dateTo: statsPeriod.toDate }); - else if (activeTab === 'neutralization') void _loadNeutMappings(); + if (activeTab === 'stats') void _loadStats({ dateFrom: statsPeriod.fromDate, dateTo: statsPeriod.toDate }); }, [activeTab, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps // ── Content view handler (modal) ── @@ -866,6 +873,7 @@ export const ComplianceAuditPage: React.FC = () => { filterable={true} searchable={true} selectable={false} + initialSort={[{ key: 'timestamp', direction: 'desc' }]} emptyMessage={t('Keine Audit-Einträge vorhanden.')} onRefresh={_loadAuditLog} hookData={auditLogHookData} @@ -891,6 +899,7 @@ export const ComplianceAuditPage: React.FC = () => { filterable={true} searchable={true} selectable={false} + initialSort={[{ key: 'timestamp', direction: 'desc' }]} emptyMessage={t('Keine AI-Audit-Einträge vorhanden.')} onRefresh={_loadAiLog} hookData={aiLogHookData} diff --git a/src/pages/GDPR.tsx b/src/pages/GDPR.tsx index 8482cd0..a6d477e 100644 --- a/src/pages/GDPR.tsx +++ b/src/pages/GDPR.tsx @@ -42,7 +42,8 @@ const _downloadJson = (data: unknown, fileName: string, mimeType = 'application/ export const GDPRPage: React.FC = () => { const { t } = useLanguage(); - const contactEmail = 'p.motsch@poweron.swiss'; + const contactEmail = 'privacy@poweron.swiss'; + const deleteWord = t('LOESCHEN'); const [consentInfo, setConsentInfo] = useState(null); const [isLoadingConsent, setIsLoadingConsent] = useState(true); const [consentError, setConsentError] = useState(null); @@ -123,8 +124,8 @@ export const GDPRPage: React.FC = () => { const handleDeleteAccount = async () => { setActionMessage(null); - if (deleteConfirmText !== 'LOESCHEN') { - setActionMessage({ type: 'error', text: t('Bitte geben Sie LOESCHEN ein, um die Löschung zu bestätigen.') }); + if (deleteConfirmText !== deleteWord) { + setActionMessage({ type: 'error', text: t('Bitte geben Sie {word} ein, um die Löschung zu bestätigen.', { word: deleteWord }) }); return; } @@ -227,14 +228,14 @@ export const GDPRPage: React.FC = () => {

{t('Diese Aktion ist unwiderruflich. Geben Sie {word} ein, um zu bestätigen.', { - word: 'LOESCHEN', + word: deleteWord, })}

setDeleteConfirmText(event.target.value)} - placeholder="LOESCHEN" + placeholder={deleteWord} disabled={isDeleting} />
@@ -251,7 +252,7 @@ export const GDPRPage: React.FC = () => {
diff --git a/src/pages/RagInventoryPage.tsx b/src/pages/RagInventoryPage.tsx index 2ab932f..4a431cd 100644 --- a/src/pages/RagInventoryPage.tsx +++ b/src/pages/RagInventoryPage.tsx @@ -14,7 +14,8 @@ import { useSearchParams } from 'react-router-dom'; import { useLanguage } from '../providers/language/LanguageContext'; import { useApiRequest } from '../hooks/useApi'; import { useConfirm } from '../hooks/useConfirm'; -import type { RagInventoryDto, RagConnectionDto, RagFeatureInstanceDto } from '../api/connectionApi'; +import type { RagInventoryDto, RagConnectionDto, RagDataSourceDto, RagFeatureInstanceDto } from '../api/connectionApi'; +import api from '../api'; import { FaDatabase, FaStop, FaSync, FaToggleOn, FaToggleOff, FaRedo, FaExclamationTriangle, FaCheckCircle, FaSlidersH, FaCubes } from 'react-icons/fa'; import { mandateDisplayLabel } from '../utils/mandateDisplayUtils'; import { DataSourceSettingsModal } from '../components/UnifiedDataBar/DataSourceSettingsModal'; @@ -169,6 +170,15 @@ export const RagInventoryPage: React.FC = () => { } catch {} }; + const _handleDsRagToggle = async (connectionId: string, ds: RagDataSourceDto) => { + const nodeKey = `ds|${connectionId}|${ds.sourceType}|${ds.path}`; + const newValue = !ds.ragIndexEnabled; + try { + await api.post(`/api/udb/node/${encodeURIComponent(nodeKey)}/flag/ragIndexEnabled`, { value: newValue }); + _fetchInventory(); + } catch {} + }; + const _handleConsentToggle = async (connectionId: string, currentEnabled: boolean) => { if (currentEnabled) { const ok = await confirm(t('Alle indexierten Inhalte dieser Verbindung werden entfernt. Fortfahren?'), { variant: 'danger', confirmLabel: t('Fortfahren') }); @@ -309,6 +319,14 @@ export const RagInventoryPage: React.FC = () => { {t('{f} Dateien · {c} Chunks', { f: conn.totalFiles, c: conn.totalChunks })} )} +
))} {conn.dataSources.length === 0 && ( diff --git a/src/pages/admin/AdminFeatureAccessPage.tsx b/src/pages/admin/AdminFeatureAccessPage.tsx index 06b35a9..5fa65c6 100644 --- a/src/pages/admin/AdminFeatureAccessPage.tsx +++ b/src/pages/admin/AdminFeatureAccessPage.tsx @@ -63,8 +63,8 @@ export const AdminFeatureAccessPage: React.FC = () => { const [createFeatureCode, setCreateFeatureCode] = useState(''); const [createLabel, setCreateLabel] = useState(''); // Label field value - // Ref to track form data for featureCode detection const formDataRef = useRef>({}); + const _lastTableParams = useRef(undefined); // Load features, mandates, and attributes on mount useEffect(() => { @@ -82,12 +82,22 @@ export const AdminFeatureAccessPage: React.FC = () => { }).catch(() => setBackendAttributes([])); }, [fetchFeatures, fetchMandates]); - // Load instances when mandate changes + const _refetchInstances = React.useCallback(async (paginationParams?: any) => { + if (!selectedMandateId) return; + if (paginationParams && typeof paginationParams === 'object') { + _lastTableParams.current = paginationParams; + } else { + paginationParams = _lastTableParams.current; + } + if (paginationParams) return fetchInstances(paginationParams); + return fetchInstances(selectedMandateId); + }, [selectedMandateId, fetchInstances]); + useEffect(() => { if (selectedMandateId) { - fetchInstances(selectedMandateId); + _lastTableParams.current = undefined; } - }, [selectedMandateId, fetchInstances]); + }, [selectedMandateId]); const _rawColumns: ColumnConfig[] = useMemo(() => [ { key: 'label', label: t('Name'), sortable: true, filterable: true, searchable: true, width: 200 }, @@ -147,8 +157,8 @@ export const AdminFeatureAccessPage: React.FC = () => { setCreateFeatureCode(''); setCreateLabel(''); formDataRef.current = {}; - fetchInstances(selectedMandateId); - loadFeatures(); // Refresh global navigation cache + _refetchInstances(); + loadFeatures(); showSuccess(t('Feature-Instanz erstellt'), t('Die Instanz "{name}" wurde erfolgreich erstellt.', { name: createLabel })); } else { showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Feature-Instanz')); @@ -185,8 +195,8 @@ export const AdminFeatureAccessPage: React.FC = () => { if (result.success) { setShowEditModal(false); setEditingInstance(null); - fetchInstances(selectedMandateId); - loadFeatures(); // Refresh global navigation cache + _refetchInstances(); + loadFeatures(); showSuccess(t('Feature-Instanz aktualisiert'), t('Die Instanz "{name}" wurde erfolgreich aktualisiert.', { name: data.label })); } else { showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Feature-Instanz')); @@ -315,7 +325,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
@@ -229,7 +235,7 @@ export const AdminMandatesPage: React.FC = () => { @@ -207,7 +213,7 @@ export const AdminUsersPage: React.FC = () => { @@ -324,7 +328,7 @@ export const ConnectionsPage: React.FC = () => {
@@ -204,7 +212,7 @@ export const TrusteeDocumentsView: React.FC = () => { @@ -354,7 +362,7 @@ export const TrusteePositionsView: React.FC = () => {