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>
592 lines
20 KiB
TypeScript
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">📄</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">📂</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 ? '👁' : '👀'}
|
|
</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}>×</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
|