This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/klausur-service/frontend/src/components/EHUploadWizard.tsx
Benjamin Admin bfdaf63ba9 fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

592 lines
20 KiB
TypeScript

/**
* BYOEH Upload Wizard Component
*
* 5-step wizard for uploading Erwartungshorizonte with client-side encryption:
* 1. File Selection - Choose PDF file
* 2. Metadata - Title, Subject, Niveau, Year
* 3. Rights Confirmation - Legal acknowledgment (required)
* 4. Encryption - Set passphrase (2x confirmation)
* 5. Summary & Upload - Review and confirm
*/
import { useState, useEffect, useCallback } from 'react'
import {
encryptFile,
generateSalt,
isEncryptionSupported
} from '../services/encryption'
import { ehApi } from '../services/api'
interface EHMetadata {
title: string
subject: string
niveau: 'eA' | 'gA'
year: number
aufgaben_nummer?: string
}
interface EHUploadWizardProps {
onClose: () => void
onComplete?: (ehId: string) => void
onSuccess?: (ehId: string) => void // Legacy alias for onComplete
klausurSubject?: string
klausurYear?: number
defaultSubject?: string // Alias for klausurSubject
defaultYear?: number // Alias for klausurYear
klausurId?: string // If provided, automatically link EH to this Klausur
}
type WizardStep = 'file' | 'metadata' | 'rights' | 'encryption' | 'summary'
const WIZARD_STEPS: WizardStep[] = ['file', 'metadata', 'rights', 'encryption', 'summary']
const STEP_LABELS: Record<WizardStep, string> = {
file: 'Datei',
metadata: 'Metadaten',
rights: 'Rechte',
encryption: 'Verschluesselung',
summary: 'Zusammenfassung'
}
const SUBJECTS = [
'deutsch', 'englisch', 'mathematik', 'physik', 'chemie', 'biologie',
'geschichte', 'politik', 'erdkunde', 'kunst', 'musik', 'sport',
'informatik', 'latein', 'franzoesisch', 'spanisch'
]
const RIGHTS_TEXT = `Ich bestaetige hiermit, dass:
1. Ich das Urheberrecht oder die notwendigen Nutzungsrechte an diesem
Erwartungshorizont besitze.
2. Breakpilot diesen Erwartungshorizont NICHT fuer KI-Training verwendet,
sondern ausschliesslich fuer RAG-gestuetzte Korrekturvorschlaege
in meinem persoenlichen Arbeitsbereich.
3. Der Inhalt verschluesselt gespeichert wird und Breakpilot-Mitarbeiter
keinen Zugriff auf den Klartext haben.
4. Ich diesen Erwartungshorizont jederzeit loeschen kann.`
function EHUploadWizard({
onClose,
onComplete,
onSuccess,
klausurSubject,
klausurYear,
defaultSubject,
defaultYear,
klausurId
}: EHUploadWizardProps) {
// Resolve aliases
const effectiveSubject = klausurSubject || defaultSubject || 'deutsch'
const effectiveYear = klausurYear || defaultYear || new Date().getFullYear()
const handleComplete = onComplete || onSuccess || (() => {})
// Step state
const [currentStep, setCurrentStep] = useState<WizardStep>('file')
// File step
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [fileError, setFileError] = useState<string | null>(null)
// Metadata step
const [metadata, setMetadata] = useState<EHMetadata>({
title: '',
subject: effectiveSubject,
niveau: 'eA',
year: effectiveYear,
aufgaben_nummer: ''
})
// Rights step
const [rightsConfirmed, setRightsConfirmed] = useState(false)
// Encryption step
const [passphrase, setPassphrase] = useState('')
const [passphraseConfirm, setPassphraseConfirm] = useState('')
const [showPassphrase, setShowPassphrase] = useState(false)
const [passphraseStrength, setPassphraseStrength] = useState<'weak' | 'medium' | 'strong'>('weak')
// Upload state
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [uploadError, setUploadError] = useState<string | null>(null)
// Check encryption support
const encryptionSupported = isEncryptionSupported()
// Calculate passphrase strength
useEffect(() => {
if (passphrase.length < 8) {
setPassphraseStrength('weak')
} else if (passphrase.length < 12 || !/\d/.test(passphrase) || !/[A-Z]/.test(passphrase)) {
setPassphraseStrength('medium')
} else {
setPassphraseStrength('strong')
}
}, [passphrase])
// Step navigation
const currentStepIndex = WIZARD_STEPS.indexOf(currentStep)
const isFirstStep = currentStepIndex === 0
const isLastStep = currentStepIndex === WIZARD_STEPS.length - 1
const goNext = useCallback(() => {
if (!isLastStep) {
setCurrentStep(WIZARD_STEPS[currentStepIndex + 1])
}
}, [currentStepIndex, isLastStep])
const goBack = useCallback(() => {
if (!isFirstStep) {
setCurrentStep(WIZARD_STEPS[currentStepIndex - 1])
}
}, [currentStepIndex, isFirstStep])
// Validation
const isStepValid = useCallback((step: WizardStep): boolean => {
switch (step) {
case 'file':
return selectedFile !== null && fileError === null
case 'metadata':
return metadata.title.trim().length > 0 && metadata.subject.length > 0
case 'rights':
return rightsConfirmed
case 'encryption':
return passphrase.length >= 8 && passphrase === passphraseConfirm
case 'summary':
return true
default:
return false
}
}, [selectedFile, fileError, metadata, rightsConfirmed, passphrase, passphraseConfirm])
// File handling
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
if (file.type !== 'application/pdf') {
setFileError('Nur PDF-Dateien sind erlaubt')
setSelectedFile(null)
return
}
if (file.size > 50 * 1024 * 1024) { // 50MB limit
setFileError('Datei ist zu gross (max. 50MB)')
setSelectedFile(null)
return
}
setFileError(null)
setSelectedFile(file)
// Auto-fill title from filename
if (!metadata.title) {
const name = file.name.replace(/\.pdf$/i, '').replace(/[_-]/g, ' ')
setMetadata(prev => ({ ...prev, title: name }))
}
}
}
// Upload handler
const handleUpload = async () => {
if (!selectedFile || !encryptionSupported) return
setUploading(true)
setUploadProgress(10)
setUploadError(null)
try {
// Step 1: Generate salt (used via encrypted.salt below)
generateSalt()
setUploadProgress(20)
// Step 2: Encrypt file client-side
const encrypted = await encryptFile(selectedFile, passphrase)
setUploadProgress(50)
// Step 3: Create form data
const formData = new FormData()
const encryptedBlob = new Blob([encrypted.encryptedData], { type: 'application/octet-stream' })
formData.append('file', encryptedBlob, 'encrypted.bin')
const metadataJson = JSON.stringify({
metadata: {
title: metadata.title,
subject: metadata.subject,
niveau: metadata.niveau,
year: metadata.year,
aufgaben_nummer: metadata.aufgaben_nummer || null
},
encryption_key_hash: encrypted.keyHash,
salt: encrypted.salt,
rights_confirmed: true,
original_filename: selectedFile.name
})
formData.append('metadata_json', metadataJson)
setUploadProgress(70)
// Step 4: Upload to server
const response = await ehApi.uploadEH(formData)
setUploadProgress(90)
// Step 5: Link to Klausur if klausurId provided
if (klausurId && response.id) {
try {
await ehApi.linkToKlausur(response.id, klausurId)
} catch (linkError) {
console.warn('Failed to auto-link EH to Klausur:', linkError)
// Don't fail the whole upload if linking fails
}
}
setUploadProgress(100)
// Success!
handleComplete(response.id)
} catch (error) {
console.error('Upload failed:', error)
setUploadError(error instanceof Error ? error.message : 'Upload fehlgeschlagen')
} finally {
setUploading(false)
}
}
// Render step content
const renderStepContent = () => {
switch (currentStep) {
case 'file':
return (
<div className="eh-wizard-step">
<h3>Erwartungshorizont hochladen</h3>
<p className="eh-wizard-description">
Waehlen Sie die PDF-Datei Ihres Erwartungshorizonts aus.
Die Datei wird verschluesselt und kann nur von Ihnen entschluesselt werden.
</p>
<div className="eh-file-drop">
<input
type="file"
accept=".pdf"
onChange={handleFileSelect}
id="eh-file-input"
/>
<label htmlFor="eh-file-input" className="eh-file-label">
{selectedFile ? (
<>
<span className="eh-file-icon">&#128196;</span>
<span className="eh-file-name">{selectedFile.name}</span>
<span className="eh-file-size">
({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
</span>
</>
) : (
<>
<span className="eh-file-icon">&#128194;</span>
<span>PDF-Datei hier ablegen oder klicken</span>
</>
)}
</label>
</div>
{fileError && <p className="eh-error">{fileError}</p>}
{!encryptionSupported && (
<p className="eh-warning">
Ihr Browser unterstuetzt keine Verschluesselung.
Bitte verwenden Sie einen modernen Browser (Chrome, Firefox, Safari, Edge).
</p>
)}
</div>
)
case 'metadata':
return (
<div className="eh-wizard-step">
<h3>Metadaten</h3>
<p className="eh-wizard-description">
Geben Sie Informationen zum Erwartungshorizont ein.
</p>
<div className="eh-form-group">
<label htmlFor="eh-title">Titel *</label>
<input
id="eh-title"
type="text"
value={metadata.title}
onChange={e => setMetadata(prev => ({ ...prev, title: e.target.value }))}
placeholder="z.B. Deutsch Abitur 2024 Aufgabe 1"
/>
</div>
<div className="eh-form-row">
<div className="eh-form-group">
<label htmlFor="eh-subject">Fach *</label>
<select
id="eh-subject"
value={metadata.subject}
onChange={e => setMetadata(prev => ({ ...prev, subject: e.target.value }))}
>
{SUBJECTS.map(s => (
<option key={s} value={s}>
{s.charAt(0).toUpperCase() + s.slice(1)}
</option>
))}
</select>
</div>
<div className="eh-form-group">
<label htmlFor="eh-niveau">Niveau *</label>
<select
id="eh-niveau"
value={metadata.niveau}
onChange={e => setMetadata(prev => ({ ...prev, niveau: e.target.value as 'eA' | 'gA' }))}
>
<option value="eA">Erhoehtes Anforderungsniveau (eA)</option>
<option value="gA">Grundlegendes Anforderungsniveau (gA)</option>
</select>
</div>
</div>
<div className="eh-form-row">
<div className="eh-form-group">
<label htmlFor="eh-year">Jahr *</label>
<input
id="eh-year"
type="number"
min={2000}
max={2050}
value={metadata.year}
onChange={e => setMetadata(prev => ({ ...prev, year: parseInt(e.target.value) }))}
/>
</div>
<div className="eh-form-group">
<label htmlFor="eh-aufgabe">Aufgabennummer</label>
<input
id="eh-aufgabe"
type="text"
value={metadata.aufgaben_nummer || ''}
onChange={e => setMetadata(prev => ({ ...prev, aufgaben_nummer: e.target.value }))}
placeholder="z.B. 1a, 2.1"
/>
</div>
</div>
</div>
)
case 'rights':
return (
<div className="eh-wizard-step">
<h3>Rechte-Bestaetigung</h3>
<p className="eh-wizard-description">
Bitte lesen und bestaetigen Sie die folgenden Bedingungen.
</p>
<div className="eh-rights-box">
<pre>{RIGHTS_TEXT}</pre>
</div>
<div className="eh-checkbox-group">
<input
type="checkbox"
id="eh-rights-confirm"
checked={rightsConfirmed}
onChange={e => setRightsConfirmed(e.target.checked)}
/>
<label htmlFor="eh-rights-confirm">
Ich habe die Bedingungen gelesen und stimme ihnen zu.
</label>
</div>
<div className="eh-info-box">
<strong>Wichtig:</strong> Ihr Erwartungshorizont wird niemals fuer
KI-Training verwendet. Er dient ausschliesslich als Referenz fuer
Ihre persoenlichen Korrekturvorschlaege.
</div>
</div>
)
case 'encryption':
return (
<div className="eh-wizard-step">
<h3>Verschluesselung</h3>
<p className="eh-wizard-description">
Waehlen Sie ein sicheres Passwort fuer Ihren Erwartungshorizont.
Dieses Passwort wird <strong>niemals</strong> an den Server gesendet.
</p>
<div className="eh-form-group">
<label htmlFor="eh-passphrase">Passwort *</label>
<div className="eh-password-input">
<input
id="eh-passphrase"
type={showPassphrase ? 'text' : 'password'}
value={passphrase}
onChange={e => setPassphrase(e.target.value)}
placeholder="Mindestens 8 Zeichen"
/>
<button
type="button"
className="eh-toggle-password"
onClick={() => setShowPassphrase(!showPassphrase)}
>
{showPassphrase ? '&#128065;' : '&#128064;'}
</button>
</div>
<div className={`eh-password-strength eh-strength-${passphraseStrength}`}>
Staerke: {passphraseStrength === 'weak' ? 'Schwach' : passphraseStrength === 'medium' ? 'Mittel' : 'Stark'}
</div>
</div>
<div className="eh-form-group">
<label htmlFor="eh-passphrase-confirm">Passwort bestaetigen *</label>
<input
id="eh-passphrase-confirm"
type={showPassphrase ? 'text' : 'password'}
value={passphraseConfirm}
onChange={e => setPassphraseConfirm(e.target.value)}
placeholder="Passwort wiederholen"
/>
{passphraseConfirm && passphrase !== passphraseConfirm && (
<p className="eh-error">Passwoerter stimmen nicht ueberein</p>
)}
</div>
<div className="eh-warning-box">
<strong>Achtung:</strong> Merken Sie sich dieses Passwort gut!
Ohne das Passwort kann der Erwartungshorizont nicht fuer
Korrekturvorschlaege verwendet werden. Breakpilot kann Ihr
Passwort nicht wiederherstellen.
</div>
</div>
)
case 'summary':
return (
<div className="eh-wizard-step">
<h3>Zusammenfassung</h3>
<p className="eh-wizard-description">
Pruefen Sie Ihre Eingaben und starten Sie den Upload.
</p>
<div className="eh-summary-table">
<div className="eh-summary-row">
<span className="eh-summary-label">Datei:</span>
<span className="eh-summary-value">{selectedFile?.name}</span>
</div>
<div className="eh-summary-row">
<span className="eh-summary-label">Titel:</span>
<span className="eh-summary-value">{metadata.title}</span>
</div>
<div className="eh-summary-row">
<span className="eh-summary-label">Fach:</span>
<span className="eh-summary-value">
{metadata.subject.charAt(0).toUpperCase() + metadata.subject.slice(1)}
</span>
</div>
<div className="eh-summary-row">
<span className="eh-summary-label">Niveau:</span>
<span className="eh-summary-value">{metadata.niveau}</span>
</div>
<div className="eh-summary-row">
<span className="eh-summary-label">Jahr:</span>
<span className="eh-summary-value">{metadata.year}</span>
</div>
<div className="eh-summary-row">
<span className="eh-summary-label">Verschluesselung:</span>
<span className="eh-summary-value">AES-256-GCM</span>
</div>
<div className="eh-summary-row">
<span className="eh-summary-label">Rechte bestaetigt:</span>
<span className="eh-summary-value">Ja</span>
</div>
</div>
{uploading && (
<div className="eh-upload-progress">
<div
className="eh-progress-bar"
style={{ width: `${uploadProgress}%` }}
/>
<span>{uploadProgress}%</span>
</div>
)}
{uploadError && (
<p className="eh-error">{uploadError}</p>
)}
</div>
)
default:
return null
}
}
return (
<div className="eh-wizard-overlay">
<div className="eh-wizard-modal">
{/* Header */}
<div className="eh-wizard-header">
<h2>Erwartungshorizont hochladen</h2>
<button className="eh-wizard-close" onClick={onClose}>&times;</button>
</div>
{/* Progress */}
<div className="eh-wizard-progress">
{WIZARD_STEPS.map((step, index) => (
<div
key={step}
className={`eh-progress-step ${
index < currentStepIndex ? 'completed' :
index === currentStepIndex ? 'active' : ''
}`}
>
<div className="eh-progress-dot">
{index < currentStepIndex ? '\u2713' : index + 1}
</div>
<span className="eh-progress-label">{STEP_LABELS[step]}</span>
</div>
))}
</div>
{/* Content */}
<div className="eh-wizard-content">
{renderStepContent()}
</div>
{/* Footer */}
<div className="eh-wizard-footer">
<button
className="eh-btn eh-btn-secondary"
onClick={isFirstStep ? onClose : goBack}
disabled={uploading}
>
{isFirstStep ? 'Abbrechen' : 'Zurueck'}
</button>
{isLastStep ? (
<button
className="eh-btn eh-btn-primary"
onClick={handleUpload}
disabled={uploading || !isStepValid(currentStep)}
>
{uploading ? 'Wird hochgeladen...' : 'Hochladen'}
</button>
) : (
<button
className="eh-btn eh-btn-primary"
onClick={goNext}
disabled={!isStepValid(currentStep)}
>
Weiter
</button>
)}
</div>
</div>
</div>
)
}
export default EHUploadWizard