Major code quality improvements: Fix XSS vulnerabilities and replace browser dialogs
Security Fixes: - Add DOMPurify HTML sanitization for all dangerouslySetInnerHTML usage - Create comprehensive HTML sanitization utility with configurable security levels - Implement safe template rendering for slide content and layouts - Add input validation for image sources and dangerous attributes UI/UX Improvements: - Replace browser alert() and confirm() with proper React modal components - Create reusable Modal, AlertDialog, and ConfirmDialog components - Add useDialog hook for easy dialog state management - Implement proper accessibility with keyboard navigation and focus management - Add smooth animations and responsive design for dialogs Components Added: - src/utils/htmlSanitizer.ts - Comprehensive HTML sanitization - src/components/ui/Modal.tsx - Base modal component - src/components/ui/AlertDialog.tsx - Alert dialog component - src/components/ui/ConfirmDialog.tsx - Confirmation dialog component - src/hooks/useDialog.ts - Dialog state management hook Updated Components: - SlideEditor.tsx - Now uses sanitized HTML rendering - LayoutPreviewPage.tsx - Now uses sanitized HTML rendering - PresentationEditor.tsx - Now uses React dialogs instead of browser dialogs Benefits: ✅ Eliminates XSS vulnerabilities from unsafe HTML rendering ✅ Provides consistent, accessible user interface ✅ Improves user experience with proper modal dialogs ✅ Maintains security while preserving slide formatting capabilities ✅ Follows React best practices for component composition 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4ce9f225a6
commit
d0e70f81e7
@ -53,6 +53,9 @@
|
||||
|
||||
# General Claude Guidelines
|
||||
- Don't run npm commands, just tell me what to run and I'll run them myself
|
||||
- generally tsx files should be used for React components and they should be less than 200 lines of code
|
||||
- if a file I should be warned when I ask for a code audit
|
||||
- if a function or method is more than 50 lines of code, I should be warned
|
||||
|
||||
# Architecture
|
||||
## Themes
|
||||
|
28
package-lock.json
generated
28
package-lock.json
generated
@ -8,6 +8,7 @@
|
||||
"name": "slideshare",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.6",
|
||||
"loglevel": "^1.9.2",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.1.1",
|
||||
@ -16,6 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
@ -1396,6 +1398,16 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@ -1440,6 +1452,13 @@
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.40.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.40.0.tgz",
|
||||
@ -1997,6 +2016,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
|
||||
"integrity": "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.207",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.207.tgz",
|
||||
|
@ -11,6 +11,7 @@
|
||||
"generate-manifest": "node scripts/generate-themes-manifest.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.6",
|
||||
"loglevel": "^1.9.2",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "^19.1.1",
|
||||
@ -19,6 +20,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
|
@ -12,5 +12,5 @@
|
||||
"hasMasterSlide": true
|
||||
}
|
||||
},
|
||||
"generated": "2025-08-20T22:06:06.798Z"
|
||||
"generated": "2025-08-20T22:36:56.857Z"
|
||||
}
|
@ -4,6 +4,8 @@ import type { Presentation } from '../../types/presentation';
|
||||
import type { Theme } from '../../types/theme';
|
||||
import { getPresentationById, updatePresentation } from '../../utils/presentationStorage';
|
||||
import { getTheme } from '../../themes';
|
||||
import { useDialog } from '../../hooks/useDialog';
|
||||
import { AlertDialog, ConfirmDialog } from '../../components/ui';
|
||||
import './PresentationEditor.css';
|
||||
|
||||
export const PresentationEditor: React.FC = () => {
|
||||
@ -20,6 +22,20 @@ export const PresentationEditor: React.FC = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const currentSlideIndex = slideNumber ? parseInt(slideNumber, 10) - 1 : 0;
|
||||
|
||||
const {
|
||||
isAlertOpen,
|
||||
isConfirmOpen,
|
||||
alertOptions,
|
||||
confirmOptions,
|
||||
showAlert,
|
||||
showConfirm,
|
||||
closeAlert,
|
||||
closeConfirm,
|
||||
handleConfirm,
|
||||
showError,
|
||||
confirmDelete
|
||||
} = useDialog();
|
||||
|
||||
useEffect(() => {
|
||||
const loadPresentationAndTheme = async () => {
|
||||
@ -150,7 +166,8 @@ export const PresentationEditor: React.FC = () => {
|
||||
confirmMessage += ` This will remove the slide and renumber all subsequent slides. This action cannot be undone.`;
|
||||
}
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
const confirmed = await confirmDelete(confirmMessage);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -206,11 +223,15 @@ export const PresentationEditor: React.FC = () => {
|
||||
|
||||
// TODO: Implement presentation saving
|
||||
console.log('Save presentation functionality to be implemented');
|
||||
alert('Auto-save will be implemented. Changes are saved automatically.');
|
||||
showAlert({
|
||||
message: 'Auto-save will be implemented. Changes are saved automatically.',
|
||||
type: 'info',
|
||||
title: 'Auto-save Feature'
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error saving presentation:', err);
|
||||
alert('Failed to save presentation');
|
||||
showError('Failed to save presentation. Please try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@ -508,6 +529,28 @@ export const PresentationEditor: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Dialog Components */}
|
||||
<AlertDialog
|
||||
isOpen={isAlertOpen}
|
||||
onClose={closeAlert}
|
||||
title={alertOptions.title}
|
||||
message={alertOptions.message}
|
||||
type={alertOptions.type}
|
||||
confirmText={alertOptions.confirmText}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={isConfirmOpen}
|
||||
onClose={closeConfirm}
|
||||
onConfirm={handleConfirm}
|
||||
title={confirmOptions.title}
|
||||
message={confirmOptions.message}
|
||||
type={confirmOptions.type}
|
||||
confirmText={confirmOptions.confirmText}
|
||||
cancelText={confirmOptions.cancelText}
|
||||
isDestructive={confirmOptions.isDestructive}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -5,6 +5,7 @@ import type { Theme, SlideLayout } from '../../types/theme';
|
||||
import { getPresentationById, updatePresentation } from '../../utils/presentationStorage';
|
||||
import { getTheme } from '../../themes';
|
||||
import { renderTemplateWithSampleData } from '../../utils/templateRenderer';
|
||||
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer';
|
||||
import './SlideEditor.css';
|
||||
|
||||
export const SlideEditor: React.FC = () => {
|
||||
@ -272,7 +273,7 @@ export const SlideEditor: React.FC = () => {
|
||||
<div
|
||||
className="layout-rendered"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderTemplateWithSampleData(layout.htmlTemplate, layout)
|
||||
__html: sanitizeSlideTemplate(renderTemplateWithSampleData(layout.htmlTemplate, layout))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -388,7 +389,7 @@ export const SlideEditor: React.FC = () => {
|
||||
<div
|
||||
className={`slide-container ${presentation.metadata.aspectRatio ? `aspect-${presentation.metadata.aspectRatio.replace(':', '-')}` : 'aspect-16-9'}`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderTemplateWithContent(selectedLayout, slideContent)
|
||||
__html: sanitizeSlideTemplate(renderTemplateWithContent(selectedLayout, slideContent))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom';
|
||||
import type { Theme, SlideLayout } from '../../types/theme';
|
||||
import { getTheme } from '../../themes';
|
||||
import { renderTemplateWithSampleData } from '../../utils/templateRenderer';
|
||||
import { sanitizeSlideTemplate } from '../../utils/htmlSanitizer';
|
||||
import './LayoutPreviewPage.css';
|
||||
|
||||
export const LayoutPreviewPage: React.FC = () => {
|
||||
@ -152,7 +153,7 @@ export const LayoutPreviewPage: React.FC = () => {
|
||||
</div>
|
||||
<div
|
||||
className="layout-rendered-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderedContent }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeSlideTemplate(renderedContent) }}
|
||||
/>
|
||||
<Link
|
||||
to={`/themes/${theme.id}/layouts/${layout.id}`}
|
||||
|
122
src/components/ui/AlertDialog.tsx
Normal file
122
src/components/ui/AlertDialog.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
interface AlertDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: 'info' | 'warning' | 'error' | 'success';
|
||||
confirmText?: string;
|
||||
}
|
||||
|
||||
export const AlertDialog: React.FC<AlertDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
message,
|
||||
type = 'info',
|
||||
confirmText = 'OK'
|
||||
}) => {
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return '❌';
|
||||
case 'warning':
|
||||
return '⚠️';
|
||||
case 'success':
|
||||
return '✅';
|
||||
default:
|
||||
return 'ℹ️';
|
||||
}
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (title) return title;
|
||||
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return 'Error';
|
||||
case 'warning':
|
||||
return 'Warning';
|
||||
case 'success':
|
||||
return 'Success';
|
||||
default:
|
||||
return 'Information';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="small" title={getTitle()}>
|
||||
<div className="alert-dialog">
|
||||
<div className="alert-content">
|
||||
<div className="alert-icon">{getIcon()}</div>
|
||||
<p className="alert-message">{message}</p>
|
||||
</div>
|
||||
<div className="alert-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button primary"
|
||||
onClick={onClose}
|
||||
autoFocus
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.alert-dialog {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-message {
|
||||
color: #374151;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.alert-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.button.primary:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`}</style>
|
||||
</Modal>
|
||||
);
|
||||
};
|
179
src/components/ui/ConfirmDialog.tsx
Normal file
179
src/components/ui/ConfirmDialog.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import React from 'react';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: 'info' | 'warning' | 'danger';
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
isDestructive?: boolean;
|
||||
}
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
type = 'info',
|
||||
confirmText,
|
||||
cancelText = 'Cancel',
|
||||
isDestructive = false
|
||||
}) => {
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'danger':
|
||||
return '⚠️';
|
||||
case 'warning':
|
||||
return '⚠️';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (title) return title;
|
||||
|
||||
switch (type) {
|
||||
case 'danger':
|
||||
return 'Confirm Deletion';
|
||||
case 'warning':
|
||||
return 'Confirm Action';
|
||||
default:
|
||||
return 'Confirm';
|
||||
}
|
||||
};
|
||||
|
||||
const getConfirmText = () => {
|
||||
if (confirmText) return confirmText;
|
||||
|
||||
switch (type) {
|
||||
case 'danger':
|
||||
return 'Delete';
|
||||
case 'warning':
|
||||
return 'Continue';
|
||||
default:
|
||||
return 'Confirm';
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="small"
|
||||
title={getTitle()}
|
||||
closeOnOverlayClick={false}
|
||||
>
|
||||
<div className="confirm-dialog">
|
||||
<div className="confirm-content">
|
||||
<div className="confirm-icon">{getIcon()}</div>
|
||||
<p className="confirm-message">{message}</p>
|
||||
</div>
|
||||
<div className="confirm-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`button ${isDestructive || type === 'danger' ? 'danger' : 'primary'}`}
|
||||
onClick={handleConfirm}
|
||||
autoFocus
|
||||
>
|
||||
{getConfirmText()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.confirm-dialog {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-content {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.confirm-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.confirm-message {
|
||||
color: #374151;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.button.secondary:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
background: #dc2626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button.danger:hover {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.button:focus {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.button.danger:focus {
|
||||
outline-color: #dc2626;
|
||||
}
|
||||
`}</style>
|
||||
</Modal>
|
||||
);
|
||||
};
|
123
src/components/ui/Modal.css
Normal file
123
src/components/ui/Modal.css
Normal file
@ -0,0 +1,123 @@
|
||||
/* Modal Overlay */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Modal Content */
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: modalSlideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal Sizes */
|
||||
.modal-small {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.modal-medium {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.modal-large {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* Modal Header */
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1.5rem 0 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.modal-close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.modal-close-button:hover {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* Modal Body */
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 640px) {
|
||||
.modal-overlay {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem 1rem 0 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
80
src/components/ui/Modal.tsx
Normal file
80
src/components/ui/Modal.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import './Modal.css';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
closeOnOverlayClick?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
}
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'medium',
|
||||
closeOnOverlayClick = true,
|
||||
closeOnEscape = true
|
||||
}) => {
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen || !closeOnEscape) return;
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, closeOnEscape, onClose]);
|
||||
|
||||
// Handle body scroll lock
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleOverlayClick = (event: React.MouseEvent) => {
|
||||
if (event.target === event.currentTarget && closeOnOverlayClick) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleOverlayClick}>
|
||||
<div className={`modal-content modal-${size}`}>
|
||||
{title && (
|
||||
<header className="modal-header">
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close-button"
|
||||
onClick={onClose}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
)}
|
||||
<div className="modal-body">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
3
src/components/ui/index.ts
Normal file
3
src/components/ui/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { Modal } from './Modal';
|
||||
export { AlertDialog } from './AlertDialog';
|
||||
export { ConfirmDialog } from './ConfirmDialog';
|
117
src/hooks/useDialog.ts
Normal file
117
src/hooks/useDialog.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface AlertOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: 'info' | 'warning' | 'error' | 'success';
|
||||
confirmText?: string;
|
||||
}
|
||||
|
||||
interface ConfirmOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
type?: 'info' | 'warning' | 'danger';
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
isDestructive?: boolean;
|
||||
}
|
||||
|
||||
interface DialogState {
|
||||
isAlertOpen: boolean;
|
||||
isConfirmOpen: boolean;
|
||||
alertOptions: AlertOptions;
|
||||
confirmOptions: ConfirmOptions;
|
||||
confirmCallback: (() => void) | null;
|
||||
}
|
||||
|
||||
export function useDialog() {
|
||||
const [state, setState] = useState<DialogState>({
|
||||
isAlertOpen: false,
|
||||
isConfirmOpen: false,
|
||||
alertOptions: { message: '' },
|
||||
confirmOptions: { message: '' },
|
||||
confirmCallback: null
|
||||
});
|
||||
|
||||
const showAlert = useCallback((options: AlertOptions) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isAlertOpen: true,
|
||||
alertOptions: options
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const showConfirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConfirmOpen: true,
|
||||
confirmOptions: options,
|
||||
confirmCallback: () => resolve(true)
|
||||
}));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeAlert = useCallback(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isAlertOpen: false
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const closeConfirm = useCallback(() => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConfirmOpen: false,
|
||||
confirmCallback: null
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (state.confirmCallback) {
|
||||
state.confirmCallback();
|
||||
}
|
||||
closeConfirm();
|
||||
}, [state.confirmCallback, closeConfirm]);
|
||||
|
||||
const handleConfirmCancel = useCallback(() => {
|
||||
closeConfirm();
|
||||
}, [closeConfirm]);
|
||||
|
||||
return {
|
||||
// State
|
||||
isAlertOpen: state.isAlertOpen,
|
||||
isConfirmOpen: state.isConfirmOpen,
|
||||
alertOptions: state.alertOptions,
|
||||
confirmOptions: state.confirmOptions,
|
||||
|
||||
// Actions
|
||||
showAlert,
|
||||
showConfirm,
|
||||
closeAlert,
|
||||
closeConfirm: handleConfirmCancel,
|
||||
handleConfirm,
|
||||
|
||||
// Convenience methods
|
||||
showError: useCallback((message: string, title?: string) => {
|
||||
showAlert({ message, title, type: 'error' });
|
||||
}, [showAlert]),
|
||||
|
||||
showSuccess: useCallback((message: string, title?: string) => {
|
||||
showAlert({ message, title, type: 'success' });
|
||||
}, [showAlert]),
|
||||
|
||||
showWarning: useCallback((message: string, title?: string) => {
|
||||
showAlert({ message, title, type: 'warning' });
|
||||
}, [showAlert]),
|
||||
|
||||
confirmDelete: useCallback((message: string, title?: string) => {
|
||||
return showConfirm({
|
||||
message,
|
||||
title: title || 'Confirm Deletion',
|
||||
type: 'danger',
|
||||
isDestructive: true
|
||||
});
|
||||
}, [showConfirm])
|
||||
};
|
||||
}
|
165
src/utils/htmlSanitizer.ts
Normal file
165
src/utils/htmlSanitizer.ts
Normal file
@ -0,0 +1,165 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/**
|
||||
* Configuration for HTML sanitization based on context
|
||||
*/
|
||||
interface SanitizeConfig {
|
||||
/**
|
||||
* Whether to allow basic HTML formatting tags (b, i, em, strong, etc.)
|
||||
*/
|
||||
allowFormatting?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to allow structural HTML elements (div, span, p, etc.)
|
||||
*/
|
||||
allowStructural?: boolean;
|
||||
|
||||
/**
|
||||
* Whether to allow image tags (with strict src validation)
|
||||
*/
|
||||
allowImages?: boolean;
|
||||
|
||||
/**
|
||||
* Custom allowed tags (overrides other options if provided)
|
||||
*/
|
||||
allowedTags?: string[];
|
||||
|
||||
/**
|
||||
* Custom allowed attributes (overrides defaults if provided)
|
||||
*/
|
||||
allowedAttributes?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default safe configuration for slide content
|
||||
*/
|
||||
const DEFAULT_SLIDE_CONFIG: Required<SanitizeConfig> = {
|
||||
allowFormatting: true,
|
||||
allowStructural: true,
|
||||
allowImages: false,
|
||||
allowedTags: [
|
||||
// Text formatting
|
||||
'b', 'i', 'em', 'strong', 'u', 'mark', 'small', 'sub', 'sup',
|
||||
// Structure
|
||||
'div', 'span', 'p', 'br', 'hr',
|
||||
// Lists
|
||||
'ul', 'ol', 'li',
|
||||
// Headings
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
// Quotes
|
||||
'blockquote', 'cite'
|
||||
],
|
||||
allowedAttributes: [
|
||||
'class', 'id', 'style', 'data-*'
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes HTML content to prevent XSS attacks while preserving safe formatting
|
||||
*
|
||||
* @param html - The HTML string to sanitize
|
||||
* @param config - Configuration options for sanitization
|
||||
* @returns Sanitized HTML string safe for rendering
|
||||
*/
|
||||
export function sanitizeHtml(html: string, config: SanitizeConfig = {}): string {
|
||||
if (!html || typeof html !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const finalConfig = { ...DEFAULT_SLIDE_CONFIG, ...config };
|
||||
|
||||
// Build DOMPurify configuration
|
||||
const purifyConfig: any = {
|
||||
ALLOWED_TAGS: finalConfig.allowedTags,
|
||||
ALLOWED_ATTR: finalConfig.allowedAttributes,
|
||||
// Remove any scripts or dangerous content
|
||||
FORBID_TAGS: ['script', 'object', 'embed', 'base', 'link', 'meta', 'style'],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur'],
|
||||
// Keep content structure but remove dangerous elements
|
||||
KEEP_CONTENT: true,
|
||||
// Allow data attributes for slots and styling
|
||||
ALLOW_DATA_ATTR: true
|
||||
};
|
||||
|
||||
// Add image support if requested
|
||||
if (finalConfig.allowImages) {
|
||||
purifyConfig.ALLOWED_TAGS.push('img');
|
||||
purifyConfig.ALLOWED_ATTR.push('src', 'alt', 'width', 'height');
|
||||
|
||||
// Add hook to validate image sources
|
||||
DOMPurify.addHook('beforeSanitizeAttributes', (node) => {
|
||||
if (node.tagName === 'IMG') {
|
||||
const src = node.getAttribute('src');
|
||||
if (src && !isValidImageSource(src)) {
|
||||
node.removeAttribute('src');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const sanitized = DOMPurify.sanitize(html, purifyConfig);
|
||||
|
||||
// Clean up hooks to prevent memory leaks
|
||||
DOMPurify.removeAllHooks();
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates image sources to ensure they're safe
|
||||
*
|
||||
* @param src - Image source URL
|
||||
* @returns Whether the source is considered safe
|
||||
*/
|
||||
function isValidImageSource(src: string): boolean {
|
||||
try {
|
||||
const url = new URL(src, window.location.origin);
|
||||
|
||||
// Allow data URLs for base64 images
|
||||
if (url.protocol === 'data:') {
|
||||
return src.startsWith('data:image/');
|
||||
}
|
||||
|
||||
// Allow HTTPS and HTTP from trusted domains
|
||||
if (url.protocol === 'https:' || url.protocol === 'http:') {
|
||||
// Add any domain restrictions here if needed
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific sanitization for slide template content
|
||||
* Allows structural elements and formatting but restricts dangerous content
|
||||
*
|
||||
* @param html - Template HTML to sanitize
|
||||
* @returns Sanitized HTML safe for slide rendering
|
||||
*/
|
||||
export function sanitizeSlideTemplate(html: string): string {
|
||||
return sanitizeHtml(html, {
|
||||
allowFormatting: true,
|
||||
allowStructural: true,
|
||||
allowImages: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific sanitization for user-provided content
|
||||
* More restrictive than template sanitization
|
||||
*
|
||||
* @param html - User content to sanitize
|
||||
* @returns Sanitized HTML safe for user content
|
||||
*/
|
||||
export function sanitizeUserContent(html: string): string {
|
||||
return sanitizeHtml(html, {
|
||||
allowFormatting: true,
|
||||
allowStructural: false,
|
||||
allowImages: false,
|
||||
allowedTags: ['b', 'i', 'em', 'strong', 'u', 'br'],
|
||||
allowedAttributes: []
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user