diff --git a/src/components/FormGenerator/ActionButtons/ActionButton.module.css b/src/components/FormGenerator/ActionButtons/ActionButton.module.css index ee9efca..3c382b9 100644 --- a/src/components/FormGenerator/ActionButtons/ActionButton.module.css +++ b/src/components/FormGenerator/ActionButtons/ActionButton.module.css @@ -161,6 +161,15 @@ background: var(--color-secondary-hover); } +.actionButton.copy { + background: var(--color-secondary); + color: white; +} + +.actionButton.copy:hover { + background: var(--color-secondary-hover); +} + /* Responsive Design */ @media (max-width: 768px) { .actionButtons { @@ -215,4 +224,12 @@ .actionButton.view:hover { background: var(--color-secondary-hover); } + + .actionButton.copy { + background: var(--color-secondary); + } + + .actionButton.copy:hover { + background: var(--color-secondary-hover); + } } diff --git a/src/components/FormGenerator/ActionButtons/CopyActionButton/CopyActionButton.tsx b/src/components/FormGenerator/ActionButtons/CopyActionButton/CopyActionButton.tsx new file mode 100644 index 0000000..0f2d122 --- /dev/null +++ b/src/components/FormGenerator/ActionButtons/CopyActionButton/CopyActionButton.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react'; +import { IoCopy } from 'react-icons/io5'; +import { useLanguage } from '../../../../contexts/LanguageContext'; +import styles from '../ActionButton.module.css'; + +export interface CopyActionButtonProps { + row: T; + onCopy?: (row: T) => Promise | void; + disabled?: boolean | { disabled: boolean; message?: string }; + loading?: boolean; + className?: string; + title?: string; + isCopying?: boolean; + hookData?: any; // Contains all hook data including operations + // Field mappings + idField?: string; // Field name for the unique identifier + nameField?: string; // Field name for display name + contentField?: string; // Field name for content + loadingStateName?: string; // Name of the loading state in hookData + operationName?: string; // Name of the operation function in hookData +} + +export function CopyActionButton({ + row, + onCopy, + disabled = false, + loading = false, + className = '', + title, + isCopying = false, + hookData, + idField = 'id', + nameField = 'name', + contentField = 'content', + loadingStateName = 'creatingPrompt', + operationName = 'handlePromptCreate' +}: CopyActionButtonProps) { + const { t } = useLanguage(); + const [internalLoading, setInternalLoading] = useState(false); + const [showCopiedFeedback, setShowCopiedFeedback] = useState(false); + + // Extract disabled state and tooltip message + const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false; + const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined; + + const handleClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!isDisabled && !loading && !isCopying && !internalLoading) { + setInternalLoading(true); + try { + // If operationName is provided and hookData is available, use the hook function + if (operationName && hookData && hookData[operationName]) { + // Extract data from row for creating a copy + const copyData = { + name: `${(row as any)[nameField]} (Copy)`, + content: (row as any)[contentField], + mandateId: (row as any).mandateId + }; + + const result = await hookData[operationName](copyData); + + if (result.success) { + // Show copied feedback + setShowCopiedFeedback(true); + setTimeout(() => setShowCopiedFeedback(false), 2000); + + // Refetch to update the list + if (hookData.refetch) { + await hookData.refetch(); + } + } + } else if (onCopy) { + // Fallback to the provided onCopy function + await onCopy(row); + setShowCopiedFeedback(true); + setTimeout(() => setShowCopiedFeedback(false), 2000); + } else { + console.error('No copy function available'); + } + } catch (error) { + console.error('Copy failed:', error); + } finally { + setInternalLoading(false); + } + } + }; + + const buttonTitle = title || t('prompts.action.copy', 'Copy'); + // Use hookData copying state if available, otherwise use passed isCopying + const loadingState = hookData?.[loadingStateName]; + const actualIsCopying = (typeof loadingState === 'boolean' && loadingState) || isCopying; + const isLoading = loading || actualIsCopying || internalLoading; + + // Determine the final button title (tooltip) + const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle; + + return ( + + ); +} + +export default CopyActionButton; + diff --git a/src/components/FormGenerator/ActionButtons/CopyActionButton/index.ts b/src/components/FormGenerator/ActionButtons/CopyActionButton/index.ts new file mode 100644 index 0000000..aa9bada --- /dev/null +++ b/src/components/FormGenerator/ActionButtons/CopyActionButton/index.ts @@ -0,0 +1,3 @@ +export { default as CopyActionButton } from './CopyActionButton'; +export type { CopyActionButtonProps } from './CopyActionButton'; + diff --git a/src/components/FormGenerator/ActionButtons/DeleteActionButton/DeleteActionButton.tsx b/src/components/FormGenerator/ActionButtons/DeleteActionButton/DeleteActionButton.tsx index e505c67..78ed614 100644 --- a/src/components/FormGenerator/ActionButtons/DeleteActionButton/DeleteActionButton.tsx +++ b/src/components/FormGenerator/ActionButtons/DeleteActionButton/DeleteActionButton.tsx @@ -35,7 +35,7 @@ export function DeleteActionButton({ hookData, idField = 'id', operationName = 'handleDelete', - loadingStateName = 'deletingFiles' + loadingStateName = 'deletingItems' }: DeleteActionButtonProps) { const { t } = useLanguage(); const [isConfirming, setIsConfirming] = useState(false); @@ -52,7 +52,7 @@ export function DeleteActionButton({ // Extract operations from hookData const handleDelete = hookData[operationName]; - const removeOptimistically = hookData.removeFileOptimistically || hookData.removeOptimistically; + const removeOptimistically = hookData.removeOptimistically || hookData.removeFileOptimistically; const refetch = hookData.refetch; const loadingState = hookData[loadingStateName]; @@ -117,18 +117,26 @@ export function DeleteActionButton({ const success = await handleDelete(itemId); if (success) { - // Refetch in background to sync with backend (non-blocking) - refetch(); // Non-blocking - let it run in background + // If we used optimistic removal, delay refetch to ensure server has synced + if (removeOptimistically) { + // Delay refetch by 500ms to give server time to fully process deletion + setTimeout(() => { + refetch(); + }, 500); + } else { + // No optimistic removal, refetch immediately + refetch(); + } onSuccess?.(row); } else { - // Refetch to restore the file in case of failure + // Refetch to restore the item in case of failure await refetch(); onError?.(row, 'Delete failed'); } } catch (error: any) { console.error('Delete failed:', error); onError?.(row, error.message || 'Delete failed'); - // Refetch to restore the file in case of failure + // Refetch to restore the item in case of failure await refetch(); } finally { setIsDeleting(false); @@ -140,7 +148,7 @@ export function DeleteActionButton({ setIsConfirming(false); }; - const buttonTitle = title || t('files.action.delete', 'Delete'); + const buttonTitle = title || t('common.delete', 'Delete'); const confirmButtonTitle = confirmTitle || t('formgen.delete.confirm', 'Confirm delete'); const cancelButtonTitle = cancelTitle || t('formgen.delete.cancel', 'Cancel delete'); diff --git a/src/components/FormGenerator/ActionButtons/index.ts b/src/components/FormGenerator/ActionButtons/index.ts index aae3d32..e25af69 100644 --- a/src/components/FormGenerator/ActionButtons/index.ts +++ b/src/components/FormGenerator/ActionButtons/index.ts @@ -3,9 +3,11 @@ export { EditActionButton } from './EditActionButton'; export { DeleteActionButton } from './DeleteActionButton'; export { DownloadActionButton } from './DownloadActionButton'; export { ViewActionButton } from './ViewActionButton'; +export { CopyActionButton } from './CopyActionButton'; // Action Button Types export type { EditActionButtonProps } from './EditActionButton'; export type { DeleteActionButtonProps } from './DeleteActionButton'; export type { DownloadActionButtonProps } from './DownloadActionButton'; export type { ViewActionButtonProps } from './ViewActionButton'; +export type { CopyActionButtonProps } from './CopyActionButton'; diff --git a/src/components/FormGenerator/FormGenerator.tsx b/src/components/FormGenerator/FormGenerator.tsx index ae879c5..658fc3a 100644 --- a/src/components/FormGenerator/FormGenerator.tsx +++ b/src/components/FormGenerator/FormGenerator.tsx @@ -5,7 +5,8 @@ import { EditActionButton, DeleteActionButton, DownloadActionButton, - ViewActionButton + ViewActionButton, + CopyActionButton } from './ActionButtons'; import { Button } from '../ui/Button'; @@ -46,7 +47,7 @@ export interface FormGeneratorProps { isRowSelectable?: (row: T) => boolean; loading?: boolean; actionButtons?: { - type: 'edit' | 'delete' | 'download' | 'view'; + type: 'edit' | 'delete' | 'download' | 'view' | 'copy'; onAction?: (row: T) => Promise | void; // Optional for delete buttons since they handle their own logic disabled?: (row: T) => boolean | { disabled: boolean; message?: string }; loading?: (row: T) => boolean; @@ -58,6 +59,7 @@ export interface FormGeneratorProps { idField?: string; // Field name for the unique identifier nameField?: string; // Field name for display name typeField?: string; // Field name for type/mime type + contentField?: string; // Field name for content (used by copy button) // Operation and loading state names operationName?: string; // Name of the operation function in hookData loadingStateName?: string; // Name of the loading state in hookData @@ -784,6 +786,7 @@ export function FormGenerator>({ idField: actionButton.idField ?? 'id', nameField: actionButton.nameField ?? 'name', typeField: actionButton.typeField ?? 'type', + contentField: actionButton.contentField ?? 'content', operationName: actionButton.operationName, loadingStateName: actionButton.loadingStateName }; @@ -803,6 +806,8 @@ export function FormGenerator>({ return {})} isDownloading={isProcessing} hookData={hookData} operationName={actionButton.operationName} />; case 'view': return {})} isViewing={isProcessing} hookData={hookData} />; + case 'copy': + return ; default: return null; } diff --git a/src/components/ui/Button/ButtonTypes.ts b/src/components/ui/Button/ButtonTypes.ts index 266bb01..85c11ac 100644 --- a/src/components/ui/Button/ButtonTypes.ts +++ b/src/components/ui/Button/ButtonTypes.ts @@ -26,3 +26,26 @@ export interface UploadButtonProps extends BaseButtonProps { icon?: IconType; iconPosition?: 'left' | 'right'; } + +export interface CreateButtonFieldConfig { + key: string; + label: string; + type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly'; + required?: boolean; + placeholder?: string; + minRows?: number; + maxRows?: number; + validator?: (value: any) => string | null; + defaultValue?: any; +} + +export interface CreateButtonProps extends BaseButtonProps { + onCreate: (data: any) => Promise; + fields: CreateButtonFieldConfig[]; + popupTitle?: string; + popupSize?: 'small' | 'medium' | 'large'; + icon?: IconType; + iconPosition?: 'left' | 'right'; + onSuccess?: (result: any) => void; + onError?: (error: string) => void; +} \ No newline at end of file diff --git a/src/components/ui/Button/CreateButton/CreateButton.tsx b/src/components/ui/Button/CreateButton/CreateButton.tsx new file mode 100644 index 0000000..c82d0d2 --- /dev/null +++ b/src/components/ui/Button/CreateButton/CreateButton.tsx @@ -0,0 +1,140 @@ +import React, { useState } from 'react'; +import { CreateButtonProps } from '../ButtonTypes'; +import Button from '../Button'; +import { Popup, EditForm } from '../../Popup'; +import { useLanguage } from '../../../../contexts/LanguageContext'; + +const CreateButton: React.FC = ({ + onCreate, + fields, + popupTitle = 'Create New Item', + popupSize = 'medium', + disabled = false, + loading = false, + className = '', + children, + icon, + iconPosition = 'left', + variant = 'primary', + size = 'md', + onSuccess, + onError, + ...props +}) => { + const { t } = useLanguage(); + const [isCreating, setIsCreating] = useState(false); + const [isPopupOpen, setIsPopupOpen] = useState(false); + const [formData, setFormData] = useState({}); + + // Initialize form data with default values + React.useEffect(() => { + const initialData: any = {}; + fields.forEach(field => { + initialData[field.key] = field.defaultValue || ''; + }); + setFormData(initialData); + }, [fields]); + + const handleButtonClick = () => { + if (!disabled && !loading && !isCreating) { + // Reset form data + const initialData: any = {}; + fields.forEach(field => { + initialData[field.key] = field.defaultValue || ''; + }); + setFormData(initialData); + setIsPopupOpen(true); + } + }; + + const handleSave = async (updatedData: any) => { + setIsCreating(true); + + try { + const result = await onCreate(updatedData); + + if (result?.success !== false) { + // Success + setIsPopupOpen(false); + if (onSuccess) { + onSuccess(result); + } + } else { + // Handle error + if (onError) { + onError(result?.error || 'Creation failed'); + } + } + } catch (error: any) { + console.error('Creation failed:', error); + if (onError) { + onError(error.message || 'Creation failed'); + } + } finally { + setIsCreating(false); + } + }; + + const handleCancel = () => { + setIsPopupOpen(false); + }; + + const isDisabled = disabled || loading || isCreating; + + // Resolve language text for popup title + const resolvedPopupTitle = typeof popupTitle === 'string' + ? t(popupTitle, popupTitle) + : popupTitle; + + // Resolve language text for fields + const resolvedFields = fields.map(field => ({ + ...field, + label: typeof field.label === 'string' ? t(field.label, field.label) : field.label, + placeholder: field.placeholder + ? (typeof field.placeholder === 'string' ? t(field.placeholder, field.placeholder) : field.placeholder) + : undefined, + editable: true + })); + + return ( + <> + + + {/* Create Popup */} + + + + + ); +}; + +export default CreateButton; + diff --git a/src/components/ui/Button/CreateButton/index.ts b/src/components/ui/Button/CreateButton/index.ts new file mode 100644 index 0000000..33d7c17 --- /dev/null +++ b/src/components/ui/Button/CreateButton/index.ts @@ -0,0 +1,3 @@ +export { default as CreateButton } from './CreateButton'; +export { default } from './CreateButton'; + diff --git a/src/components/ui/Button/index.ts b/src/components/ui/Button/index.ts index e283a04..299b56c 100644 --- a/src/components/ui/Button/index.ts +++ b/src/components/ui/Button/index.ts @@ -1,2 +1,4 @@ export { default as Button } from './Button'; +export { UploadButton } from './UploadButton'; +export { CreateButton } from './CreateButton'; export * from './ButtonTypes'; diff --git a/src/core/PageManager/PageRenderer.tsx b/src/core/PageManager/PageRenderer.tsx index 5555437..43188af 100644 --- a/src/core/PageManager/PageRenderer.tsx +++ b/src/core/PageManager/PageRenderer.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { GenericPageData, PageButton, PageContent, resolveLanguageText } from './pageInterface'; import { FormGenerator } from '../../components/FormGenerator'; -import { Button, UploadButton } from '../../components/ui'; +import { Button, UploadButton, CreateButton } from '../../components/ui'; import { DragDropOverlay } from '../../components/ui/DragDropOverlay'; import { useLanguage } from '../../contexts/LanguageContext'; import styles from '../../styles/pages.module.css'; @@ -253,24 +253,59 @@ const PageRenderer: React.FC = ({ {pageData.headerButtons && pageData.headerButtons.length > 0 && (
{pageData.headerButtons.map((button) => { - // Check if this is an upload button - if (button.id === 'upload-file') { - const handleUpload = (hookData as any)?.handleUpload; + // Check if this is an upload button (has handleUpload in hookData) + const handleUpload = (hookData as any)?.handleUpload; + if (handleUpload && button.id === 'upload-file') { + return ( + + {resolveLanguageText(button.label, t)} + + ); + } + + // Check if this button has a formConfig (create button) + if (button.formConfig && hookData) { + const createOperation = button.formConfig.createOperationName + ? (hookData as any)[button.formConfig.createOperationName] + : null; - if (handleUpload) { + if (createOperation) { + // Resolve field labels + const resolvedFields = button.formConfig.fields.map(field => ({ + ...field, + label: resolveLanguageText(field.label, t), + placeholder: field.placeholder ? resolveLanguageText(field.placeholder, t) : undefined + })); + return ( - { + // Refetch data after successful creation + if (hookData.refetch) { + hookData.refetch(); + } + }} > {resolveLanguageText(button.label, t)} - + ); } } diff --git a/src/core/PageManager/data/pages/files.ts b/src/core/PageManager/data/pages/files.ts index 9561d21..1949241 100644 --- a/src/core/PageManager/data/pages/files.ts +++ b/src/core/PageManager/data/pages/files.ts @@ -212,7 +212,7 @@ export const filesPageData: GenericPageData = { title: 'files.action.delete', idField: 'id', operationName: 'handleDelete', - loadingStateName: 'deletingFiles' + loadingStateName: 'deletingFiles' // Keep for backward compatibility } ], searchable: true, @@ -232,6 +232,7 @@ export const filesPageData: GenericPageData = { // Page behavior persistent: false, preload: false, + preserveState: true, // Keep page mounted and prevent refetching moduleEnabled: true, // Sidebar - will be shown as subpage under Administration diff --git a/src/core/PageManager/data/pages/index.ts b/src/core/PageManager/data/pages/index.ts index 555d6fb..6f8f77e 100644 --- a/src/core/PageManager/data/pages/index.ts +++ b/src/core/PageManager/data/pages/index.ts @@ -3,12 +3,14 @@ export { dashboardPageData } from './dashboard'; export { filesPageData } from './files'; export { teamBereichPageData } from './team-bereich'; export { administrationPageData } from './administration'; +export { promptsPageData } from './prompts'; // Import all page data import { dashboardPageData } from './dashboard'; import { administrationPageData } from './administration'; import { filesPageData } from './files'; import { teamBereichPageData } from './team-bereich'; +import { promptsPageData } from './prompts'; // Array of all page data export const allPageData = [ @@ -16,6 +18,7 @@ export const allPageData = [ administrationPageData, filesPageData, teamBereichPageData, + promptsPageData, ]; diff --git a/src/core/PageManager/data/pages/prompts.ts b/src/core/PageManager/data/pages/prompts.ts new file mode 100644 index 0000000..f09572d --- /dev/null +++ b/src/core/PageManager/data/pages/prompts.ts @@ -0,0 +1,247 @@ +import { useCallback } from 'react'; +import { GenericPageData } from '../../pageInterface'; +import { FaLightbulb, FaPlus } from 'react-icons/fa'; +import { privilegeCheckers } from '../../../../utils/privilegeCheckers'; +import { usePrompts, usePromptOperations } from '../../../../hooks/usePrompts'; + +// Hook factory function for prompts data +const createPromptsHook = () => { + return () => { + const { prompts, loading, error, refetch, removeOptimistically } = usePrompts(); + const { + handlePromptDelete, + handlePromptCreate, + handlePromptUpdate, + deletingPrompts, + creatingPrompt, + deleteError, + createError, + updateError + } = usePromptOperations(); + + // Wrapped create handler that adds mandateId automatically + const wrappedHandlePromptCreate = useCallback(async (formData: { name: string; content: string }) => { + // Get mandateId from the first prompt if available, or use empty string + const mandateId = prompts.length > 0 ? prompts[0].mandateId : ''; + + const promptData = { + name: formData.name, + content: formData.content, + mandateId: mandateId + }; + + return await handlePromptCreate(promptData); + }, [handlePromptCreate, prompts]); + + // Handle single prompt deletion for FormGenerator + const handleDeleteSingle = useCallback(async (prompt: any) => { + const success = await handlePromptDelete(prompt.id); + + if (success) { + refetch(); + } + }, [handlePromptDelete, refetch]); + + // Handle multiple prompt deletion for FormGenerator + const handleDeleteMultiple = useCallback(async (selectedPrompts: any[]) => { + const promptIds = selectedPrompts.map(prompt => prompt.id); + const results = await Promise.all( + promptIds.map(id => handlePromptDelete(id)) + ); + + const allSuccessful = results.every(result => result); + + if (allSuccessful) { + refetch(); + } + }, [handlePromptDelete, refetch]); + + return { + data: prompts, + loading, + error, + refetch, + removeOptimistically, // Expose optimistic removal (generic name for DeleteActionButton) + // Operations + handleDelete: handlePromptDelete, + handleDeleteMultiple, + handlePromptCreate: wrappedHandlePromptCreate, // Use wrapped version + handlePromptUpdate, + // FormGenerator specific handlers + onDelete: handleDeleteSingle, + onDeleteMultiple: handleDeleteMultiple, + // Loading states + deletingPrompts, + creatingPrompt, + // Error states + deleteError, + createError, + updateError + }; + }; +}; + +// Static columns configuration for prompts table +const promptsColumns = [ + { + key: 'name', + label: 'prompts.column.name', + type: 'string', + width: 250, + minWidth: 150, + maxWidth: 350, + sortable: true, + filterable: true, + searchable: true + }, + { + key: 'content', + label: 'prompts.column.content', + type: 'string', + width: 400, + minWidth: 200, + maxWidth: 600, + sortable: true, + filterable: true, + searchable: true + } +]; + +export const promptsPageData: GenericPageData = { + id: 'administration-prompts', + path: 'administration/prompts', + name: 'prompts.title', + description: 'prompts.description', + + // Parent page + parentPath: 'administration', + + // Visual + icon: FaLightbulb, + title: 'prompts.title', + subtitle: 'prompts.subtitle', + + // Header buttons + headerButtons: [ + { + id: 'new-prompt', + label: 'prompts.new_button', + icon: FaPlus, + variant: 'primary', + formConfig: { + fields: [ + { + key: 'name', + label: 'prompts.field.name', + type: 'string', + required: true, + placeholder: 'prompts.field.name', + validator: (value: string) => { + if (!value || value.trim() === '') { + return 'Prompt name cannot be empty'; + } + if (value.length > 100) { + return 'Prompt name cannot exceed 100 characters'; + } + return null; + } + }, + { + key: 'content', + label: 'prompts.field.content', + type: 'textarea', + required: true, + placeholder: 'prompts.field.content', + minRows: 6, + maxRows: 12, + validator: (value: string) => { + if (!value || value.trim() === '') { + return 'Prompt content cannot be empty'; + } + if (value.length > 10000) { + return 'Prompt content cannot exceed 10,000 characters'; + } + return null; + } + } + ], + popupTitle: 'prompts.modal.create.title', + popupSize: 'medium', + createOperationName: 'handlePromptCreate', + successMessage: 'prompts.create.success', + errorMessage: 'prompts.create.error' + } + } + ], + + // Content sections - using generic table approach + content: [ + { + id: 'prompts-table', + type: 'table', + tableConfig: { + hookFactory: createPromptsHook, + columns: promptsColumns, + actionButtons: [ + { + type: 'edit', + title: 'prompts.action.edit', + idField: 'id', + nameField: 'name', + operationName: 'handlePromptUpdate', + loadingStateName: 'updatingPrompts' + }, + { + type: 'delete', + title: 'prompts.action.delete', + idField: 'id', + operationName: 'handleDelete', + loadingStateName: 'deletingPrompts' // Entity-specific loading state + }, + { + type: 'copy', + title: 'prompts.action.copy', + idField: 'id', + nameField: 'name', + contentField: 'content', + operationName: 'handlePromptCreate', + loadingStateName: 'creatingPrompt' + } + ], + searchable: true, + filterable: true, + sortable: true, + resizable: true, + pagination: true, + pageSize: 10, + className: 'prompts-table' + } + } + ], + + // Privilege system + privilegeChecker: privilegeCheckers.viewerRole, + + // Page behavior + persistent: false, + preload: false, + preserveState: true, // Keep page mounted and prevent refetching + moduleEnabled: true, + + // shown in sidebar under administration + showInSidebar: false, + + // No drag and drop configuration for prompts + + // Lifecycle hooks + onActivate: async () => { + if (import.meta.env.DEV) console.log('Prompts activated'); + }, + onLoad: async () => { + if (import.meta.env.DEV) console.log('Prompts loaded - can initialize prompts list here'); + }, + onUnload: async () => { + if (import.meta.env.DEV) console.log('Prompts unloaded - cleanup prompts references'); + } +}; + diff --git a/src/core/PageManager/pageInterface.ts b/src/core/PageManager/pageInterface.ts index eb1add7..6d4af91 100644 --- a/src/core/PageManager/pageInterface.ts +++ b/src/core/PageManager/pageInterface.ts @@ -5,6 +5,19 @@ import { DragDropConfig } from '../../components/ui/DragDropOverlay/DragDropOver // Generic privilege checker function type export type PrivilegeChecker = () => boolean | Promise; +// Form field configuration for create/edit buttons +export interface ButtonFormField { + key: string; + label: string | LanguageText; + type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly'; + required?: boolean; + placeholder?: string | LanguageText; + minRows?: number; + maxRows?: number; + validator?: (value: any) => string | null; + defaultValue?: any; +} + // Button configuration for header actions export interface PageButton { id: string; @@ -15,6 +28,15 @@ export interface PageButton { onClick?: (hookData?: any) => void | Promise; disabled?: boolean; privilegeChecker?: PrivilegeChecker; + // Form configuration for create buttons + formConfig?: { + fields: ButtonFormField[]; + popupTitle?: string | LanguageText; + popupSize?: 'small' | 'medium' | 'large'; + createOperationName?: string; // Name of the create operation in hookData (e.g., 'handlePromptCreate') + successMessage?: string | LanguageText; + errorMessage?: string | LanguageText; + }; } // Content section for paragraphs @@ -54,7 +76,7 @@ export interface GenericDataHook { // Action button configuration export interface ActionButtonConfig { - type: 'view' | 'edit' | 'download' | 'delete'; + type: 'view' | 'edit' | 'download' | 'delete' | 'copy'; onAction?: (row: any) => Promise | void; // Optional for delete buttons since they handle their own logic title?: string | LanguageText; disabled?: (row: any) => boolean | { disabled: boolean; message?: string }; @@ -63,6 +85,7 @@ export interface ActionButtonConfig { idField?: string; // Field name for the unique identifier (default: 'id') nameField?: string; // Field name for display name (default: 'name' or 'file_name') typeField?: string; // Field name for type/mime type (default: 'type' or 'mime_type') + contentField?: string; // Field name for content (default: 'content') // Operation and loading state names operationName?: string; // Name of the operation function in hookData loadingStateName?: string; // Name of the loading state in hookData diff --git a/src/hooks/useAuthentication.ts b/src/hooks/useAuthentication.ts index 9fce963..0197663 100644 --- a/src/hooks/useAuthentication.ts +++ b/src/hooks/useAuthentication.ts @@ -935,13 +935,79 @@ export function useLogout() { try { // Call logout endpoint to clear JWT tokens on server - await api.post('/api/local/logout'); + const logoutResponse = await api.post('/api/local/logout'); - // Clear local storage (user data and auth_authority) - // Note: JWT tokens are now stored in httpOnly cookies and cleared by backend + console.log('✅ Logout API call completed, waiting for browser to process cookies...'); + + // CRITICAL: Wait for browser to process Set-Cookie headers from logout response + // This gives the browser time to clear httpOnly cookies before redirect + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Clear all authentication-related localStorage items localStorage.removeItem('currentUser'); localStorage.removeItem('auth_authority'); + // Clear MSAL cache tokens from localStorage + // MSAL stores tokens with keys starting with 'msal.' + const keysToRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && ( + key.startsWith('msal.') || + key === 'auth_token' || + key === 'refresh_token' || + key.includes('token') || + key.includes('auth') || + key.includes('msal') + )) { + keysToRemove.push(key); + } + } + keysToRemove.forEach(key => { + console.log('🗑️ Removing token:', key); + localStorage.removeItem(key); + }); + + // Clear debug items and other auth-related data + localStorage.removeItem('msft_auth_debug'); + localStorage.removeItem('msft_cookie_debug'); + + // Clear ALL MSAL cache data (including account keys, token keys, version) + const msalKeysToRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith('msal.')) { + msalKeysToRemove.push(key); + } + } + msalKeysToRemove.forEach(key => { + console.log('🗑️ Removing MSAL cache:', key); + localStorage.removeItem(key); + }); + + // Clear sessionStorage as well (CSRF tokens, etc.) + sessionStorage.clear(); + + // Clear cookies as backup (in case backend doesn't clear them properly) + // Note: This only works for cookies that are accessible to JavaScript + console.log('🍪 Checking cookies for cleanup...'); + console.log('🍪 All cookies:', document.cookie); + + const cookies = document.cookie.split(";"); + console.log('🍪 Cookie count:', cookies.length); + + cookies.forEach(function(c) { + const cookieName = c.split("=")[0].trim(); + console.log('🍪 Checking cookie:', cookieName); + + if (cookieName === 'auth_token' || cookieName === 'refresh_token' || cookieName.includes('token') || cookieName.includes('msal')) { + console.log('🗑️ Clearing cookie:', cookieName); + document.cookie = cookieName + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + } + }); + + console.log('🍪 Cookies after cleanup attempt:', document.cookie); + // Redirect to login page window.location.href = '/login?logout=true'; } catch (error: any) { @@ -954,9 +1020,58 @@ export function useLogout() { setError(errorMessage); // Even if logout fails on server, clear local data and redirect - // Note: JWT tokens are now stored in httpOnly cookies and cleared by backend localStorage.removeItem('currentUser'); localStorage.removeItem('auth_authority'); + + // Clear MSAL cache tokens from localStorage + const keysToRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && ( + key.startsWith('msal.') || + key === 'auth_token' || + key === 'refresh_token' || + key.includes('token') || + key.includes('auth') || + key.includes('msal') + )) { + keysToRemove.push(key); + } + } + keysToRemove.forEach(key => { + console.log('🗑️ Removing token (error case):', key); + localStorage.removeItem(key); + }); + + // Clear debug items and other auth-related data + localStorage.removeItem('msft_auth_debug'); + localStorage.removeItem('msft_cookie_debug'); + + // Clear ALL MSAL cache data (including account keys, token keys, version) + const msalKeysToRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith('msal.')) { + msalKeysToRemove.push(key); + } + } + msalKeysToRemove.forEach(key => { + console.log('🗑️ Removing MSAL cache (error case):', key); + localStorage.removeItem(key); + }); + + // Clear sessionStorage as well + sessionStorage.clear(); + + // Clear cookies as backup (in case backend doesn't clear them properly) + document.cookie.split(";").forEach(function(c) { + const cookieName = c.split("=")[0].trim(); + if (cookieName === 'auth_token' || cookieName === 'refresh_token' || cookieName.includes('token') || cookieName.includes('msal')) { + console.log('🗑️ Clearing cookie (error case):', cookieName); + document.cookie = cookieName + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + } + }); + window.location.href = '/login?logout=true'; } finally { setIsLoading(false); diff --git a/src/hooks/usePrompts.ts b/src/hooks/usePrompts.ts index 0eee636..b23948c 100644 --- a/src/hooks/usePrompts.ts +++ b/src/hooks/usePrompts.ts @@ -32,11 +32,22 @@ export function usePrompts() { } }; + // Optimistically remove a prompt from the local state + const removeOptimistically = (promptId: string) => { + setPrompts(prevPrompts => prevPrompts.filter(prompt => prompt.id !== promptId)); + }; + useEffect(() => { fetchPrompts(); }, []); - return { prompts, loading, error, refetch: fetchPrompts }; + return { + prompts, + loading, + error, + refetch: fetchPrompts, + removeOptimistically + }; } // Prompt operations hook diff --git a/src/hooks/useUsers.ts b/src/hooks/useUsers.ts index fc5fc60..4c7fbb3 100644 --- a/src/hooks/useUsers.ts +++ b/src/hooks/useUsers.ts @@ -151,22 +151,100 @@ export function useCurrentUser() { logoutEndpoint = '/api/local/logout'; } - await request({ + const logoutResponse = await request({ url: logoutEndpoint, method: 'post' }); + console.log('✅ Logout API call completed, waiting for browser to process cookies...'); + + // CRITICAL: Wait for browser to process Set-Cookie headers from logout response + // This gives the browser time to clear httpOnly cookies before redirect + await new Promise(resolve => setTimeout(resolve, 1000)); + // Clear user state after successful logout setUser(null); - // Clear any local storage data - localStorage.clear(); + // CRITICAL: Clear all authentication data BEFORE any redirects + // This ensures cleanup happens even if MSAL redirect interrupts the process + console.log('🧹 Starting comprehensive localStorage cleanup...'); + + // Clear all authentication-related localStorage items + localStorage.removeItem('currentUser'); + localStorage.removeItem('auth_authority'); + + // Clear MSAL cache tokens from localStorage + // MSAL stores tokens with keys starting with 'msal.' + const keysToRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && ( + key.startsWith('msal.') || + key === 'auth_token' || + key === 'refresh_token' || + key.includes('token') || + key.includes('auth') || + key.includes('msal') + )) { + keysToRemove.push(key); + } + } + keysToRemove.forEach(key => { + console.log('🗑️ Removing token:', key); + localStorage.removeItem(key); + }); + + // Clear debug items and other auth-related data + localStorage.removeItem('msft_auth_debug'); + localStorage.removeItem('msft_cookie_debug'); + + // Clear ALL MSAL cache data (including account keys, token keys, version) + const msalKeysToRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith('msal.')) { + msalKeysToRemove.push(key); + } + } + msalKeysToRemove.forEach(key => { + console.log('🗑️ Removing MSAL cache:', key); + localStorage.removeItem(key); + }); + + // Clear sessionStorage as well (CSRF tokens, etc.) + sessionStorage.clear(); + + // Clear cookies as backup (in case backend doesn't clear them properly) + // Note: This only works for cookies that are accessible to JavaScript + console.log('🍪 Checking cookies for cleanup...'); + console.log('🍪 All cookies:', document.cookie); + + const cookies = document.cookie.split(";"); + console.log('🍪 Cookie count:', cookies.length); + + cookies.forEach(function(c) { + const cookieName = c.split("=")[0].trim(); + console.log('🍪 Checking cookie:', cookieName); + + if (cookieName === 'auth_token' || cookieName === 'refresh_token' || cookieName.includes('token') || cookieName.includes('msal')) { + console.log('🗑️ Clearing cookie:', cookieName); + document.cookie = cookieName + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;"; + } + }); + + console.log('🍪 Cookies after cleanup attempt:', document.cookie); + + console.log('✅ localStorage cleanup completed'); // Handle MSAL logout for Microsoft authentication if (user.authenticationAuthority === 'msft' && msalInstance) { try { + console.log('🔄 Starting MSAL logout redirect...'); await msalInstance.logoutRedirect({ - onRedirectNavigate: () => true + onRedirectNavigate: () => { + console.log('🔄 MSAL redirect initiated - cleanup already completed'); + return true; + } }); return; // MSAL will handle the redirect } catch (msalError) { @@ -176,6 +254,7 @@ export function useCurrentUser() { } // Redirect to login or home page + console.log('🔄 Redirecting to login page...'); window.location.href = '/login'; } catch (error) { diff --git a/src/locales/de.ts b/src/locales/de.ts index dc70078..d8d6e98 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -60,6 +60,8 @@ export default { 'common.edit': 'Bearbeiten', 'common.close': 'Schließen', 'common.retry': 'Wiederholen', + 'common.create': 'Erstellen', + 'common.creating': 'Erstellen...', // Auth 'auth.login': 'Anmelden', @@ -441,10 +443,14 @@ export default { // Prompts 'prompts.title': 'Prompts', + 'prompts.subtitle': 'Prompts verwalten', + 'prompts.description': 'Prompts für Ihren KI-Assistenten erstellen und verwalten', + 'prompts.new_button': 'Neuer Prompt', 'prompts.addNew': 'Prompt hinzufügen', 'prompts.creating': 'Erstellen...', 'prompts.column.name': 'Name', 'prompts.column.content': 'Inhalt', + 'prompts.column.mandateId': 'Mandat-ID', 'prompts.unnamed': 'Unbenannt', 'prompts.action.edit': 'Bearbeiten', 'prompts.action.copy': 'Kopieren', @@ -463,6 +469,8 @@ export default { 'prompts.modal.edit.save': 'Änderungen speichern', 'prompts.modal.create.title': 'Neuen Prompt erstellen', 'prompts.modal.create.save': 'Prompt erstellen', + 'prompts.create.success': 'Prompt erfolgreich erstellt', + 'prompts.create.error': 'Fehler beim Erstellen des Prompts', // Users/Members 'users.title': 'Benutzer', diff --git a/src/locales/en.ts b/src/locales/en.ts index 51aa44a..1afb559 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -60,6 +60,8 @@ export default { 'common.edit': 'Edit', 'common.close': 'Close', 'common.retry': 'Retry', + 'common.create': 'Create', + 'common.creating': 'Creating...', // Auth 'auth.login': 'Login', @@ -441,10 +443,14 @@ export default { // Prompts 'prompts.title': 'Prompts', + 'prompts.subtitle': 'Manage your prompts', + 'prompts.description': 'Create and manage prompts for your AI assistant', + 'prompts.new_button': 'New Prompt', 'prompts.addNew': 'Add Prompt', 'prompts.creating': 'Creating...', 'prompts.column.name': 'Name', 'prompts.column.content': 'Content', + 'prompts.column.mandateId': 'Mandate ID', 'prompts.unnamed': 'Unnamed', 'prompts.action.edit': 'Edit', 'prompts.action.copy': 'Copy', @@ -463,6 +469,8 @@ export default { 'prompts.modal.edit.save': 'Save Changes', 'prompts.modal.create.title': 'Create New Prompt', 'prompts.modal.create.save': 'Create Prompt', + 'prompts.create.success': 'Prompt created successfully', + 'prompts.create.error': 'Error creating prompt', // Users/Members 'users.title': 'Users', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index fea35cb..7306485 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -60,6 +60,8 @@ export default { 'common.edit': 'Modifier', 'common.close': 'Fermer', 'common.retry': 'Réessayer', + 'common.create': 'Créer', + 'common.creating': 'Création...', // Auth 'auth.login': 'Se connecter', @@ -441,10 +443,14 @@ export default { // Prompts 'prompts.title': 'Prompts', + 'prompts.subtitle': 'Gérer vos prompts', + 'prompts.description': 'Créer et gérer des prompts pour votre assistant IA', + 'prompts.new_button': 'Nouveau prompt', 'prompts.addNew': 'Ajouter un prompt', 'prompts.creating': 'Création...', 'prompts.column.name': 'Nom', 'prompts.column.content': 'Contenu', + 'prompts.column.mandateId': 'ID Mandat', 'prompts.unnamed': 'Sans nom', 'prompts.action.edit': 'Modifier', 'prompts.action.copy': 'Copier', @@ -463,6 +469,8 @@ export default { 'prompts.modal.edit.save': 'Enregistrer les modifications', 'prompts.modal.create.title': 'Créer un nouveau prompt', 'prompts.modal.create.save': 'Créer le prompt', + 'prompts.create.success': 'Prompt créé avec succès', + 'prompts.create.error': 'Erreur lors de la création du prompt', // Users/Members 'users.title': 'Utilisateurs',