Add server security hardening and draft quiz persistence

Security:
- Add AES-256-GCM encryption for user PII (email, API keys, config)
- Add rate limiting (helmet + express-rate-limit)
- Require auth for file uploads

UX:
- Persist draft quizzes to sessionStorage (survives refresh)
- Add URL-based edit routes (/edit/draft, /edit/:quizId)
- Fix QuizEditor async defaultConfig race condition
- Fix URL param accumulation in Landing
This commit is contained in:
Joey Yakimowich-Payne 2026-01-15 10:12:05 -07:00
commit e480ad06df
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
18 changed files with 1775 additions and 94 deletions

View file

@ -39,11 +39,13 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const setMode = (newMode: 'HOST' | 'JOIN') => {
setModeState(newMode);
const newParams = new URLSearchParams(searchParams);
const newParams = new URLSearchParams();
const modalParam = searchParams.get('modal');
if (modalParam) {
newParams.set('modal', modalParam);
}
if (newMode === 'HOST') {
newParams.set('mode', 'host');
} else {
newParams.delete('mode');
}
setSearchParams(newParams, { replace: true });
};
@ -58,44 +60,33 @@ export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, on
const defaultConfigOpen = modalParam === 'settings';
const accountSettingsOpen = modalParam === 'account';
const setLibraryOpen = (open: boolean) => {
const newParams = new URLSearchParams(searchParams);
if (open) {
newParams.set('modal', 'library');
} else {
newParams.delete('modal');
const buildCleanParams = (updates: Record<string, string | null>) => {
const newParams = new URLSearchParams();
const modeParam = searchParams.get('mode');
if (modeParam) newParams.set('mode', modeParam);
for (const [key, value] of Object.entries(updates)) {
if (value !== null) {
newParams.set(key, value);
}
}
setSearchParams(newParams);
return newParams;
};
const setLibraryOpen = (open: boolean) => {
setSearchParams(buildCleanParams({ modal: open ? 'library' : null }));
};
const setPreferencesOpen = (open: boolean) => {
const newParams = new URLSearchParams(searchParams);
if (open) {
newParams.set('modal', 'preferences');
} else {
newParams.delete('modal');
}
setSearchParams(newParams);
setSearchParams(buildCleanParams({ modal: open ? 'preferences' : null }));
};
const setDefaultConfigOpen = (open: boolean) => {
const newParams = new URLSearchParams(searchParams);
if (open) {
newParams.set('modal', 'settings');
} else {
newParams.delete('modal');
}
setSearchParams(newParams);
setSearchParams(buildCleanParams({ modal: open ? 'settings' : null }));
};
const setAccountSettingsOpen = (open: boolean) => {
const newParams = new URLSearchParams(searchParams);
if (open) {
newParams.set('modal', 'account');
} else {
newParams.delete('modal');
}
setSearchParams(newParams);
setSearchParams(buildCleanParams({ modal: open ? 'account' : null }));
};
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);

View file

@ -39,11 +39,20 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
const [config, setConfig] = useState<GameConfig>(
initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG
);
const [hasAppliedDefaultConfig, setHasAppliedDefaultConfig] = useState(!!initialQuiz.config);
useEffect(() => {
if (!hasAppliedDefaultConfig && defaultConfig && defaultConfig !== DEFAULT_GAME_CONFIG) {
setConfig(defaultConfig);
setHasAppliedDefaultConfig(true);
}
}, [defaultConfig, hasAppliedDefaultConfig]);
useBodyScrollLock(!!showDeleteConfirm);
const handleConfigChange = useCallback((newConfig: GameConfig) => {
setConfig(newConfig);
setHasAppliedDefaultConfig(true);
onConfigChange?.(newConfig);
}, [onConfigChange]);
@ -191,7 +200,7 @@ export const QuizEditor: React.FC<QuizEditorProps> = ({
{showSaveButton && (
<button
onClick={() => onSave(quiz)}
onClick={() => onSave({ ...quiz, config })}
disabled={isSaving}
className="bg-white/20 p-2 rounded-full hover:bg-white/30 transition disabled:opacity-50"
title="Save to library"