generated from knso/webklar-preview-template
App: QR-Uebergabe mit dem Beratungs-PC
- Brief -> "Am Computer zeigen": schickt den Brief verschluesselt auf den Bildschirm der Beratung (zweisprachig, wenn eine Uebersetzung da ist) - Archiv -> "Vom Computer uebernehmen": holt das Ergebnis vom PC ins verschluesselte Archiv, mit Erinnerungen - Gleiches Paeckchen-Format wie die Webseite (expo-crypto combined = IV||Chiffrat||Tag); Test prueft beide Richtungen gegen WebCrypto - Link-Einfuegen als Ersatz, wenn keine Kamera da ist Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
014737bd95
commit
cf188af080
9 files changed
+509
-1
No files matched your search
@@ -27,6 +27,7 @@ import { AnalyseScreen } from './src/screens/AnalyseScreen';
|
||||
import { AntwortScreen } from './src/screens/AntwortScreen';
|
||||
import { GlossarScreen } from './src/screens/GlossarScreen';
|
||||
import { EinstellungenScreen } from './src/screens/EinstellungenScreen';
|
||||
import { VerbindenScreen } from './src/screens/VerbindenScreen';
|
||||
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
const Tab = createBottomTabNavigator<HauptTabParamList>();
|
||||
@@ -131,6 +132,7 @@ export default function App() {
|
||||
<Stack.Screen name="Antwort" component={AntwortScreen} options={{ title: 'Antwort erstellen' }} />
|
||||
<Stack.Screen name="Glossar" component={GlossarScreen} options={{ title: 'Behörden-Glossar' }} />
|
||||
<Stack.Screen name="Einstellungen" component={EinstellungenScreen} options={{ title: 'Einstellungen' }} />
|
||||
<Stack.Screen name="Verbinden" component={VerbindenScreen} options={{ title: 'Verbinden' }} />
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
</AppSchutz>
|
||||
|
||||
@@ -32,7 +32,9 @@ export type IkonenName =
|
||||
| 'archiv'
|
||||
| 'schloss'
|
||||
| 'lupe'
|
||||
| 'pfeilLinks';
|
||||
| 'pfeilLinks'
|
||||
| 'computer'
|
||||
| 'qrScannen';
|
||||
|
||||
// `as const` hält die Namen als Literal-Typen — so prüft TypeScript gegen
|
||||
// die offizielle SF-Symbol-/Material-Namensliste von expo-symbols
|
||||
@@ -62,6 +64,8 @@ const NAMEN = {
|
||||
schloss: { ios: 'lock.fill', android: 'lock' },
|
||||
lupe: { ios: 'magnifyingglass', android: 'search' },
|
||||
pfeilLinks: { ios: 'chevron.left', android: 'chevron_left' },
|
||||
computer: { ios: 'desktopcomputer', android: 'computer' },
|
||||
qrScannen: { ios: 'qrcode.viewfinder', android: 'qr_code_scanner' },
|
||||
} as const;
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -287,6 +287,16 @@ export function AnalyseScreen({ navigation, route }: Props) {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ── In der Beratung: Brief auf dem Bildschirm zeigen ── */}
|
||||
<View style={{ marginTop: abstand.l }}>
|
||||
<GrossButton
|
||||
titel="Am Computer zeigen"
|
||||
ikone="computer"
|
||||
variante="sekundaer"
|
||||
onPress={() => navigation.navigate('Verbinden', { modus: 'senden', briefId: brief.id })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* ── Datenschutz-Fuß + Löschen ── */}
|
||||
<View style={styles.schutzZeile}>
|
||||
<Ikone name="schloss" groesse={13} farbe={farben.textTertiaer} />
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useAppStore } from '../store/useAppStore';
|
||||
import { berechneAmpel } from '../utils/ampel';
|
||||
import { formatiereDatum } from '../services/erinnerungen';
|
||||
import { Ikone } from '../components/Ikone';
|
||||
import { GrossButton } from '../components/GrossButton';
|
||||
import { farben, schrift, abstand, TOUCH_TARGET } from '../theme';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
@@ -144,6 +145,16 @@ export function ArchivScreen() {
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Ergebnis vom Beratungs-PC per QR-Code übernehmen */}
|
||||
<View style={{ marginTop: abstand.l }}>
|
||||
<GrossButton
|
||||
titel="Vom Computer übernehmen"
|
||||
ikone="qrScannen"
|
||||
variante="sekundaer"
|
||||
onPress={() => navigation.navigate('Verbinden', { modus: 'empfangen' })}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* QR-Code vom Beratungs-PC scannen (Übergabe Handy ↔ PC).
|
||||
*
|
||||
* Zwei Wege, beide Ende-zu-Ende verschlüsselt:
|
||||
* - „senden": ein Brief aus dem Archiv erscheint am PC (der PC zeigt dafür
|
||||
* auf behoerdenklar.de/demo „Lieber mit dem Handy fotografieren")
|
||||
* - „empfangen": das Ergebnis vom PC landet im Archiv, mit Erinnerungen
|
||||
* (der PC zeigt dafür „Ergebnis aufs Handy mitnehmen")
|
||||
*
|
||||
* Ohne Kamera (Simulator, Erlaubnis verweigert) kann man den Link einfügen.
|
||||
*/
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, TextInput, View, ScrollView } from 'react-native';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { RootStackParamList } from '../types';
|
||||
import { useAppStore, neueId } from '../store/useAppStore';
|
||||
import { planeErinnerungen } from '../services/erinnerungen';
|
||||
import { holeAusUebergabe, loescheUebergabe, oeffneUebergabe, sendeAnUebergabe } from '../services/uebergabe';
|
||||
import { briefZuWebPaket, leseUebergabeLink, webPaketZuBrief, WebPaket } from '../utils/uebergabeFormat';
|
||||
import { GrossButton } from '../components/GrossButton';
|
||||
import { Ikone } from '../components/Ikone';
|
||||
import { farben, schrift, abstand } from '../theme';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'Verbinden'>;
|
||||
|
||||
type Zustand =
|
||||
| { art: 'scannen' }
|
||||
| { art: 'arbeitet'; text: string }
|
||||
| { art: 'fertig'; text: string }
|
||||
| { art: 'fehler'; text: string };
|
||||
|
||||
export function VerbindenScreen({ navigation, route }: Props) {
|
||||
const modus = route.params.modus;
|
||||
const briefId = route.params.modus === 'senden' ? route.params.briefId : null;
|
||||
const brief = useAppStore((s) => s.briefe.find((b) => b.id === briefId));
|
||||
const addBrief = useAppStore((s) => s.addBrief);
|
||||
const [erlaubnis, frageErlaubnis] = useCameraPermissions();
|
||||
const [zustand, setZustand] = useState<Zustand>({ art: 'scannen' });
|
||||
const [eingefuegt, setEingefuegt] = useState('');
|
||||
const beschaeftigt = useRef(false); // Kamera meldet denselben Code mehrfach
|
||||
|
||||
async function verarbeite(text: string) {
|
||||
if (beschaeftigt.current) return;
|
||||
beschaeftigt.current = true;
|
||||
try {
|
||||
const link = leseUebergabeLink(text);
|
||||
if (!link) {
|
||||
setZustand({ art: 'fehler', text: 'Das ist kein QR-Code von BehördenKlar. Bitte den Code auf dem Bildschirm der Beratung scannen.' });
|
||||
return;
|
||||
}
|
||||
if (link.art === 'g') {
|
||||
setZustand({ art: 'fehler', text: 'Dieser Code ist für die Webseite gedacht. Öffnen Sie ihn mit der normalen Handy-Kamera.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (modus === 'senden') {
|
||||
if (link.art !== 'k') {
|
||||
setZustand({ art: 'fehler', text: 'Dieser Code ist zum Mitnehmen. Gehen Sie dafür ins Archiv auf „Vom Computer übernehmen".' });
|
||||
return;
|
||||
}
|
||||
if (!brief) {
|
||||
setZustand({ art: 'fehler', text: 'Der Brief wurde nicht gefunden.' });
|
||||
return;
|
||||
}
|
||||
setZustand({ art: 'arbeitet', text: 'Der Brief wird verschlüsselt geschickt …' });
|
||||
const u = await oeffneUebergabe(link);
|
||||
await sendeAnUebergabe(u, 'status', { schritt: 'verbunden' });
|
||||
await sendeAnUebergabe(u, 'ergebnis', briefZuWebPaket(brief));
|
||||
setZustand({ art: 'fertig', text: 'Fertig. Der Brief ist jetzt auf dem Bildschirm der Beratung zu sehen.' });
|
||||
return;
|
||||
}
|
||||
|
||||
// empfangen
|
||||
if (link.art !== 'm') {
|
||||
setZustand({ art: 'fehler', text: 'Dieser Code wartet auf einen Brief. Öffnen Sie dafür den Brief und tippen Sie auf „Am Computer zeigen".' });
|
||||
return;
|
||||
}
|
||||
setZustand({ art: 'arbeitet', text: 'Das Ergebnis wird geholt …' });
|
||||
const u = await oeffneUebergabe(link);
|
||||
const faecher = await holeAusUebergabe(u);
|
||||
const paket = faecher.ergebnis as WebPaket | undefined;
|
||||
if (!paket?.analyse) {
|
||||
setZustand({ art: 'fehler', text: 'Dieser Code ist abgelaufen oder wurde schon benutzt (er gilt 15 Minuten und nur einmal). Bitte am Computer einen neuen erzeugen.' });
|
||||
return;
|
||||
}
|
||||
loescheUebergabe(u);
|
||||
const neu = webPaketZuBrief(paket, neueId(), new Date());
|
||||
await addBrief(neu);
|
||||
planeErinnerungen(neu).catch(() => {});
|
||||
navigation.replace('Analyse', { briefId: neu.id });
|
||||
} catch {
|
||||
setZustand({ art: 'fehler', text: 'Keine Verbindung. Bitte prüfen Sie das Internet und versuchen Sie es noch einmal.' });
|
||||
} finally {
|
||||
beschaeftigt.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
const titel = modus === 'senden' ? 'Am Computer zeigen' : 'Vom Computer übernehmen';
|
||||
const anleitung =
|
||||
modus === 'senden'
|
||||
? 'Die Beratung öffnet behoerdenklar.de/demo und tippt auf „Lieber mit dem Handy fotografieren". Scannen Sie den QR-Code, dann erscheint Ihr Brief auf dem Bildschirm.'
|
||||
: 'Die Beratung tippt am Computer auf „Ergebnis aufs Handy mitnehmen". Scannen Sie den QR-Code, dann landet der Brief verschlüsselt in Ihrem Archiv.';
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container} contentContainerStyle={{ padding: abstand.m, paddingBottom: abstand.xl }} keyboardShouldPersistTaps="handled">
|
||||
<Text style={styles.titel}>{titel}</Text>
|
||||
<Text style={styles.text}>{anleitung}</Text>
|
||||
|
||||
{zustand.art === 'scannen' && (
|
||||
<View style={styles.kameraRahmen}>
|
||||
{erlaubnis?.granted ? (
|
||||
<CameraView
|
||||
style={StyleSheet.absoluteFill}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={({ data }) => verarbeite(data)}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.kameraLeer}>
|
||||
<Text style={styles.text}>Für den QR-Code braucht die App kurz die Kamera.</Text>
|
||||
<GrossButton titel="Kamera erlauben" ikone="kamera" onPress={frageErlaubnis} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{zustand.art === 'arbeitet' && (
|
||||
<View style={styles.meldung}>
|
||||
<ActivityIndicator color={farben.primaer} />
|
||||
<Text style={styles.text}>{zustand.text}</Text>
|
||||
</View>
|
||||
)}
|
||||
{zustand.art === 'fertig' && (
|
||||
<View style={styles.meldung}>
|
||||
<Ikone name="checkAn" groesse={28} farbe={farben.ampelGruen} />
|
||||
<Text style={styles.text}>{zustand.text}</Text>
|
||||
<GrossButton titel="Zurück zum Brief" onPress={() => navigation.goBack()} />
|
||||
</View>
|
||||
)}
|
||||
{zustand.art === 'fehler' && (
|
||||
<View style={styles.meldung}>
|
||||
<Ikone name="warnung" groesse={26} farbe={farben.fehler} />
|
||||
<Text style={styles.text}>{zustand.text}</Text>
|
||||
<GrossButton titel="Noch einmal scannen" variante="sekundaer" onPress={() => setZustand({ art: 'scannen' })} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.schutzZeile}>
|
||||
<Ikone name="schloss" groesse={13} farbe={farben.textTertiaer} />
|
||||
<Text style={styles.schutzText}>Ende-zu-Ende verschlüsselt · nach 15 Minuten gelöscht</Text>
|
||||
</View>
|
||||
|
||||
{zustand.art === 'scannen' && (
|
||||
<View style={{ marginTop: abstand.l }}>
|
||||
<Text style={styles.klein}>Kein Scannen möglich? Link hier einfügen:</Text>
|
||||
<TextInput
|
||||
style={styles.feld}
|
||||
value={eingefuegt}
|
||||
onChangeText={setEingefuegt}
|
||||
placeholder="https://behoerdenklar.de/demo#…"
|
||||
placeholderTextColor={farben.textTertiaer}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
accessibilityLabel="Link vom Computer einfügen"
|
||||
/>
|
||||
<GrossButton titel="Link verwenden" variante="sekundaer" deaktiviert={!eingefuegt.trim()} onPress={() => verarbeite(eingefuegt)} />
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: farben.hintergrund },
|
||||
titel: { fontSize: schrift.titel, fontWeight: '700', color: farben.text, marginBottom: abstand.s },
|
||||
text: { fontSize: schrift.basis, color: farben.text, lineHeight: 26 },
|
||||
klein: { fontSize: schrift.klein, color: farben.textSekundaer, marginBottom: abstand.s },
|
||||
kameraRahmen: {
|
||||
marginTop: abstand.m, height: 300, borderRadius: 16, overflow: 'hidden',
|
||||
backgroundColor: farben.flaecheSekundaer,
|
||||
},
|
||||
kameraLeer: { flex: 1, justifyContent: 'center', padding: abstand.m, gap: abstand.m },
|
||||
meldung: { marginTop: abstand.m, gap: abstand.m, padding: abstand.m, borderRadius: 16, backgroundColor: farben.flaeche },
|
||||
schutzZeile: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: abstand.m },
|
||||
schutzText: { fontSize: 13, color: farben.textTertiaer },
|
||||
feld: {
|
||||
backgroundColor: farben.flaeche, borderRadius: 12, padding: 14, fontSize: schrift.klein,
|
||||
color: farben.text, borderWidth: 1, borderColor: farben.rand, marginBottom: abstand.s,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Übergabe Handy ↔ PC — Ende-zu-Ende verschlüsselt (AES-256-GCM).
|
||||
*
|
||||
* Päckchen-Format wie auf der Webseite: base64url(IV[12] || Chiffrat || Tag[16]).
|
||||
* expo-crypto liefert mit `combined()` genau diese Reihenfolge, deshalb können
|
||||
* App und Webseite die Päckchen gegenseitig lesen. Der Worker sieht nur
|
||||
* Chiffrat und löscht alles nach 15 Minuten.
|
||||
*/
|
||||
import { AESEncryptionKey, AESSealedData, aesDecryptAsync, aesEncryptAsync } from 'expo-crypto';
|
||||
import { utf8Dekodieren, utf8Kodieren } from '../utils/utf8';
|
||||
import { UebergabeLink, base64UrlZuBase64, base64ZuBase64Url } from '../utils/uebergabeFormat';
|
||||
|
||||
export interface OffeneUebergabe {
|
||||
link: UebergabeLink;
|
||||
schluessel: AESEncryptionKey;
|
||||
}
|
||||
|
||||
export async function oeffneUebergabe(link: UebergabeLink): Promise<OffeneUebergabe> {
|
||||
const schluessel = await AESEncryptionKey.import(base64UrlZuBase64(link.schluessel), 'base64');
|
||||
return { link, schluessel };
|
||||
}
|
||||
|
||||
async function verschluesseln(u: OffeneUebergabe, daten: unknown): Promise<string> {
|
||||
const versiegelt = await aesEncryptAsync(utf8Kodieren(JSON.stringify(daten)), u.schluessel);
|
||||
return base64ZuBase64Url((await versiegelt.combined('base64')) as string);
|
||||
}
|
||||
|
||||
async function entschluesseln(u: OffeneUebergabe, text: string): Promise<unknown | null> {
|
||||
try {
|
||||
const versiegelt = AESSealedData.fromCombined(base64UrlZuBase64(text));
|
||||
const bytes = (await aesDecryptAsync(versiegelt, u.schluessel)) as Uint8Array;
|
||||
return JSON.parse(utf8Dekodieren(bytes));
|
||||
} catch {
|
||||
return null; // nicht von dieser Übergabe oder beschädigt
|
||||
}
|
||||
}
|
||||
|
||||
const adresse = (u: OffeneUebergabe) => `${u.link.basis}/uebergabe/${u.link.id}`;
|
||||
|
||||
export async function sendeAnUebergabe(u: OffeneUebergabe, fach: string, daten: unknown): Promise<void> {
|
||||
const r = await fetch(`${adresse(u)}/${fach}`, { method: 'PUT', body: await verschluesseln(u, daten) });
|
||||
if (!r.ok) throw new Error(`Übergabe fehlgeschlagen (${r.status})`);
|
||||
}
|
||||
|
||||
export async function holeAusUebergabe(u: OffeneUebergabe): Promise<Record<string, unknown>> {
|
||||
const r = await fetch(adresse(u), { headers: { 'cache-control': 'no-store' } });
|
||||
if (!r.ok) throw new Error(`Übergabe nicht erreichbar (${r.status})`);
|
||||
const faecher = ((await r.json()) as { faecher?: Record<string, string> }).faecher ?? {};
|
||||
const aus: Record<string, unknown> = {};
|
||||
for (const [fach, wert] of Object.entries(faecher)) {
|
||||
const d = await entschluesseln(u, wert);
|
||||
if (d) aus[fach] = d;
|
||||
}
|
||||
return aus;
|
||||
}
|
||||
|
||||
export function loescheUebergabe(u: OffeneUebergabe): void {
|
||||
fetch(adresse(u), { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
@@ -145,4 +145,6 @@ export type RootStackParamList = {
|
||||
Antwort: { briefId: string };
|
||||
Glossar: undefined;
|
||||
Einstellungen: undefined;
|
||||
/** QR-Code vom Beratungs-PC: Brief hinschicken oder Ergebnis übernehmen. */
|
||||
Verbinden: { modus: 'senden'; briefId: string } | { modus: 'empfangen' };
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
/// <reference types="node" />
|
||||
/**
|
||||
* Übergabe Handy ↔ PC: App und Webseite müssen die Päckchen gegenseitig lesen.
|
||||
*
|
||||
* Die Web-Seite wird hier mit WebCrypto nachgebaut — exakt wie in
|
||||
* webseite/uebergabe.js. Für die App läuft die echte Web-Implementierung von
|
||||
* expo-crypto (gleiche Kombi-Reihenfolge IV‖Chiffrat‖Tag wie nativ).
|
||||
*/
|
||||
jest.mock('expo-crypto/build/aes/ExpoCryptoAES', () => jest.requireActual('expo-crypto/build/aes/ExpoCryptoAES.web'));
|
||||
|
||||
import { webcrypto } from 'crypto';
|
||||
import { holeAusUebergabe, oeffneUebergabe, sendeAnUebergabe } from '../../services/uebergabe';
|
||||
import { briefZuWebPaket, leseUebergabeLink, webPaketZuBrief } from '../uebergabeFormat';
|
||||
import { BriefEintrag } from '../../types';
|
||||
|
||||
const subtle = webcrypto.subtle;
|
||||
const b64u = (b: Uint8Array) => Buffer.from(b).toString('base64url');
|
||||
|
||||
// --- Web-Seite (wie webseite/uebergabe.js) ---
|
||||
async function webSchluessel(k: string) {
|
||||
return subtle.importKey('raw', Buffer.from(k, 'base64url'), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
async function webVerschluesseln(k: string, daten: unknown) {
|
||||
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv }, await webSchluessel(k), new TextEncoder().encode(JSON.stringify(daten))));
|
||||
return b64u(Buffer.concat([iv, ct]));
|
||||
}
|
||||
async function webEntschluesseln(k: string, text: string) {
|
||||
const b = Buffer.from(text, 'base64url');
|
||||
const klar = await subtle.decrypt({ name: 'AES-GCM', iv: b.subarray(0, 12) }, await webSchluessel(k), b.subarray(12));
|
||||
return JSON.parse(new TextDecoder().decode(klar));
|
||||
}
|
||||
|
||||
const ID = 'v1wRNjDlmRZeGSS_YRiRVA';
|
||||
const KEY = b64u(webcrypto.getRandomValues(new Uint8Array(32)));
|
||||
const link = (a: string) => `https://behoerdenklar.de/demo#a=${a}&s=${ID}&k=${KEY}&l=ar&d=1`;
|
||||
|
||||
const ANALYSE_DE = {
|
||||
brieftyp: 'Aufforderung', absender: 'Ausländerbehörde', kernaussage: 'Unterlagen fehlen.',
|
||||
erklaerung_einfach: 'Bitte bis 12.11. einreichen.', fachbegriffe: [{ begriff: 'Fiktionsbescheinigung', erklaerung: 'Papier' }],
|
||||
frist: { datum: '2026-11-12', aktion: 'Unterlagen einreichen' }, termin: null, checkliste: ['Pass'],
|
||||
kontakt: { telefon: '069 90009-314' }, anruf: [],
|
||||
};
|
||||
const ANALYSE_AR = { ...ANALYSE_DE, kernaussage: 'المستندات ناقصة.', checkliste: ['جواز السفر'] };
|
||||
|
||||
describe('leseUebergabeLink', () => {
|
||||
it('liest Kamera- und Mitnehmen-Codes', () => {
|
||||
expect(leseUebergabeLink(link('k'))).toMatchObject({ art: 'k', id: ID, schluessel: KEY, basis: 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev' });
|
||||
expect(leseUebergabeLink(link('m'))?.art).toBe('m');
|
||||
});
|
||||
it('nimmt beim lokalen Test den lokalen Worker', () => {
|
||||
expect(leseUebergabeLink(`http://localhost:4173/demo.html#a=m&s=${ID}&k=${KEY}`)?.basis).toBe('http://localhost:8799');
|
||||
});
|
||||
it('lehnt fremde Seiten und kaputte Codes ab', () => {
|
||||
expect(leseUebergabeLink(`https://boese.example/demo#a=m&s=${ID}&k=${KEY}`)).toBeNull();
|
||||
expect(leseUebergabeLink('https://behoerdenklar.de/demo#a=m&s=kurz&k=kurz')).toBeNull();
|
||||
expect(leseUebergabeLink('Hallo')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verschlüsselung App ↔ Webseite', () => {
|
||||
const fetchOriginal = global.fetch;
|
||||
afterEach(() => { global.fetch = fetchOriginal; });
|
||||
|
||||
it('App liest, was die Webseite verschlüsselt hat', async () => {
|
||||
const chiffrat = await webVerschluesseln(KEY, { analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' });
|
||||
global.fetch = jest.fn(async () => new Response(JSON.stringify({ faecher: { ergebnis: chiffrat, fremd: 'QUJD' } }))) as typeof fetch;
|
||||
const u = await oeffneUebergabe(leseUebergabeLink(link('m'))!);
|
||||
const faecher = await holeAusUebergabe(u);
|
||||
expect(faecher.ergebnis).toEqual({ analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' });
|
||||
expect(faecher.fremd).toBeUndefined(); // nicht entschlüsselbar → ignoriert
|
||||
});
|
||||
|
||||
it('Webseite liest, was die App verschlüsselt hat', async () => {
|
||||
let gesendet = '';
|
||||
let adresse = '';
|
||||
global.fetch = jest.fn(async (url: string, opt: RequestInit) => {
|
||||
adresse = url;
|
||||
gesendet = String(opt.body);
|
||||
return new Response(null, { status: 204 });
|
||||
}) as unknown as typeof fetch;
|
||||
const u = await oeffneUebergabe(leseUebergabeLink(link('k'))!);
|
||||
await sendeAnUebergabe(u, 'ergebnis', { analyse: ANALYSE_DE, sprache: 'de' });
|
||||
expect(adresse).toBe(`https://behoerdenklar-proxy.behoerdenbrief.workers.dev/uebergabe/${ID}/ergebnis`);
|
||||
expect(gesendet).toMatch(/^[A-Za-z0-9_-]+$/); // base64url, wie der Worker verlangt
|
||||
expect(await webEntschluesseln(KEY, gesendet)).toEqual({ analyse: ANALYSE_DE, sprache: 'de' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Umwandeln Web ↔ App', () => {
|
||||
it('Ergebnis vom PC wird ein Brief: Deutsch als Grundlage, Arabisch als Übersetzung', () => {
|
||||
const b = webPaketZuBrief({ analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' }, 'id1', new Date('2026-10-29T10:00:00Z'));
|
||||
expect(b.analyse.kernaussage).toBe('Unterlagen fehlen.');
|
||||
expect(b.analyse.frist).toEqual({ datum: '2026-11-12', aktion: 'Unterlagen einreichen' });
|
||||
expect(b.analyse.antwort_noetig).toBe(false);
|
||||
expect(b.uebersetzungen.ar.kernaussage).toBe('المستندات ناقصة.');
|
||||
expect(b.erstelltAm).toBe('2026-10-29T10:00:00.000Z');
|
||||
});
|
||||
it('rein deutsches Ergebnis hat keine Übersetzung', () => {
|
||||
const b = webPaketZuBrief({ analyse: ANALYSE_DE, sprache: 'de' }, 'id2', new Date());
|
||||
expect(b.analyse.kernaussage).toBe('Unterlagen fehlen.');
|
||||
expect(b.uebersetzungen).toEqual({});
|
||||
});
|
||||
it('Brief aus dem Archiv geht zweisprachig an den PC', () => {
|
||||
const brief = webPaketZuBrief({ analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' }, 'id3', new Date());
|
||||
const p = briefZuWebPaket(brief as BriefEintrag);
|
||||
expect(p.sprache).toBe('ar');
|
||||
expect(p.analyse.kernaussage).toBe('المستندات ناقصة.');
|
||||
expect(p.analyse.frist).toEqual(ANALYSE_DE.frist);
|
||||
expect(p.analyse_de?.kernaussage).toBe('Unterlagen fehlen.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Übergabe Handy ↔ PC — reine Hilfsfunktionen ohne native Module (testbar).
|
||||
*
|
||||
* Gleiches Protokoll wie die Webseite (webseite/uebergabe.js): Der QR-Code
|
||||
* enthält hinter dem `#` eine Übergabe-ID (s), einen AES-256-Schlüssel (k)
|
||||
* und die Art der Übergabe (a):
|
||||
* a=k „Kamera": der PC wartet auf einen Brief → App schickt ihn hin
|
||||
* a=m „Mitnehmen": der PC hat ein Ergebnis bereitgelegt → App holt es
|
||||
* a=g Guthaben der Webseite (gilt nur im Browser, nicht in der App)
|
||||
*/
|
||||
import { BriefAnalyse, BriefEintrag, Fachbegriff, Uebersetzung } from '../types';
|
||||
|
||||
export type UebergabeArt = 'k' | 'm' | 'g';
|
||||
|
||||
export interface UebergabeLink {
|
||||
art: UebergabeArt;
|
||||
id: string;
|
||||
schluessel: string; // base64url, 32 Byte
|
||||
/** Worker, der die Päckchen hält (lokal beim Testen, sonst live). */
|
||||
basis: string;
|
||||
}
|
||||
|
||||
const LIVE_BASIS = 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev';
|
||||
const LOKAL_BASIS = 'http://localhost:8799';
|
||||
const ERLAUBTE_SEITEN = /^https:\/\/([a-z0-9-]+\.)?behoerdenklar\.(de|pages\.dev)\//;
|
||||
const LOKALE_SEITE = /^http:\/\/(localhost|127\.0\.0\.1):\d+\//;
|
||||
|
||||
/** Liest einen gescannten QR-Code. null = kein BehördenKlar-Code. */
|
||||
export function leseUebergabeLink(text: string): UebergabeLink | null {
|
||||
const t = (text || '').trim();
|
||||
const lokal = LOKALE_SEITE.test(t);
|
||||
if (!ERLAUBTE_SEITEN.test(t) && !lokal) return null;
|
||||
const raute = t.indexOf('#');
|
||||
if (raute < 0) return null;
|
||||
const werte: Record<string, string> = {};
|
||||
for (const teil of t.slice(raute + 1).split('&')) {
|
||||
const i = teil.indexOf('=');
|
||||
if (i > 0) werte[teil.slice(0, i)] = decodeURIComponent(teil.slice(i + 1));
|
||||
}
|
||||
const art = werte.a as UebergabeArt;
|
||||
if (art === 'g') return { art, id: '', schluessel: '', basis: '' };
|
||||
if ((art !== 'k' && art !== 'm') || !/^[A-Za-z0-9_-]{22}$/.test(werte.s ?? '') || !/^[A-Za-z0-9_-]{43}$/.test(werte.k ?? '')) {
|
||||
return null;
|
||||
}
|
||||
return { art, id: werte.s, schluessel: werte.k, basis: lokal ? LOKAL_BASIS : LIVE_BASIS };
|
||||
}
|
||||
|
||||
export function base64UrlZuBase64(s: string): string {
|
||||
const b = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
return b + '='.repeat((4 - (b.length % 4)) % 4);
|
||||
}
|
||||
export function base64ZuBase64Url(s: string): string {
|
||||
return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/** Was die Webseite als Ergebnis schickt bzw. erwartet. */
|
||||
export interface WebPaket {
|
||||
analyse: WebAnalyse;
|
||||
analyse_de?: WebAnalyse;
|
||||
sprache?: string;
|
||||
}
|
||||
/** Die Analyse der Webseite: wie BriefAnalyse, dafür mit Kontakt + Anruf-Hilfe. */
|
||||
type WebAnalyse = Partial<BriefAnalyse> & {
|
||||
kontakt?: unknown;
|
||||
anruf?: unknown;
|
||||
};
|
||||
|
||||
function textOder(s: unknown, ersatz = ''): string {
|
||||
return typeof s === 'string' ? s : ersatz;
|
||||
}
|
||||
function alsBriefAnalyse(w: WebAnalyse): BriefAnalyse {
|
||||
return {
|
||||
brieftyp: textOder(w.brieftyp, 'Brief'),
|
||||
absender: textOder(w.absender),
|
||||
kernaussage: textOder(w.kernaussage),
|
||||
erklaerung_einfach: textOder(w.erklaerung_einfach),
|
||||
fachbegriffe: Array.isArray(w.fachbegriffe) ? (w.fachbegriffe as Fachbegriff[]) : [],
|
||||
frist: w.frist ?? null,
|
||||
termin: w.termin ?? null,
|
||||
checkliste: Array.isArray(w.checkliste) ? w.checkliste.filter((x) => typeof x === 'string') : [],
|
||||
// Die Web-Analyse kennt keine Antwort-Vorschläge — die App kann sie nicht erfinden
|
||||
antwort_noetig: false,
|
||||
antwort_optionen: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Ergebnis vom PC → Brief fürs Archiv. Deutsch ist die Grundlage, die
|
||||
* andere Sprache landet im Übersetzungs-Cache (wie bei App-Übersetzungen). */
|
||||
export function webPaketZuBrief(p: WebPaket, id: string, jetzt: Date): BriefEintrag {
|
||||
const sprache = p.sprache && p.sprache !== 'de' ? p.sprache : null;
|
||||
const deutsch = p.analyse_de ?? (sprache ? null : p.analyse);
|
||||
const basis = alsBriefAnalyse(deutsch ?? p.analyse);
|
||||
const uebersetzungen: Record<string, Uebersetzung> = {};
|
||||
if (sprache) {
|
||||
const f = alsBriefAnalyse(p.analyse);
|
||||
uebersetzungen[sprache] = {
|
||||
kernaussage: f.kernaussage,
|
||||
erklaerung_einfach: f.erklaerung_einfach,
|
||||
checkliste: f.checkliste,
|
||||
fachbegriffe: f.fachbegriffe,
|
||||
};
|
||||
}
|
||||
return { id, erstelltAm: jetzt.toISOString(), analyse: basis, uebersetzungen };
|
||||
}
|
||||
|
||||
/** Brief aus dem Archiv → Ergebnis für den PC. Gibt es eine Übersetzung,
|
||||
* bekommt der PC beide Sprachen (Deutsch zuerst für die Beratung). */
|
||||
export function briefZuWebPaket(b: BriefEintrag): WebPaket {
|
||||
const deutsch: WebAnalyse = { ...b.analyse };
|
||||
const [sprache] = Object.keys(b.uebersetzungen ?? {});
|
||||
if (!sprache) return { analyse: deutsch, sprache: 'de' };
|
||||
return {
|
||||
analyse: { ...b.analyse, ...b.uebersetzungen[sprache] },
|
||||
analyse_de: deutsch,
|
||||
sprache,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user