generated from knso/webklar-preview-template
Compare commits
5
Commits
9622fd68bd
...
6a7f35eb19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a7f35eb19 | ||
|
|
cf188af080 | ||
|
|
014737bd95 | ||
|
|
0eab2a0ee9 | ||
|
|
7621dd054a |
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>
|
||||
|
||||
+33
-2
@@ -1,6 +1,6 @@
|
||||
# BehördenKlar — Überblick für Claude
|
||||
|
||||
> Stand: 23.09.2026. Diese Datei beschreibt, **was es gibt**.
|
||||
> Stand: 22.10.2026. Diese Datei beschreibt, **was es gibt**.
|
||||
> Für Release-Stand und offene Aufgaben siehe die Projekt-Erinnerung.
|
||||
> Für Arbeitsregeln siehe `AGENTS.md` (Expo-Docs vor Code lesen!).
|
||||
|
||||
@@ -92,6 +92,30 @@ Sprechzeiten — und schreibt einen **Spickzettel** mit 3–5 Sätzen für das T
|
||||
**Regel im Prompt:** Kontaktdaten nur, wenn sie **wörtlich** im Brief stehen.
|
||||
Getestet mit einem Brief ohne Telefonnummer → `telefon: null`, nichts erfunden.
|
||||
Eine falsche Nummer wäre schlimmer als keine.
|
||||
### 3c. Handy ↔ PC — für die Beratung (seit 22.10.)
|
||||
Ein QR-Code verbindet zwei Geräte, **Ende-zu-Ende verschlüsselt** (AES-256-GCM).
|
||||
Schlüssel und Übergabe-ID stehen hinter dem `#` der Adresse, gehen also nie an
|
||||
einen Server. Der Worker hält nur Chiffrat, höchstens 15 Minuten
|
||||
(Durable Object `Uebergabe`, Route `/uebergabe/<id>[/<fach>]`).
|
||||
|
||||
| Art (`a=`) | Wer zeigt den QR | Was passiert |
|
||||
|---|---|---|
|
||||
| `k` Kamera | PC: „Lieber mit dem Handy fotografieren" | Handy fotografiert, Ergebnis erscheint am PC **auf Deutsch** und am Handy **in der Muttersprache** (`auchDeutsch` → Worker analysiert deutsch und übersetzt mit Haiku, kostet 1 Analyse). Guthaben des PCs gilt am Handy nur für diese Sitzung. |
|
||||
| `m` Mitnehmen | PC: „Ergebnis aufs Handy mitnehmen" | Ergebnis landet auf dem Handy (Browser) bzw. im App-Archiv, einmal abholbar |
|
||||
| `g` Guthaben | PC: „Auch auf dem Handy nutzen" | Guthaben/Einrichtungs-Zugang dauerhaft aufs Handy (Browser) |
|
||||
|
||||
Gleiches Päckchen-Format in `webseite/uebergabe.js` und `src/services/uebergabe.ts`
|
||||
(IV‖Chiffrat‖Tag, base64url) — Test: `src/utils/__tests__/uebergabe.test.ts`.
|
||||
**App:** Brief → „Am Computer zeigen" (`a=k`), Archiv → „Vom Computer übernehmen"
|
||||
(`a=m`), Bildschirm `VerbindenScreen` mit QR-Scanner und Link-Einfügen als Ersatz.
|
||||
|
||||
### 3d. Einrichtungs-Codes für Beratungsstellen (seit 22.10.)
|
||||
Die Einrichtung zahlt, Ratsuchende nicht. Ein Code (z. B. `MBE-K7QX`) zeigt auf
|
||||
ein Guthaben wie beim Kauf, mit Name und Ablaufdatum. Einlösen per Link
|
||||
`behoerdenklar.de/demo?code=…` oder Eingabefeld; Banner „Kostenlos über …".
|
||||
Verwalten mit `node tools/einrichtungs-code.js neu|stand|aufladen|verlaengern`.
|
||||
Falsche Codes: max. 10 pro IP und Tag.
|
||||
|
||||
### 4. Ampel statt Datum
|
||||
Rot ≤ 7 Tage, gelb ≤ 21, sonst grün. Die Startseite sortiert nach
|
||||
**Dringlichkeit**, nicht nach Eingang — man sieht sofort, was brennt.
|
||||
@@ -207,6 +231,8 @@ und Sprachwähler in der handgemachten `index.html` nach und schreibt die
|
||||
| `/kaufen` | Stripe-Bezahlseite erzeugen |
|
||||
| `/kauf-status` | Nach Rückkehr: Zahlung prüfen, Guthaben freischalten |
|
||||
| `/stripe-webhook` | Sicherungsnetz + Wiederherstellungs-Mail |
|
||||
| `/code` | Einrichtungs-Code einlösen → signiertes Guthaben-Token |
|
||||
| `/uebergabe/<id>` | Handy ↔ PC: verschlüsselte Päckchen (Durable Object, 15 Min.) |
|
||||
|
||||
### Bezahlung (LIVE, echtes Geld)
|
||||
- **Guthaben statt Abo:** 5 Analysen 2,99 € · 15 Analysen 6,99 €
|
||||
@@ -247,11 +273,16 @@ rotiert werden.
|
||||
|
||||
```bash
|
||||
npm run pruefung # Gesundheits-Check — nach JEDEM Deploy
|
||||
npm test # 35 Tests
|
||||
npm test # 43 Tests
|
||||
npx tsc --noEmit # Typen
|
||||
node tools/sprachseiten/build.js # Sprachseiten neu bauen
|
||||
```
|
||||
|
||||
**Lokal testen** (ohne live zu gehen): in `/Users/homefolder/Behörden_App/.claude/launch.json`
|
||||
`proxy-worker` (Port 8799, `.dev.vars` mit `LOKAL_TEST=1` und Turnstile-Testschlüssel)
|
||||
+ `webseite` (Port 4173) starten, dann `http://localhost:4173/demo.html`. Auf
|
||||
localhost nimmt die Seite automatisch den lokalen Worker und den Test-Sitekey.
|
||||
|
||||
**Deploy:**
|
||||
```bash
|
||||
cd proxy && npx wrangler deploy # Worker
|
||||
|
||||
+183
-40
@@ -20,39 +20,59 @@ selbst ausprobieren. Das überzeugt mehr als jedes PDF.
|
||||
| **MBE Donnersberg** (Migrationsberatung, Kirchheimbolanden) | mbe@fid-donnersberg.de | 06352 7897955 | Erstmail 17.07. · Nachfass: ____ | ____ | |
|
||||
| **Diakonie Donnersbergkreis** (Soziale Lebensberatung, Kirchheimbolanden) | slb.kibo@diakonie-pfalz.de | *auf diakonie-pfalz.de nachsehen* | ____ | ____ | |
|
||||
| **Caritas-Zentrum Kaiserslautern** (Migrationsberatung, Engelsgasse 1) | Migration.Kaiserslautern@caritas-speyer.de | 0631 36120-222 | ____ | ____ | |
|
||||
| **DRK Kaiserslautern-Stadt** (Migrationsberatung, Barbarossastraße 27) — Birgit Steinmann, Leitung · nutzt schon mbeon | b.steinmann@kv-kls.drk.de | 0631 80093-140 | ____ | ____ | |
|
||||
|
||||
---
|
||||
|
||||
## Mail 1 — MBE Donnersberg *(Nachfass-Mail — die haben im Juli schon eine bekommen)*
|
||||
|
||||
**Von:** justin@webklar.com *(direkt aus dem Webklar-Postfach senden)*
|
||||
**An:** mbe@fid-donnersberg.de
|
||||
**Betreff:** Nachtrag zu meiner Mail vom Juli: BehördenKlar jetzt im Browser nutzbar
|
||||
**Betreff:** Nachtrag zu meiner Mail vom Juli: BehördenKlar jetzt ausprobierbar
|
||||
|
||||
> Bewusst kürzer als die Erstmail. Die Juli-Mail nannte noch „iPhone-App", ein Abo
|
||||
> (2,99 €/Monat) und Anhänge — beides stimmt so nicht mehr, deshalb Preis ehrlich
|
||||
> korrigieren (Pakete statt Abo). Nach Absenden: ANRUFEN (06352 7897955), die Mail
|
||||
> ist schon 2½ Monate her.
|
||||
|
||||
Sehr geehrtes Team der Migrationsberatung,
|
||||
|
||||
im Juli hatte ich Ihnen von BehördenKlar geschrieben, einem Werkzeug, das
|
||||
Behördenbriefe in einfacher Sprache erklärt. Seitdem hat sich viel getan, deshalb
|
||||
melde ich mich noch einmal kurz.
|
||||
im Juli hatte ich Ihnen von BehördenKlar geschrieben. Seitdem hat sich viel getan,
|
||||
deshalb melde ich mich noch einmal kurz.
|
||||
|
||||
Man kann es jetzt direkt im Browser nutzen — auf jedem Handy, ohne App und ohne Konto:
|
||||
**https://behoerdenklar.de/demo**
|
||||
Schwierige Briefe vom Amt kenne ich von klein auf: Meine Mutter ist selbst nach
|
||||
Deutschland gekommen, und solche Briefe gehörten bei uns zum Alltag. Deshalb arbeite
|
||||
ich an einer App, die Behördenbriefe in einfacher Sprache erklärt, auf Wunsch in
|
||||
einer von 27 Sprachen, darunter Arabisch, Farsi, Paschtu, Kurdisch, Tigrinya, Türkisch
|
||||
und Ukrainisch.
|
||||
|
||||
- Brief fotografieren → Erklärung in einfachem Deutsch **oder in einer von 27
|
||||
Sprachen**, u. a. Arabisch, Farsi, Kurdisch, Tigrinya und Ukrainisch. Die deutschen
|
||||
Fachbegriffe bleiben stehen, damit man sie im Brief wiederfindet.
|
||||
- **Frist und Termin mit einem Tipp in den Handy-Kalender**, mit Erinnerung vorher.
|
||||
- **Anruf-Hilfe:** Telefonnummer und Aktenzeichen aus dem Brief, dazu ein kurzer
|
||||
Spickzettel, was man am Telefon sagen kann.
|
||||
Die App ist noch in Arbeit, aber die Browser-Version läuft schon, auf jedem Handy,
|
||||
ohne Konto und ohne Installation: https://behoerdenklar.de/demo
|
||||
|
||||
Es soll Ihre Beratung nicht ersetzen — eher dafür sorgen, dass Menschen vorbereitet
|
||||
zu Ihnen kommen und keine Frist verpassen.
|
||||
- Die deutschen Fachbegriffe bleiben sichtbar, damit man sie im Brief wiederfindet.
|
||||
- Fristen und Termine kommen mit einem Tipp in den Handy-Kalender, damit keine Frist
|
||||
untergeht.
|
||||
- Für Anrufe beim Amt gibt es einen Spickzettel mit der Bedeutung in der eigenen
|
||||
Sprache.
|
||||
|
||||
Dürfte ich es Ihnen einmal 10 Minuten zeigen? Gern bei Ihnen in Kirchheimbolanden
|
||||
oder am Telefon — ich richte mich nach Ihrer Zeit.
|
||||
Es soll Ihre Beratung nicht ersetzen, sondern dafür sorgen, dass Menschen vorbereitet
|
||||
zu Ihnen kommen.
|
||||
|
||||
Eine Korrektur zu meiner Juli-Mail: Es wird kein Abo geben. Zwei Briefe sind gratis,
|
||||
danach gibt es kleine Pakete ab 2,99 €, weil die KI pro Brief Geld kostet. Für
|
||||
Beratungsstellen möchte ich eine eigene, deutlich günstigere Lösung anbieten, bei der
|
||||
die Ratsuchenden selbst nichts bezahlen. Ums große Geld geht es mir dabei nicht. Wie
|
||||
das für Sie aussehen könnte, würde ich gern gemeinsam mit Ihnen überlegen.
|
||||
|
||||
Dürfte ich es Ihnen 10 Minuten zeigen, gern bei Ihnen in Kirchheimbolanden oder am
|
||||
Telefon? Mich interessiert vor allem Ihre ehrliche Einschätzung, solange die App noch
|
||||
entsteht: Was würde Ihren Ratsuchenden wirklich helfen, und was fehlt noch?
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
Justin Klein
|
||||
Webklar GbR · Winnweiler · behoerdenbriefhelfer@gmail.com
|
||||
Webklar GbR · Winnweiler
|
||||
Tel. 0170 4969375
|
||||
justin@webklar.com
|
||||
|
||||
---
|
||||
|
||||
@@ -92,35 +112,136 @@ Webklar GbR · Winnweiler · behoerdenbriefhelfer@gmail.com
|
||||
|
||||
## Mail 3 — Caritas Kaiserslautern *(Erstmail)*
|
||||
|
||||
**Von:** justin@webklar.com *(direkt aus dem Webklar-Postfach senden)*
|
||||
**An:** Migration.Kaiserslautern@caritas-speyer.de
|
||||
**Betreff:** Behördenbriefe in 27 Sprachen erklärt — 10 Minuten für Ihre Einschätzung?
|
||||
**Betreff:** Behördenbriefe in 27 Sprachen erklärt – als Ergänzung zu Ihrer Beratung
|
||||
|
||||
> Aufbau wie Mail 4 (DRK). Kein mbeon-Absatz, weil unbekannt ist, ob Caritas es nutzt.
|
||||
> Steht auf caritas.de oder der Zentrum-Seite ein Name der Leitung: Anrede
|
||||
> persönlich machen („Sehr geehrte Frau …") — wirkt deutlich besser als „Team".
|
||||
|
||||
Sehr geehrtes Team der Migrationsberatung,
|
||||
|
||||
mein Name ist Justin Klein, ich komme aus Winnweiler. Ich habe BehördenKlar
|
||||
entwickelt: eine Webseite, die Behördenbriefe in einfacher Sprache erklärt — auf
|
||||
Wunsch in einer von 27 Sprachen, darunter Arabisch, Farsi/Dari, Paschtu, Kurdisch,
|
||||
Tigrinya, Somali und Ukrainisch.
|
||||
mein Name ist Justin Klein, ich komme aus Winnweiler. Schwierige Briefe vom Amt kenne
|
||||
ich von klein auf: Meine Mutter ist selbst nach Deutschland gekommen, und solche
|
||||
Briefe gehörten bei uns zum Alltag. Ich weiß, wie viel Sorge ein Brief machen kann,
|
||||
den man nicht versteht.
|
||||
|
||||
Sie funktioniert direkt im Browser, auf jedem Handy, ohne App und ohne Konto:
|
||||
**https://behoerdenklar.de/demo**
|
||||
Deshalb arbeite ich an BehördenKlar: einer App, die Behördenbriefe in einfacher
|
||||
Sprache erklärt, auf Wunsch in einer von 27 Sprachen, darunter Arabisch,
|
||||
Farsi, Paschtu, Kurdisch, Tigrinya, Türkisch und Ukrainisch.
|
||||
|
||||
- Brief fotografieren → Erklärung in der eigenen Sprache. Die **deutschen
|
||||
Fachbegriffe bleiben sichtbar**, damit man sie im Brief wiederfindet.
|
||||
- **Frist und Termin mit einem Tipp in den Handy-Kalender**, mit Erinnerung vorher.
|
||||
- **Anruf-Hilfe:** Telefonnummer und Aktenzeichen aus dem Brief und ein Spickzettel
|
||||
für das Telefonat — jeder Satz auf Deutsch, darunter die Bedeutung in der eigenen
|
||||
Sprache.
|
||||
Ich habe mir dabei viele Gedanken gemacht, was den Menschen, die zu Ihnen kommen,
|
||||
wirklich hilft:
|
||||
|
||||
Es ersetzt Ihre Beratung nicht. Es soll helfen, dass Ihre Klientinnen und Klienten
|
||||
vorbereitet zu Ihnen kommen und zwischen den Terminen keine Frist verpassen.
|
||||
- Es braucht kein Konto, damit niemand schon an einer Anmeldung scheitert, und die
|
||||
Browser-Version auch keine Installation.
|
||||
- Die deutschen Fachbegriffe bleiben sichtbar, damit man sie im Brief wiederfindet
|
||||
und gezielt nachfragen kann.
|
||||
- Fristen und Termine kommen mit einem Tipp in den Handy-Kalender, damit keine Frist
|
||||
untergeht.
|
||||
- Für Anrufe beim Amt gibt es einen Spickzettel: jeder Satz auf Deutsch, darunter die
|
||||
Bedeutung in der eigenen Sprache.
|
||||
- Unter jeder Erklärung steht der Hinweis, dass bei wichtigen Entscheidungen eine
|
||||
Beratungsstelle hilft.
|
||||
- Die Briefe werden nicht dauerhaft gespeichert.
|
||||
|
||||
Die App ist noch in Arbeit, aber die Browser-Version können Sie schon jetzt direkt
|
||||
selbst ausprobieren: https://behoerdenklar.de/demo
|
||||
|
||||
BehördenKlar soll Ihre Beratung nicht ersetzen, sondern davor ansetzen: Wer einen
|
||||
Brief bekommt, versteht sofort, worum es geht und ob es eilt, auch abends oder am
|
||||
Wochenende. Mit den wichtigen Fragen kommt man dann vorbereitet zu Ihnen und
|
||||
verpasst zwischen den Terminen keine Frist.
|
||||
|
||||
Ums große Geld geht es mir dabei nicht, aber die KI, die die Briefe erklärt, kostet
|
||||
mich pro Brief Geld. Deshalb ist die Browser-Version für Privatleute nicht ganz kostenlos: Zwei
|
||||
Briefe sind gratis, danach gibt es kleine Pakete ab 2,99 €. Für Beratungsstellen
|
||||
möchte ich eine eigene, deutlich günstigere Lösung anbieten, bei der die Ratsuchenden
|
||||
selbst nichts bezahlen. Wie das für Sie aussehen könnte, würde ich gern gemeinsam mit
|
||||
Ihnen überlegen.
|
||||
|
||||
Dürfte ich es Ihnen 10 Minuten zeigen, gern bei Ihnen in der Engelsgasse oder am
|
||||
Telefon? Ich richte mich nach Ihrer Zeit.
|
||||
Telefon? Mich interessiert vor allem Ihre ehrliche Einschätzung, solange die App noch
|
||||
entsteht: Was würde Ihren Ratsuchenden wirklich helfen, und was fehlt noch?
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
Justin Klein
|
||||
Webklar GbR · Winnweiler · behoerdenbriefhelfer@gmail.com
|
||||
Webklar GbR · Winnweiler
|
||||
Tel. 0170 4969375
|
||||
justin@webklar.com
|
||||
|
||||
---
|
||||
|
||||
## Mail 4 — DRK Kaiserslautern-Stadt *(Erstmail, persönlich an die Leitung)*
|
||||
|
||||
**Von:** justin@webklar.com *(direkt aus dem Webklar-Postfach senden, nicht über
|
||||
Gmail „Senden als" — webklar.com hat DMARC `p=quarantine`, sonst droht Spam-Ordner)*
|
||||
**An:** b.steinmann@kv-kls.drk.de
|
||||
**Betreff:** Behördenbriefe in 27 Sprachen erklärt – als Ergänzung zu mbeon
|
||||
|
||||
> Hintergrund: Das DRK KL berät schon über **mbeon** (Online-Beratung der MBE, für
|
||||
> Ratsuchende kostenlos). Deshalb als *Ergänzung* positionieren, nie als Konkurrenz.
|
||||
> Ton: helfend, persönlich (Mutter), Gedanken zeigen statt behaupten (jeder Punkt
|
||||
> mit „damit …"). „Ums große Geld geht es mir nicht" nur EINMAL — öfter wirkt es wie
|
||||
> das Gegenteil. Preise: Webseite = Privatleute, Beratungsstellen deutlich günstiger,
|
||||
> Ratsuchende zahlen nichts, KEINE Zahlen (siehe „Preise für Beratungsstellen").
|
||||
> Alle Funktions-Aussagen am 30.09. gegen demo.html + Demo-Prompt geprüft.
|
||||
> KI + Datenschutz MÜSSEN in der Mail stehen (kurz, Details erst im Gespräch): Die Demo-Seite nennt vor dem Hochladen
|
||||
> „KI-Dienst Anthropic (USA)" — fehlt das in der Mail, wirkt es verschwiegen.
|
||||
> Fragt sie „Was kostet Sie ein Brief?": ehrlich antworten (ein paar Cent KI +
|
||||
> Zahlungsgebühren + Server + Arbeitszeit), nicht ausweichen.
|
||||
|
||||
Sehr geehrte Frau Steinmann,
|
||||
|
||||
mein Name ist Justin Klein, ich komme aus Winnweiler. Schwierige Briefe vom Amt kenne
|
||||
ich von klein auf: Meine Mutter ist selbst nach Deutschland gekommen, und solche
|
||||
Briefe gehörten bei uns zum Alltag. Ich weiß, wie viel Sorge ein Brief machen kann,
|
||||
den man nicht versteht.
|
||||
|
||||
Deshalb arbeite ich an BehördenKlar: einer App, die Behördenbriefe in einfacher
|
||||
Sprache erklärt, auf Wunsch in einer von 27 Sprachen, darunter Arabisch,
|
||||
Farsi, Paschtu, Kurdisch, Tigrinya, Türkisch und Ukrainisch.
|
||||
|
||||
Ich habe mir dabei viele Gedanken gemacht, was den Menschen, die zu Ihnen kommen,
|
||||
wirklich hilft:
|
||||
|
||||
- Es braucht kein Konto, damit niemand schon an einer Anmeldung scheitert, und die
|
||||
Browser-Version auch keine Installation.
|
||||
- Die deutschen Fachbegriffe bleiben sichtbar, damit man sie im Brief wiederfindet
|
||||
und gezielt nachfragen kann.
|
||||
- Fristen und Termine kommen mit einem Tipp in den Handy-Kalender, damit keine Frist
|
||||
untergeht.
|
||||
- Für Anrufe beim Amt gibt es einen Spickzettel: jeder Satz auf Deutsch, darunter die
|
||||
Bedeutung in der eigenen Sprache.
|
||||
- Unter jeder Erklärung steht der Hinweis, dass bei wichtigen Entscheidungen eine
|
||||
Beratungsstelle hilft.
|
||||
- Die Briefe werden nicht dauerhaft gespeichert.
|
||||
|
||||
Die App ist noch in Arbeit, aber die Browser-Version können Sie schon jetzt direkt
|
||||
selbst ausprobieren: https://behoerdenklar.de/demo
|
||||
|
||||
Ich habe gesehen, dass Sie auch über mbeon beraten. BehördenKlar soll das nicht
|
||||
ersetzen, sondern davor ansetzen: Wer einen Brief bekommt, versteht sofort, worum es
|
||||
geht und ob es eilt, auch abends oder am Wochenende. Mit den wirklich wichtigen
|
||||
Fragen kommt man dann vorbereitet zu Ihnen, persönlich oder über mbeon.
|
||||
|
||||
Ums große Geld geht es mir dabei nicht, aber die KI, die die Briefe erklärt, kostet
|
||||
mich pro Brief Geld.
|
||||
Deshalb ist die Browser-Version für Privatleute nicht ganz kostenlos: Zwei Briefe sind gratis,
|
||||
danach gibt es kleine Pakete ab 2,99 €. Für Beratungsstellen möchte ich eine eigene,
|
||||
deutlich günstigere Lösung anbieten, bei der die Ratsuchenden selbst nichts bezahlen.
|
||||
Wie das für Sie aussehen könnte, würde ich gern gemeinsam mit Ihnen überlegen.
|
||||
|
||||
Dürfte ich es Ihnen 10 Minuten zeigen, gern bei Ihnen in der Barbarossastraße oder am
|
||||
Telefon? Mich interessiert vor allem Ihre ehrliche Einschätzung, solange die App noch
|
||||
entsteht: Was würde Ihren Ratsuchenden wirklich helfen, und was fehlt noch?
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
Justin Klein
|
||||
Webklar GbR · Winnweiler
|
||||
Tel. 0170 4969375
|
||||
justin@webklar.com
|
||||
|
||||
---
|
||||
|
||||
@@ -164,9 +285,10 @@ fehlt noch" ist für dich schon ein Erfolg.
|
||||
> erfinden wir nicht: Steht nichts im Brief, sagen wir das.
|
||||
|
||||
**„Was kostet das für unsere Klienten?"**
|
||||
> Zwei Briefe sind kostenlos. Danach 2,99 € für fünf Briefe oder 6,99 € für
|
||||
> fünfzehn — kein Abo. Wenn Sie eine Idee haben, wie das für Ihre Einrichtung
|
||||
> einfacher geht, bin ich offen dafür.
|
||||
> Für Privatleute: zwei Briefe kostenlos, danach 2,99 € für fünf oder 6,99 € für
|
||||
> fünfzehn — kein Abo. Für Beratungsstellen soll es anders laufen: Die Einrichtung
|
||||
> bekommt ein Briefkontingent zu einem deutlich günstigeren Preis, und die
|
||||
> Ratsuchenden zahlen gar nichts. *(Zahlen: siehe „Preise für Beratungsstellen".)*
|
||||
|
||||
**„Ersetzt das unsere Beratung?"**
|
||||
> Nein. Bei rechtlich wichtigen Entscheidungen verweisen wir ausdrücklich auf
|
||||
@@ -187,6 +309,27 @@ fehlt noch" ist für dich schon ein Erfolg.
|
||||
4. Darf ich Ihre Stelle auf meiner Seite als Beratungsstelle nennen?
|
||||
*(Und umgekehrt: Würden Sie auf BehördenKlar verlinken? — Das hilft enorm bei Google.)*
|
||||
|
||||
**Nicht in die Mail, erst im Gespräch:** die Idee eines Guthabens, das die
|
||||
Einrichtung für ihre Klienten bereitstellt. Das gibt es technisch noch nicht —
|
||||
also nichts versprechen, nur fragen, ob das interessant wäre.
|
||||
---
|
||||
|
||||
## Preise für Beratungsstellen — nur im Gespräch, nie in die Mail *(Vorschlag 30.09.)*
|
||||
|
||||
**Grundidee:** Die Einrichtung bezahlt, die Ratsuchenden zahlen nichts. Die
|
||||
Einrichtung kauft vorher ein **Briefkontingent** — jeder Brief ist bezahlt, bevor er
|
||||
genutzt wird. Mehr Nutzer heißt mehr Einnahmen, nie mehr Verlust.
|
||||
|
||||
| | Preis | pro Brief |
|
||||
|---|---|---|
|
||||
| Deine Kosten (eigene Rechnung ~2,5 ct, mit Puffer für fremde Sprachen) | — | ~5 ct |
|
||||
| Privatleute (Webseite) | 2,99 € für 5 · 6,99 € für 15 | 47–60 ct |
|
||||
| **Test** für eine Beratungsstelle, 1 Monat (verlängerbar) + Auswertungsgespräch | bis 100 Briefe gratis | kostet dich max. ~5 € |
|
||||
| **Kleines Paket** | 100 Briefe · 20 € | 20 ct |
|
||||
| **Großes Paket** | 500 Briefe · 75 € | 15 ct |
|
||||
| **Untergrenze beim Verhandeln** | — | 10 ct |
|
||||
|
||||
- **Nie „unbegrenzt"** anbieten — immer eine feste Briefzahl. Wenn sie „Lizenz"
|
||||
sagen: „Jahreslizenz mit X Briefen" ist dasselbe.
|
||||
- **Keine Mehrwertsteuer** (Kleinunternehmer §19) — der Preis ist der Endpreis.
|
||||
- Echte Kosten einmal in der Anthropic-Konsole unter „Usage" nachsehen, dann ist
|
||||
die Untergrenze sicher.
|
||||
- Den **Einrichtungs-Code gibt es technisch noch nicht** — nichts mit Datum
|
||||
versprechen. Bauen, sobald eine Stelle ernsthaft Ja sagt.
|
||||
@@ -0,0 +1,202 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Handzettel für Beratungsstellen (DIN A4, zum Dalassen nach der Präsentation).
|
||||
CODE unten eintragen, sobald der Einrichtungs-Code angelegt ist
|
||||
(node tools/einrichtungs-code.js neu "MBE Donnersberg" MBE --briefe 100 --monate 1),
|
||||
dann neu als PDF ausgeben:
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new \
|
||||
--no-pdf-header-footer --print-to-pdf=handzettel.pdf "file://$PWD/handzettel.html"
|
||||
-->
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>BehördenKlar für Beratungsstellen</title>
|
||||
<script src="../../webseite/qrcode.js"></script>
|
||||
<style>
|
||||
@page { size: A4; margin: 0; }
|
||||
:root {
|
||||
--papier: #faf7f0; --tinte: #21211d; --grau: #6b675e;
|
||||
--amtsblau: #1d4e89; --stempelrot: #b3372b; --marker: #ffe66b; --gruen: #2e7d4f;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
body { font-family: Georgia, 'Times New Roman', serif; color: var(--tinte); background: #fff; }
|
||||
.seite {
|
||||
width: 210mm; height: 297mm; overflow: hidden; position: relative;
|
||||
padding: 11mm 14mm 0;
|
||||
background: repeating-linear-gradient(0deg, transparent 0 6.5mm, rgba(33,33,29,0.03) 6.5mm 6.7mm), var(--papier);
|
||||
font-size: 9.6pt; line-height: 1.42;
|
||||
}
|
||||
.mono { font-family: 'Courier New', ui-monospace, monospace; }
|
||||
.briefkopf { display: flex; justify-content: space-between; align-items: baseline; padding-bottom: 2mm; border-bottom: 0.6mm solid var(--tinte); }
|
||||
.absender { font-weight: 700; font-size: 12.5pt; }
|
||||
.absender small { font-weight: 400; font-size: 8pt; opacity: .6; letter-spacing: .04em; }
|
||||
.az { color: var(--grau); font-size: 8pt; }
|
||||
.betreff { margin-top: 5mm; font-size: 8pt; color: var(--grau); text-transform: uppercase; letter-spacing: .14em; }
|
||||
h1 { font-size: 23pt; line-height: 1.14; margin-top: 1.5mm; letter-spacing: -0.01em; }
|
||||
.markiert { background: linear-gradient(104deg, transparent 2%, var(--marker) 4%, var(--marker) 96%, transparent 98%); padding: 0 1.5mm; }
|
||||
.anrede { margin-top: 3mm; font-size: 10.5pt; max-width: 165mm; }
|
||||
|
||||
.schritte { display: flex; gap: 3mm; margin-top: 4.5mm; }
|
||||
.schritt { flex: 1; border: 0.4mm solid var(--tinte); background: #fff; padding: 2.2mm 3mm; font-size: 9pt; line-height: 1.3; }
|
||||
.schritt b { font-family: 'Courier New', monospace; color: var(--amtsblau); margin-right: 1mm; }
|
||||
|
||||
h2 { font-size: 8pt; text-transform: uppercase; letter-spacing: .14em; color: var(--grau); font-weight: 400;
|
||||
margin: 5mm 0 2.5mm; display: flex; align-items: center; gap: 3mm; }
|
||||
h2::after { content: ''; flex: 1; border-top: 0.25mm solid rgba(33,33,29,.25); }
|
||||
|
||||
.raster { display: grid; grid-template-columns: 1fr 1fr; gap: 2.4mm 5mm; }
|
||||
.punkt { display: flex; gap: 2.5mm; align-items: flex-start; }
|
||||
.punkt .ik { font-size: 12pt; line-height: 1.2; width: 6mm; flex-shrink: 0; text-align: center; }
|
||||
.punkt h3 { font-size: 10pt; line-height: 1.25; }
|
||||
.punkt p { color: #3a3833; font-size: 8.8pt; line-height: 1.36; }
|
||||
.punkt.neu h3::after { content: 'NEU'; font-family: 'Courier New', monospace; font-size: 6.5pt; letter-spacing: .1em;
|
||||
color: var(--stempelrot); border: 0.3mm solid var(--stempelrot); padding: 0 1mm; margin-left: 1.5mm; vertical-align: 1px; }
|
||||
|
||||
.unten { display: grid; grid-template-columns: 1fr 72mm; gap: 5mm; margin-top: 5mm; }
|
||||
.kasten { background: #fff; border: 0.5mm solid var(--tinte); padding: 3.5mm 4mm; position: relative; }
|
||||
.kasten .label { position: absolute; top: -2.6mm; left: 3.5mm; font-family: 'Courier New', monospace; font-size: 7pt;
|
||||
text-transform: uppercase; letter-spacing: .1em; background: var(--tinte); color: var(--papier); padding: .4mm 2mm; }
|
||||
.kasten ul { list-style: none; font-size: 8.7pt; line-height: 1.38; }
|
||||
.kasten li { padding-left: 4mm; position: relative; margin-bottom: 1mm; }
|
||||
.kasten li::before { content: '✓'; position: absolute; left: 0; color: var(--gruen); font-weight: 700; }
|
||||
.kasten li.ehrlich::before { content: '!'; color: var(--stempelrot); left: .8mm; }
|
||||
|
||||
.angebot { border: 0.7mm solid var(--tinte); box-shadow: 1.6mm 1.8mm 0 rgba(33,33,29,.9); background: #fff; padding: 4mm; text-align: center; }
|
||||
.angebot .kopf { font-family: 'Courier New', monospace; font-size: 7.5pt; text-transform: uppercase; letter-spacing: .1em; color: var(--grau); }
|
||||
.angebot .gross { font-size: 13pt; font-weight: 700; line-height: 1.25; margin: 1.5mm 0; }
|
||||
.angebot .gross .markiert { padding: 0 1mm; }
|
||||
.angebot .klein { font-size: 8pt; color: var(--grau); line-height: 1.35; }
|
||||
#qr { width: 34mm; height: 34mm; margin: 2.5mm auto 1.5mm; }
|
||||
#qr svg { width: 100%; height: 100%; display: block; }
|
||||
.code { font-family: 'Courier New', monospace; font-weight: 700; font-size: 13pt; letter-spacing: .08em; background: var(--marker); display: inline-block; padding: .3mm 2.5mm; }
|
||||
.link { font-family: 'Courier New', monospace; font-size: 7.5pt; color: var(--amtsblau); word-break: break-all; margin-top: 1mm; }
|
||||
|
||||
.chatgpt { margin-top: 4mm; font-size: 8.7pt; line-height: 1.4; color: #3a3833; }
|
||||
.chatgpt b { color: var(--tinte); }
|
||||
|
||||
.fuss { position: absolute; left: 14mm; right: 14mm; bottom: 8mm; border-top: 0.4mm solid var(--tinte); padding-top: 2mm;
|
||||
display: flex; justify-content: space-between; align-items: flex-end; font-size: 8pt; line-height: 1.4; }
|
||||
.fuss .wer b { font-size: 9.5pt; }
|
||||
.stempel { display: inline-block; padding: 1mm 2.5mm; border: 0.5mm solid var(--stempelrot); color: var(--stempelrot);
|
||||
font-family: 'Courier New', monospace; font-weight: 700; font-size: 7.5pt; letter-spacing: .1em;
|
||||
text-transform: uppercase; transform: rotate(-4deg); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="seite">
|
||||
|
||||
<div class="briefkopf">
|
||||
<span class="absender">📬 BehördenKlar <small>by Webklar</small></span>
|
||||
<span class="az mono">Az: KLAR-BERATUNG/2026 · Anlage: 1 Testzugang</span>
|
||||
</div>
|
||||
|
||||
<p class="betreff">Für Beratungsstellen · Migrationsberatung · Soziale Beratung</p>
|
||||
<h1>Ihre Ratsuchenden verstehen den Brief, <span class="markiert">bevor sie zu Ihnen kommen.</span></h1>
|
||||
<p class="anrede">
|
||||
BehördenKlar erklärt schwierige Post in einfacher Sprache, auf Wunsch in 27 Sprachen:
|
||||
von Ausländerbehörde, Jobcenter, Finanzamt, Krankenkasse, Vermieter oder Inkasso.
|
||||
Ein Foto genügt. Ihre Beratungszeit geht in die Lösung, nicht ins Vorlesen.
|
||||
</p>
|
||||
|
||||
<div class="schritte">
|
||||
<div class="schritt"><b>1</b> Brief mit dem Handy fotografieren, im Browser, ohne App und ohne Konto.</div>
|
||||
<div class="schritt"><b>2</b> Nach etwa einer Minute: Was will das Amt, was ist zu tun, bis wann?</div>
|
||||
<div class="schritt"><b>3</b> Frist in den Kalender, Spickzettel fürs Telefonat, mit den wichtigen Fragen zu Ihnen.</div>
|
||||
</div>
|
||||
|
||||
<h2>Was BehördenKlar kann</h2>
|
||||
<div class="raster">
|
||||
<div class="punkt"><span class="ik">🌐</span><div>
|
||||
<h3>27 Sprachen, Fachbegriffe bleiben deutsch</h3>
|
||||
<p>Arabisch, Farsi, Paschtu, Kurdisch, Tigrinya, Türkisch, Ukrainisch u. v. m. Das deutsche Wort bleibt stehen, damit man es im Brief wiederfindet.</p>
|
||||
</div></div>
|
||||
<div class="punkt"><span class="ik">🚦</span><div>
|
||||
<h3>Frist erkannt, Dringlichkeit sichtbar</h3>
|
||||
<p>Ampel: Rot bei 7 Tagen oder weniger. Mit konkreter To-do-Liste: welche Unterlagen, wohin, bis wann.</p>
|
||||
</div></div>
|
||||
<div class="punkt"><span class="ik">📅</span><div>
|
||||
<h3>Frist und Termin in den Handy-Kalender</h3>
|
||||
<p>Ein Tipp genügt, das Handy erinnert 3 Tage und 1 Tag vorher. Keine Frist geht zwischen zwei Beratungsterminen unter.</p>
|
||||
</div></div>
|
||||
<div class="punkt"><span class="ik">📞</span><div>
|
||||
<h3>Anruf-Hilfe fürs Amt</h3>
|
||||
<p>Telefonnummer und Aktenzeichen aus dem Brief, dazu Sätze zum Sagen: auf Deutsch, darunter die Bedeutung in der eigenen Sprache.</p>
|
||||
</div></div>
|
||||
<div class="punkt neu"><span class="ik">💻</span><div>
|
||||
<h3>Beratung am PC, Foto mit dem Handy</h3>
|
||||
<p>QR-Code am Bildschirm scannen, Brief fotografieren: Sie lesen die Erklärung auf Deutsch, Ihr Gegenüber gleichzeitig in seiner Sprache.</p>
|
||||
</div></div>
|
||||
<div class="punkt neu"><span class="ik">📱</span><div>
|
||||
<h3>Ergebnis zum Mitnehmen</h3>
|
||||
<p>Per QR-Code wandert die Erklärung vom Beratungs-PC aufs Handy der Ratsuchenden, mit Kalender und Anruf-Hilfe.</p>
|
||||
</div></div>
|
||||
<div class="punkt"><span class="ik">🔎</span><div>
|
||||
<h3>Erfindet nichts</h3>
|
||||
<p>Steht keine Telefonnummer oder kein Datum im Brief, sagt BehördenKlar das, statt zu raten.</p>
|
||||
</div></div>
|
||||
<div class="punkt"><span class="ik">⚖️</span><div>
|
||||
<h3>Ergänzung, kein Ersatz</h3>
|
||||
<p>Jede Erklärung verweist bei wichtigen Entscheidungen ausdrücklich auf eine Beratungsstelle.</p>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="unten">
|
||||
<div>
|
||||
<div class="kasten">
|
||||
<span class="label">Datenschutz, ehrlich erklärt</span>
|
||||
<ul>
|
||||
<li>Kein Konto, keine Anmeldung, keine Installation nötig.</li>
|
||||
<li>Briefe werden bei uns nicht dauerhaft gespeichert.</li>
|
||||
<li>Übergabe zwischen PC und Handy Ende-zu-Ende verschlüsselt: Der Schlüssel steckt nur im QR-Code, unser Server sieht nichts.</li>
|
||||
<li>Keine Werbung, kein Verkauf von Daten.</li>
|
||||
<li class="ehrlich">Die Erklärung erstellt der KI-Dienst Anthropic (USA). Er löscht Anfragen nach spätestens 30 Tagen und nutzt sie nicht zum Training. Das sagen wir offen, auch auf der Seite vor dem Hochladen.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="chatgpt">
|
||||
<b>„Ist das nicht einfach ChatGPT?“</b> Dort braucht man ein Konto und eine gut
|
||||
formulierte Frage, und danach ist der Brief vergessen. Bei BehördenKlar macht man
|
||||
ein Foto. Frist, Termin und Telefonnummer werden zu Kalender-Eintrag und
|
||||
Anruf-Hilfe, und deutsche Fachbegriffe bleiben erkennbar.
|
||||
</p>
|
||||
<p class="chatgpt">
|
||||
<b>Und nach dem Test?</b> Ihre Einrichtung bekommt ein festes Briefkontingent zu
|
||||
einem Preis für Beratungsstellen. Die Ratsuchenden zahlen weiterhin nichts. Ohne
|
||||
Abo-Falle: Nach vier Wochen schauen wir gemeinsam, was es gebracht hat.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="angebot">
|
||||
<p class="kopf">Ihr Testzugang</p>
|
||||
<p class="gross">1 Monat · bis 100 Briefe<br><span class="markiert">kostenlos</span></p>
|
||||
<p class="klein">Ratsuchende zahlen nichts. Kein Vertrag, endet von selbst, verlängerbar.</p>
|
||||
<div id="qr"></div>
|
||||
<p class="klein">Code für Ihre Ratsuchenden:</p>
|
||||
<p><span class="code" id="codeText"></span></p>
|
||||
<p class="link" id="linkText"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fuss">
|
||||
<div class="wer">
|
||||
<b>Justin Klein</b> · Webklar GbR · Am Schwimmbad 10, 67722 Winnweiler<br>
|
||||
Tel. 0170 4969375 · justin@webklar.com · <b>behoerdenklar.de</b>
|
||||
</div>
|
||||
<span class="stempel">Aus dem Donnersbergkreis</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Einrichtungs-Code hier eintragen, sobald er angelegt ist (z. B. 'MBE-K7QX').
|
||||
var CODE = '';
|
||||
var url = CODE ? 'https://behoerdenklar.de/demo?code=' + CODE : 'https://behoerdenklar.de/demo';
|
||||
var qr = qrcode(0, 'M');
|
||||
qr.addData(url);
|
||||
qr.make();
|
||||
document.getElementById('qr').innerHTML = qr.createSvgTag({ cellSize: 4, margin: 0, scalable: true });
|
||||
var codeEl = document.getElementById('codeText');
|
||||
codeEl.textContent = CODE || 'wird beim Termin eingetragen';
|
||||
if (!CODE) { codeEl.style.fontSize = '8.5pt'; codeEl.style.letterSpacing = '0'; }
|
||||
document.getElementById('linkText').textContent = url.replace('https://', '');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 615 KiB |
@@ -0,0 +1,141 @@
|
||||
<!DOCTYPE html>
|
||||
<!--
|
||||
Musterbrief für Vorführungen (Präsentation MBE Kirchheimbolanden, 29.10.2026).
|
||||
Behörde, Person, Aktenzeichen und Telefonnummer sind frei erfunden.
|
||||
Zum Ausdrucken: Chrome → Drucken → A4, Ränder "Keine". PDF/PNG daneben.
|
||||
Daten bei Bedarf anpassen: Frist und Termin sollen bei der Vorführung
|
||||
in 1–3 Wochen liegen, damit die Ampel Gelb/Rot zeigt.
|
||||
-->
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Musterbrief Ausländerbehörde</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 0; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { background: #fff; }
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif; color: #111;
|
||||
font-size: 10pt; line-height: 1.32;
|
||||
}
|
||||
.seite { width: 210mm; height: 297mm; position: relative; padding: 0 20mm 0 25mm; overflow: hidden; }
|
||||
.kopf { position: absolute; top: 12mm; left: 25mm; right: 20mm; display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.wappen { display: flex; gap: 4mm; align-items: center; }
|
||||
.wappen svg { width: 13mm; height: 15mm; }
|
||||
.behoerde { font-size: 13pt; font-weight: bold; line-height: 1.2; }
|
||||
.behoerde small { display: block; font-size: 10pt; font-weight: normal; }
|
||||
.absenderzeile { position: absolute; top: 45mm; left: 25mm; font-size: 7pt; text-decoration: underline; }
|
||||
.anschrift { position: absolute; top: 51mm; left: 25mm; width: 85mm; font-size: 10.5pt; line-height: 1.35; }
|
||||
.info { position: absolute; top: 45mm; left: 125mm; width: 65mm; font-size: 8.5pt; line-height: 1.45; }
|
||||
.info td { vertical-align: top; padding: 0 2mm 0.6mm 0; }
|
||||
.info td:first-child { color: #444; white-space: nowrap; }
|
||||
.text { position: absolute; top: 92mm; left: 25mm; right: 20mm; }
|
||||
.betreff { font-weight: bold; margin-bottom: 4mm; }
|
||||
p { margin-bottom: 2.4mm; text-align: justify; hyphens: auto; }
|
||||
ol { margin: 0 0 3mm 6mm; }
|
||||
ol li { margin-bottom: 0.8mm; padding-left: 1mm; }
|
||||
.fett { font-weight: bold; }
|
||||
.gruss { margin-top: 3mm; margin-bottom: 0; }
|
||||
.unterschrift { font-family: 'Brush Script MT', 'Segoe Script', cursive; font-size: 16pt; color: #1b3a7a; margin: 0 0 0 2mm; line-height: 1.2; }
|
||||
.fuss {
|
||||
position: absolute; bottom: 9mm; left: 25mm; right: 20mm;
|
||||
border-top: 0.3mm solid #999; padding-top: 2mm;
|
||||
display: flex; justify-content: space-between; font-size: 7pt; color: #444; line-height: 1.35;
|
||||
}
|
||||
.muster { position: absolute; bottom: 3.5mm; left: 25mm; right: 20mm; font-size: 6.5pt; color: #999; text-align: center; }
|
||||
.falz { position: absolute; left: 4mm; width: 4mm; border-top: 0.2mm solid #bbb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="seite">
|
||||
<span class="falz" style="top: 105mm;"></span>
|
||||
<span class="falz" style="top: 148.5mm;"></span>
|
||||
<span class="falz" style="top: 210mm;"></span>
|
||||
|
||||
<div class="kopf">
|
||||
<div class="wappen">
|
||||
<svg viewBox="0 0 26 30" aria-hidden="true">
|
||||
<path d="M1 1h24v13c0 8-6 13-12 15C7 27 1 22 1 14z" fill="#f4f4f4" stroke="#333" stroke-width="1.2"/>
|
||||
<path d="M1 10h24M13 1v28" stroke="#333" stroke-width="0.8"/>
|
||||
<circle cx="7" cy="5.5" r="2" fill="#2c5aa0"/><circle cx="19" cy="18" r="2.5" fill="#b3372b"/>
|
||||
</svg>
|
||||
<div class="behoerde">Kreisverwaltung Musterhausen<small>Fachbereich 32 – Ausländerbehörde</small></div>
|
||||
</div>
|
||||
<div style="font-size: 8pt; text-align: right; color: #333;">Musterplatz 1<br>67999 Musterhausen</div>
|
||||
</div>
|
||||
|
||||
<div class="absenderzeile">Kreisverwaltung Musterhausen · Musterplatz 1 · 67999 Musterhausen</div>
|
||||
|
||||
<div class="anschrift">
|
||||
Herrn<br>
|
||||
Ahmad Beispiel<br>
|
||||
Lindenweg 5<br>
|
||||
67999 Musterhausen
|
||||
</div>
|
||||
|
||||
<table class="info">
|
||||
<tr><td>Aktenzeichen</td><td>32.1-AE-2026-04711</td></tr>
|
||||
<tr><td>Auskunft erteilt</td><td>Frau Muster</td></tr>
|
||||
<tr><td>Zimmer</td><td>2.14</td></tr>
|
||||
<tr><td>Telefon</td><td>069 90009-314</td></tr>
|
||||
<tr><td>E-Mail</td><td>abh@musterhausen.example</td></tr>
|
||||
<tr><td>Datum</td><td>20.10.2026</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="text">
|
||||
<p class="betreff">Ihr Antrag auf Verlängerung der Aufenthaltserlaubnis vom 01.10.2026<br>
|
||||
hier: Aufforderung zur Vorlage fehlender Unterlagen gemäß § 82 Abs. 1 AufenthG<br>
|
||||
und Einladung zur persönlichen Vorsprache</p>
|
||||
|
||||
<p>Sehr geehrter Herr Beispiel,</p>
|
||||
|
||||
<p>in Ihrer o. g. aufenthaltsrechtlichen Angelegenheit nehmen wir Bezug auf Ihren Antrag auf
|
||||
Verlängerung der Aufenthaltserlaubnis. Ihr derzeitiger Aufenthaltstitel ist bis zum
|
||||
15.11.2026 befristet. Zur abschließenden Prüfung der Erteilungsvoraussetzungen gemäß
|
||||
§§ 5, 8 AufenthG sind die nachfolgend aufgeführten Unterlagen bislang nicht bzw. nicht
|
||||
vollständig vorgelegt worden:</p>
|
||||
|
||||
<ol>
|
||||
<li>gültiger Nationalpass im Original bzw. Nachweise über Ihre Bemühungen zur Passbeschaffung,</li>
|
||||
<li>aktuelle Meldebescheinigung (nicht älter als drei Monate),</li>
|
||||
<li>Nachweise über die Sicherung des Lebensunterhalts (Arbeitsvertrag sowie Lohn- und
|
||||
Gehaltsabrechnungen der letzten drei Monate bzw. aktueller Bewilligungsbescheid des Jobcenters),</li>
|
||||
<li>Nachweis über einen ausreichenden Krankenversicherungsschutz,</li>
|
||||
<li>ein aktuelles biometrisches Lichtbild.</li>
|
||||
</ol>
|
||||
|
||||
<p>Gemäß § 82 Abs. 1 AufenthG obliegt es Ihnen, die für Ihr Anliegen erforderlichen Nachweise
|
||||
unverzüglich beizubringen. Wir fordern Sie daher auf, die vorgenannten Unterlagen
|
||||
<span class="fett">bis spätestens 12.11.2026</span> bei der Ausländerbehörde einzureichen.</p>
|
||||
|
||||
<p>Zur Aufnahme Ihrer biometrischen Merkmale (Fingerabdrücke, Unterschrift) werden Sie
|
||||
gebeten, am <span class="fett">Donnerstag, den 05.11.2026, um 10:30 Uhr</span> in Zimmer 2.14
|
||||
persönlich vorzusprechen. Bitte bringen Sie dieses Schreiben sowie Ihren derzeitigen
|
||||
Aufenthaltstitel mit. Sollten Sie den Termin nicht wahrnehmen können, ist dieser rechtzeitig
|
||||
vorher telefonisch abzusagen.</p>
|
||||
|
||||
<p>Werden die angeforderten Unterlagen nicht fristgerecht vorgelegt, kann über Ihren Antrag
|
||||
nach Aktenlage entschieden werden. Dies kann zur Ablehnung des Antrags führen.</p>
|
||||
|
||||
<p>Hinweis: Da Sie den Antrag vor Ablauf Ihres Aufenthaltstitels gestellt haben, gilt dieser
|
||||
gemäß § 81 Abs. 4 AufenthG bis zur Entscheidung der Ausländerbehörde als fortbestehend
|
||||
(Fiktionswirkung). Eine entsprechende Fiktionsbescheinigung wird Ihnen bei Ihrer Vorsprache
|
||||
ausgehändigt.</p>
|
||||
|
||||
<p>Sprechzeiten: Mo, Di und Do 08:00–12:00 Uhr, Do zusätzlich 14:00–17:30 Uhr.
|
||||
Vorsprachen nur nach vorheriger Terminvereinbarung.</p>
|
||||
|
||||
<p class="gruss">Mit freundlichen Grüßen<br>Im Auftrag</p>
|
||||
<div class="unterschrift">Muster</div>
|
||||
<p style="margin-top: 0;">Muster</p>
|
||||
</div>
|
||||
|
||||
<div class="fuss">
|
||||
<div>Kreisverwaltung Musterhausen<br>Musterplatz 1 · 67999 Musterhausen</div>
|
||||
<div>Telefon 069 90009-0<br>www.musterhausen.example</div>
|
||||
<div>Bankverbindung: Sparkasse Musterhausen<br>IBAN DE00 0000 0000 0000 0000 00</div>
|
||||
</div>
|
||||
<div class="muster">Beispielbrief für Vorführzwecke · Behörde, Person und alle Angaben sind frei erfunden</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 420 KiB |
+266
-10
@@ -1,3 +1,5 @@
|
||||
import { DurableObject } from 'cloudflare:workers';
|
||||
|
||||
/**
|
||||
* BehördenKlar Backend-Proxy — Cloudflare Worker
|
||||
*
|
||||
@@ -36,6 +38,12 @@ interface Env {
|
||||
TOKEN_SIGNING_SECRET: string;
|
||||
/** Bezahlung: `wrangler secret put BREVO_API_KEY` (für die Guthaben-Link-Mail) */
|
||||
BREVO_API_KEY: string;
|
||||
/** Nur lokal (.dev.vars): "1" erlaubt Aufrufe von http://localhost */
|
||||
LOKAL_TEST?: string;
|
||||
/** Handy ↔ PC: verschlüsselte Übergabe (Durable Object, siehe unten) */
|
||||
UEBERGABE: DurableObjectNamespace<Uebergabe>;
|
||||
/** Handy ↔ PC: Schreib-Bremse pro IP (Rate-Limit-Binding in wrangler.toml) */
|
||||
UEBERGABE_LIMIT: RateLimit;
|
||||
}
|
||||
|
||||
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
|
||||
@@ -71,6 +79,12 @@ function fehler(status: number, typ: string, meldung: string): Response {
|
||||
|
||||
/** Nur die eigene Webseite darf den Demo-Endpunkt aufrufen (CORS). */
|
||||
const DEMO_ORIGIN_MUSTER = /^https:\/\/([a-z0-9-]+\.)?behoerdenklar\.(de|pages\.dev)$/;
|
||||
/** Nur für `wrangler dev` mit LOKAL_TEST=1 in .dev.vars — live nie gesetzt. */
|
||||
const LOKAL_MUSTER = /^http:\/\/localhost:\d+$/;
|
||||
let lokalTest = false;
|
||||
function originOk(origin: string | null): origin is string {
|
||||
return !!origin && (DEMO_ORIGIN_MUSTER.test(origin) || (lokalTest && LOKAL_MUSTER.test(origin)));
|
||||
}
|
||||
|
||||
/** ~5 MB Datei entsprechen ~6,7 Mio. Base64-Zeichen. */
|
||||
const DEMO_MAX_BASE64 = 7_000_000;
|
||||
@@ -82,6 +96,10 @@ const WEGWERF_DOMAINS = new Set([
|
||||
'getnada.com', 'dispostable.com', 'maildrop.cc', 'throwawaymail.com',
|
||||
]);
|
||||
|
||||
/** Was in jeder Sprache deutsch bzw. unverändert bleiben muss. */
|
||||
const SPRACH_AUSNAHMEN = (sprachName: string) =>
|
||||
`Ausnahme: das Feld "begriff" bei fachbegriffe bleibt der deutsche Originalbegriff aus dem Brief (der Nutzer muss ihn im Brief wiederfinden) — nur dessen "erklaerung" wird auf ${sprachName} geschrieben. Datumsangaben bleiben im ISO-Format. Ebenfalls NICHT übersetzen: das Feld "deutsch" bei anruf (das wird am Telefon auf Deutsch gesagt) und alle kontakt-Felder (sie stehen so im Brief).`;
|
||||
|
||||
/** Identisch zur App (src/services/analyse.ts) — gleiche Qualität in der Demo. */
|
||||
/** Zielsprachen der Web-Demo — identisch zur Sprachauswahl der Webseite.
|
||||
* Der Wert wird NUR als Name in den Prompt gesetzt, nie als freier Text,
|
||||
@@ -178,7 +196,7 @@ const DEMO_SCHEMA = {
|
||||
};
|
||||
|
||||
function demoCorsHeaders(origin: string | null): Record<string, string> {
|
||||
const erlaubt = origin && DEMO_ORIGIN_MUSTER.test(origin) ? origin : 'https://behoerdenklar.de';
|
||||
const erlaubt = originOk(origin) ? origin : 'https://behoerdenklar.de';
|
||||
return {
|
||||
'access-control-allow-origin': erlaubt,
|
||||
'access-control-allow-methods': 'POST, OPTIONS',
|
||||
@@ -199,6 +217,39 @@ async function emailHash(email: string, pepper: string): Promise<string> {
|
||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** Fertige deutsche Analyse in eine andere Sprache übersetzen (Haiku, gleiches Schema).
|
||||
* Gibt null zurück, wenn etwas schiefgeht — der Aufrufer zeigt dann Deutsch. */
|
||||
async function uebersetzeAnalyse(env: Env, analyse: unknown, sprachName: string): Promise<unknown | null> {
|
||||
try {
|
||||
const r = await fetch(ANTHROPIC_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': env.ANTHROPIC_API_KEY,
|
||||
'anthropic-version': ANTHROPIC_VERSION,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-haiku-4-5',
|
||||
max_tokens: MAX_TOKENS_OBERGRENZE,
|
||||
system: `Du übersetzt die Erklärung eines deutschen Behördenbriefs für Menschen mit wenig Deutschkenntnissen. Du bekommst ein JSON-Objekt. Gib dasselbe Objekt mit derselben Struktur zurück, aber schreibe alle Textfelder auf ${sprachName}, in einfacher, klarer Sprache. Erfinde nichts dazu und lass nichts weg; null bleibt null. ${SPRACH_AUSNAHMEN(sprachName)}`,
|
||||
output_config: { format: { type: 'json_schema', schema: DEMO_SCHEMA } },
|
||||
messages: [{ role: 'user', content: JSON.stringify(analyse) }],
|
||||
}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
console.log('Übersetzung fehlgeschlagen', r.status, (await r.text()).slice(0, 300));
|
||||
return null;
|
||||
}
|
||||
const d = (await r.json()) as { stop_reason?: string; content?: { type: string; text?: string }[] };
|
||||
const text = [...(d.content ?? [])].reverse().find((b) => b.type === 'text')?.text;
|
||||
if (d.stop_reason !== 'end_turn' || !text) return null;
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
console.log('Übersetzung fehlgeschlagen', String(e));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function demoHandler(request: Request, env: Env): Promise<Response> {
|
||||
const origin = request.headers.get('origin');
|
||||
if (request.method === 'OPTIONS') {
|
||||
@@ -207,11 +258,11 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method !== 'POST') {
|
||||
return demoFehler(405, 'methode', 'Nur POST erlaubt.', origin);
|
||||
}
|
||||
if (!origin || !DEMO_ORIGIN_MUSTER.test(origin)) {
|
||||
if (!originOk(origin)) {
|
||||
return demoFehler(403, 'origin', 'Aufruf nur von der BehördenKlar-Webseite erlaubt.', origin);
|
||||
}
|
||||
|
||||
let body: { bild?: string; mimeType?: string; turnstileToken?: string; email?: string; walletToken?: string; zielsprache?: string };
|
||||
let body: { bild?: string; mimeType?: string; turnstileToken?: string; email?: string; walletToken?: string; zielsprache?: string; auchDeutsch?: boolean };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
@@ -224,6 +275,10 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
|
||||
const sprachCode = typeof body.zielsprache === 'string' && DEMO_SPRACHEN[body.zielsprache]
|
||||
? body.zielsprache : 'de';
|
||||
const sprachName = DEMO_SPRACHEN[sprachCode];
|
||||
// Zweisprachig (Beratung am PC, Ratsuchende am Handy): erst auf Deutsch
|
||||
// analysieren, dann mit Haiku übersetzen — wie in der App. Kostet den
|
||||
// Nutzer weiterhin nur EINE Analyse.
|
||||
const zweisprachig = body.auchDeutsch === true && sprachCode !== 'de';
|
||||
|
||||
if (typeof bild !== 'string' || bild.length === 0 || bild.length > DEMO_MAX_BASE64) {
|
||||
return demoFehler(400, 'datei', 'Die Datei fehlt oder ist zu groß (max. 5 MB).', origin);
|
||||
@@ -254,14 +309,25 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
|
||||
// (die sind nur ein Kosten-Deckel für die kostenlose Stufe).
|
||||
let walletId: string | null = null;
|
||||
let walletRest = 0;
|
||||
let einrichtungsName: string | null = null;
|
||||
if (walletToken) {
|
||||
walletId = await verifyWallet(walletToken, env.TOKEN_SIGNING_SECRET);
|
||||
if (!walletId) {
|
||||
return demoFehler(403, 'wallet_ungueltig', 'Ihr Zugangs-Token ist ungültig. Bitte laden Sie die Seite neu.', origin);
|
||||
}
|
||||
walletRest = parseInt((await env.RATE_LIMIT.get(`wallet:${walletId}`)) ?? '0', 10);
|
||||
const [restWert, einrichtung] = await Promise.all([
|
||||
env.RATE_LIMIT.get(`wallet:${walletId}`),
|
||||
ladeEinrichtung(env, walletId),
|
||||
]);
|
||||
walletRest = parseInt(restWert ?? '0', 10);
|
||||
einrichtungsName = einrichtung?.name ?? null;
|
||||
if (einrichtung && einrichtung.ablauf < heute) {
|
||||
return demoFehler(402, 'kontingent_abgelaufen', `Der Zugang über ${einrichtung.name} ist abgelaufen. Bitte fragen Sie dort nach.`, origin);
|
||||
}
|
||||
if (walletRest <= 0) {
|
||||
return demoFehler(402, 'kein_guthaben', 'Ihr Guthaben ist aufgebraucht. Bitte kaufen Sie neues Guthaben.', origin);
|
||||
return einrichtung
|
||||
? demoFehler(402, 'kontingent_leer', `Die Analysen von ${einrichtung.name} sind aufgebraucht. Bitte fragen Sie dort nach.`, origin)
|
||||
: demoFehler(402, 'kein_guthaben', 'Ihr Guthaben ist aufgebraucht. Bitte kaufen Sie neues Guthaben.', origin);
|
||||
}
|
||||
}
|
||||
const bezahlt = walletId !== null;
|
||||
@@ -336,9 +402,9 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
|
||||
body: JSON.stringify({
|
||||
model: 'claude-sonnet-5',
|
||||
max_tokens: MAX_TOKENS_OBERGRENZE,
|
||||
system: sprachCode === 'de'
|
||||
system: sprachCode === 'de' || zweisprachig
|
||||
? DEMO_SYSTEM_PROMPT
|
||||
: `${DEMO_SYSTEM_PROMPT}\n\nWICHTIG — ZIELSPRACHE: Schreibe ALLE Textfelder deiner Antwort auf ${sprachName}, nicht auf Deutsch. Ausnahme: das Feld "begriff" bei fachbegriffe bleibt der deutsche Originalbegriff aus dem Brief (der Nutzer muss ihn im Brief wiederfinden) — nur dessen "erklaerung" wird auf ${sprachName} geschrieben. Datumsangaben bleiben im ISO-Format. Ebenfalls NICHT übersetzen: das Feld "deutsch" bei anruf (das wird am Telefon auf Deutsch gesagt) und alle kontakt-Felder (sie stehen so im Brief).`,
|
||||
: `${DEMO_SYSTEM_PROMPT}\n\nWICHTIG — ZIELSPRACHE: Schreibe ALLE Textfelder deiner Antwort auf ${sprachName}, nicht auf Deutsch. ${SPRACH_AUSNAHMEN(sprachName)}`,
|
||||
output_config: { format: { type: 'json_schema', schema: DEMO_SCHEMA } },
|
||||
messages: [
|
||||
{
|
||||
@@ -408,12 +474,23 @@ async function demoHandler(request: Request, env: Env): Promise<Response> {
|
||||
return demoFehler(502, 'ki', 'Die Antwort war unlesbar. Bitte erneut versuchen.', origin);
|
||||
}
|
||||
|
||||
let analyseDe: unknown = undefined;
|
||||
let ausgabeSprache = sprachCode;
|
||||
if (zweisprachig) {
|
||||
analyseDe = analyse;
|
||||
const uebersetzt = await uebersetzeAnalyse(env, analyse, sprachName);
|
||||
if (uebersetzt) analyse = uebersetzt;
|
||||
else ausgabeSprache = 'de'; // lieber Deutsch als gar nichts
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
analyse,
|
||||
analyse_de: analyseDe,
|
||||
versuch: bezahlt ? 'bezahlt' : email ? 2 : 1,
|
||||
guthaben: bezahlt ? walletRest - 1 : undefined,
|
||||
sprache: sprachCode,
|
||||
einrichtung: einrichtungsName ?? undefined,
|
||||
sprache: ausgabeSprache,
|
||||
},
|
||||
{ headers: demoCorsHeaders(origin) }
|
||||
);
|
||||
@@ -518,7 +595,7 @@ async function kaufenHandler(request: Request, env: Env): Promise<Response> {
|
||||
const origin = request.headers.get('origin');
|
||||
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: demoCorsHeaders(origin) });
|
||||
if (request.method !== 'POST') return demoFehler(405, 'methode', 'Nur POST erlaubt.', origin);
|
||||
if (!origin || !DEMO_ORIGIN_MUSTER.test(origin)) return demoFehler(403, 'origin', 'Aufruf nur von der BehördenKlar-Webseite erlaubt.', origin);
|
||||
if (!originOk(origin)) return demoFehler(403, 'origin', 'Aufruf nur von der BehördenKlar-Webseite erlaubt.', origin);
|
||||
|
||||
let body: { priceId?: string; sprache?: string };
|
||||
try {
|
||||
@@ -571,7 +648,7 @@ async function kaufenHandler(request: Request, env: Env): Promise<Response> {
|
||||
async function kaufStatusHandler(request: Request, env: Env): Promise<Response> {
|
||||
const origin = request.headers.get('origin');
|
||||
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: demoCorsHeaders(origin) });
|
||||
if (!origin || !DEMO_ORIGIN_MUSTER.test(origin)) return demoFehler(403, 'origin', 'Nicht erlaubt.', origin);
|
||||
if (!originOk(origin)) return demoFehler(403, 'origin', 'Nicht erlaubt.', origin);
|
||||
|
||||
const sessionId = new URL(request.url).searchParams.get('session_id') ?? '';
|
||||
if (!/^cs_[A-Za-z0-9_]+$/.test(sessionId)) return demoFehler(400, 'session', 'Ungültige Sitzung.', origin);
|
||||
@@ -593,6 +670,182 @@ async function kaufStatusHandler(request: Request, env: Env): Promise<Response>
|
||||
return Response.json({ token, guthaben: rest }, { headers: demoCorsHeaders(origin) });
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Einrichtungs-Codes — Beratungsstellen zahlen, Ratsuchende nicht
|
||||
// ============================================================
|
||||
//
|
||||
// Ein Code (z. B. `MBE-K7QX`) zeigt auf ein Guthaben wie beim Kauf, nur dass
|
||||
// es allen gehört, die den Code kennen. Angelegt wird er von Hand mit
|
||||
// tools/einrichtungs-code.js (kein Secret nötig, nur wrangler-Login).
|
||||
//
|
||||
// KV: code:<CODE> -> walletId
|
||||
// wallet:<walletId> -> Restguthaben (wie beim Kauf)
|
||||
// einrichtung:<walletId> -> {"name":"MBE Donnersberg","ablauf":"2027-01-31"}
|
||||
//
|
||||
// Der zufällige Teil im Code macht Raten aussichtslos; zusätzlich sind
|
||||
// Fehlversuche pro IP und Tag begrenzt.
|
||||
|
||||
const CODE_FEHLVERSUCHE_PRO_TAG = 10;
|
||||
|
||||
interface Einrichtung {
|
||||
name: string;
|
||||
/** letzter gültiger Tag, ISO-Datum */
|
||||
ablauf: string;
|
||||
}
|
||||
|
||||
async function ladeEinrichtung(env: Env, walletId: string): Promise<Einrichtung | null> {
|
||||
const roh = await env.RATE_LIMIT.get(`einrichtung:${walletId}`);
|
||||
if (!roh) return null;
|
||||
try {
|
||||
return JSON.parse(roh) as Einrichtung;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /code — Einrichtungs-Code einlösen, signiertes Wallet-Token liefern. */
|
||||
async function codeHandler(request: Request, env: Env): Promise<Response> {
|
||||
const origin = request.headers.get('origin');
|
||||
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: demoCorsHeaders(origin) });
|
||||
if (request.method !== 'POST') return demoFehler(405, 'methode', 'Nur POST erlaubt.', origin);
|
||||
if (!originOk(origin)) return demoFehler(403, 'origin', 'Nicht erlaubt.', origin);
|
||||
|
||||
let body: { code?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return demoFehler(400, 'json', 'Ungültige Anfrage.', origin);
|
||||
}
|
||||
const code = typeof body.code === 'string' ? body.code.trim().toUpperCase().replace(/\s+/g, '') : '';
|
||||
|
||||
const ip = request.headers.get('cf-connecting-ip') ?? 'unbekannt';
|
||||
const heute = new Date().toISOString().slice(0, 10);
|
||||
const failKey = `codefail:${ip}:${heute}`;
|
||||
const fehlversuche = parseInt((await env.RATE_LIMIT.get(failKey)) ?? '0', 10);
|
||||
if (fehlversuche >= CODE_FEHLVERSUCHE_PRO_TAG) {
|
||||
return demoFehler(429, 'code_gesperrt', 'Zu viele falsche Codes. Bitte versuchen Sie es morgen wieder.', origin);
|
||||
}
|
||||
|
||||
const walletId = /^[A-Z0-9-]{4,40}$/.test(code) ? await env.RATE_LIMIT.get(`code:${code}`) : null;
|
||||
const einrichtung = walletId ? await ladeEinrichtung(env, walletId) : null;
|
||||
if (!walletId || !einrichtung) {
|
||||
await env.RATE_LIMIT.put(failKey, String(fehlversuche + 1), { expirationTtl: 60 * 60 * 48 });
|
||||
return demoFehler(404, 'code_unbekannt', 'Diesen Code kennen wir nicht. Bitte prüfen Sie die Schreibweise.', origin);
|
||||
}
|
||||
if (einrichtung.ablauf < heute) {
|
||||
return demoFehler(410, 'kontingent_abgelaufen', `Der Zugang über ${einrichtung.name} ist abgelaufen. Bitte fragen Sie dort nach.`, origin);
|
||||
}
|
||||
|
||||
const rest = parseInt((await env.RATE_LIMIT.get(`wallet:${walletId}`)) ?? '0', 10);
|
||||
const token = await signWallet(walletId, env.TOKEN_SIGNING_SECRET);
|
||||
return Response.json(
|
||||
{ token, guthaben: rest, einrichtung: einrichtung.name, ablauf: einrichtung.ablauf },
|
||||
{ headers: demoCorsHeaders(origin) }
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Übergabe Handy ↔ PC — Ende-zu-Ende verschlüsselt
|
||||
// ============================================================
|
||||
//
|
||||
// Ein Gerät zeigt einen QR-Code, das andere scannt ihn. Im QR-Code stehen
|
||||
// eine zufällige Übergabe-ID und ein AES-Schlüssel — der Schlüssel steht
|
||||
// hinter dem `#` der Adresse und wird deshalb NIE an einen Server geschickt.
|
||||
// Hier liegen nur verschlüsselte Päckchen, die wir selbst nicht lesen
|
||||
// können. Jede Übergabe löscht sich nach 15 Minuten von selbst.
|
||||
//
|
||||
// PUT /uebergabe/<id>/<fach> -> Päckchen ablegen (Text, verschlüsselt)
|
||||
// GET /uebergabe/<id> -> alle Päckchen dieser Übergabe
|
||||
// DELETE /uebergabe/<id> -> sofort löschen
|
||||
//
|
||||
// Durable Object statt KV, weil KV Abfragen auf noch nicht vorhandene
|
||||
// Schlüssel zwischenspeichert — das Warten am PC würde bis zu einer Minute
|
||||
// hängen. Ein Durable Object ist sofort konsistent.
|
||||
//
|
||||
// Ein Fach (z. B. „status", „ergebnis") wird überschrieben, nicht angehängt.
|
||||
|
||||
const UEBERGABE_LEBENSDAUER_MS = 15 * 60 * 1000;
|
||||
const UEBERGABE_MAX_ZEICHEN = 400_000; // Analyse-JSON in zwei Sprachen, verschlüsselt
|
||||
const UEBERGABE_MAX_SCHREIBVORGAENGE = 50;
|
||||
const UEBERGABE_ID_MUSTER = /^[A-Za-z0-9_-]{22}$/; // 128 Bit, base64url
|
||||
const UEBERGABE_FACH_MUSTER = /^[a-z]{2,16}$/;
|
||||
|
||||
export class Uebergabe extends DurableObject<Env> {
|
||||
async lesen(): Promise<Record<string, string>> {
|
||||
const eintraege = await this.ctx.storage.list<string>({ prefix: 'fach:' });
|
||||
const faecher: Record<string, string> = {};
|
||||
for (const [k, v] of eintraege) faecher[k.slice(5)] = v;
|
||||
return faecher;
|
||||
}
|
||||
|
||||
async schreiben(fach: string, wert: string): Promise<boolean> {
|
||||
const zahl = (await this.ctx.storage.get<number>('zahl')) ?? 0;
|
||||
if (zahl >= UEBERGABE_MAX_SCHREIBVORGAENGE) return false;
|
||||
await this.ctx.storage.put({ [`fach:${fach}`]: wert, zahl: zahl + 1 });
|
||||
if ((await this.ctx.storage.getAlarm()) === null) {
|
||||
await this.ctx.storage.setAlarm(Date.now() + UEBERGABE_LEBENSDAUER_MS);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async loeschen(): Promise<void> {
|
||||
await this.ctx.storage.deleteAlarm();
|
||||
await this.ctx.storage.deleteAll();
|
||||
}
|
||||
|
||||
async alarm(): Promise<void> {
|
||||
await this.ctx.storage.deleteAll();
|
||||
}
|
||||
}
|
||||
|
||||
function uebergabeCors(origin: string | null): Record<string, string> {
|
||||
return {
|
||||
...demoCorsHeaders(origin),
|
||||
'access-control-allow-methods': 'GET, PUT, DELETE, OPTIONS',
|
||||
'cache-control': 'no-store',
|
||||
};
|
||||
}
|
||||
|
||||
async function uebergabeHandler(request: Request, env: Env, pfad: string): Promise<Response> {
|
||||
const origin = request.headers.get('origin');
|
||||
const cors = uebergabeCors(origin);
|
||||
if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors });
|
||||
// Webseite: nur unsere eigene. Die App schickt keinen Origin-Header.
|
||||
if (origin && !originOk(origin)) {
|
||||
return Response.json({ code: 'origin', meldung: 'Nicht erlaubt.' }, { status: 403, headers: cors });
|
||||
}
|
||||
|
||||
const [, , id, fach] = pfad.split('/'); // "", "uebergabe", id, fach?
|
||||
if (!id || !UEBERGABE_ID_MUSTER.test(id)) {
|
||||
return Response.json({ code: 'id', meldung: 'Ungültige Übergabe.' }, { status: 400, headers: cors });
|
||||
}
|
||||
const stub = env.UEBERGABE.get(env.UEBERGABE.idFromName(id));
|
||||
|
||||
if (request.method === 'GET' && !fach) {
|
||||
return Response.json({ faecher: await stub.lesen() }, { headers: cors });
|
||||
}
|
||||
if (request.method === 'DELETE' && !fach) {
|
||||
await stub.loeschen();
|
||||
return new Response(null, { status: 204, headers: cors });
|
||||
}
|
||||
if (request.method === 'PUT' && fach && UEBERGABE_FACH_MUSTER.test(fach)) {
|
||||
const ip = request.headers.get('cf-connecting-ip') ?? 'unbekannt';
|
||||
const { success } = await env.UEBERGABE_LIMIT.limit({ key: ip });
|
||||
if (!success) {
|
||||
return Response.json({ code: 'zu_viele', meldung: 'Zu viele Anfragen. Bitte kurz warten.' }, { status: 429, headers: cors });
|
||||
}
|
||||
const wert = await request.text();
|
||||
if (!wert || wert.length > UEBERGABE_MAX_ZEICHEN || !/^[A-Za-z0-9_-]+$/.test(wert)) {
|
||||
return Response.json({ code: 'inhalt', meldung: 'Ungültiger Inhalt.' }, { status: 400, headers: cors });
|
||||
}
|
||||
if (!(await stub.schreiben(fach, wert))) {
|
||||
return Response.json({ code: 'voll', meldung: 'Diese Übergabe ist abgeschlossen.' }, { status: 409, headers: cors });
|
||||
}
|
||||
return new Response(null, { status: 204, headers: cors });
|
||||
}
|
||||
return Response.json({ code: 'methode', meldung: 'Nicht erlaubt.' }, { status: 405, headers: cors });
|
||||
}
|
||||
|
||||
/** POST /stripe-webhook — Sicherungsnetz: Signatur prüfen, Guthaben aktivieren, Link mailen. */
|
||||
async function webhookHandler(request: Request, env: Env): Promise<Response> {
|
||||
if (request.method !== 'POST') return new Response('nur POST', { status: 405 });
|
||||
@@ -641,10 +894,13 @@ async function webhookHandler(request: Request, env: Env): Promise<Response> {
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const pfad = new URL(request.url).pathname;
|
||||
lokalTest = env.LOKAL_TEST === '1';
|
||||
// Web-Demo + Bezahl-Routen haben eigene Regeln
|
||||
if (pfad === '/demo') return demoHandler(request, env);
|
||||
if (pfad === '/kaufen') return kaufenHandler(request, env);
|
||||
if (pfad === '/kauf-status') return kaufStatusHandler(request, env);
|
||||
if (pfad === '/code') return codeHandler(request, env);
|
||||
if (pfad.startsWith('/uebergabe/')) return uebergabeHandler(request, env, pfad);
|
||||
if (pfad === '/stripe-webhook') return webhookHandler(request, env);
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
|
||||
@@ -18,3 +18,21 @@ DEMO_LIMIT_IP = "8"
|
||||
[[kv_namespaces]]
|
||||
binding = "RATE_LIMIT"
|
||||
id = "7577adef8ceb4ed6bfaeef81a1d496af"
|
||||
|
||||
# Handy <-> PC: verschluesselte Uebergabe per QR-Code (siehe src/index.ts).
|
||||
# SQLite-Durable-Objects laufen auch im kostenlosen Workers-Tarif.
|
||||
[[durable_objects.bindings]]
|
||||
name = "UEBERGABE"
|
||||
class_name = "Uebergabe"
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1-uebergabe"
|
||||
new_sqlite_classes = ["Uebergabe"]
|
||||
|
||||
# Schreib-Bremse fuer die Uebergabe: 60 Paeckchen pro Minute und IP
|
||||
[[ratelimits]]
|
||||
name = "UEBERGABE_LIMIT"
|
||||
namespace_id = "1001"
|
||||
[ratelimits.simple]
|
||||
limit = 60
|
||||
period = 60
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Einrichtungs-Codes für Beratungsstellen anlegen und verwalten.
|
||||
*
|
||||
* node tools/einrichtungs-code.js neu "MBE Donnersberg" MBE --briefe 100 --monate 1
|
||||
* node tools/einrichtungs-code.js stand MBE-K7QX
|
||||
* node tools/einrichtungs-code.js aufladen MBE-K7QX 100
|
||||
* node tools/einrichtungs-code.js verlaengern MBE-K7QX 2027-06-30
|
||||
*
|
||||
* Ein Code ist ein Guthaben wie beim Kauf, das allen gehört, die den Code
|
||||
* kennen. Die Ratsuchenden zahlen nichts, die Einrichtung zahlt (oder testet
|
||||
* gratis). Gilt bis zum Ablaufdatum oder bis die Briefe aufgebraucht sind.
|
||||
*
|
||||
* Braucht nur den wrangler-Login (npx wrangler login), kein Secret.
|
||||
* Link für Handzettel/QR-Code: https://behoerdenklar.de/demo?code=<CODE>
|
||||
*/
|
||||
const { execFileSync } = require('child_process');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
|
||||
const PROXY = path.join(__dirname, '..', 'proxy');
|
||||
const KV_ID = '7577adef8ceb4ed6bfaeef81a1d496af'; // RATE_LIMIT aus proxy/wrangler.toml
|
||||
// ohne 0/O, 1/I/L: am Telefon und auf Papier nicht zu verwechseln
|
||||
const ZEICHEN = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
|
||||
function kv(...args) {
|
||||
return execFileSync('npx', ['wrangler', 'kv', 'key', ...args, '--namespace-id', KV_ID, '--remote'],
|
||||
{ cwd: PROXY, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
||||
}
|
||||
function lies(schluessel) {
|
||||
try { return kv('get', schluessel, '--text'); } catch { return null; }
|
||||
}
|
||||
function schreib(schluessel, wert) { kv('put', schluessel, wert); }
|
||||
|
||||
function option(name, standard) {
|
||||
const i = process.argv.indexOf('--' + name);
|
||||
return i > 0 ? process.argv[i + 1] : standard;
|
||||
}
|
||||
function abbruch(text) { console.error('\x1b[31m' + text + '\x1b[0m'); process.exit(1); }
|
||||
|
||||
function zufall(laenge) {
|
||||
let s = '';
|
||||
for (const b of crypto.randomBytes(laenge)) s += ZEICHEN[b % ZEICHEN.length];
|
||||
return s;
|
||||
}
|
||||
|
||||
function plusMonate(monate) {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() + monate);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function ladeCode(code) {
|
||||
code = String(code || '').toUpperCase();
|
||||
const walletId = lies(`code:${code}`);
|
||||
if (!walletId) abbruch(`Code ${code} gibt es nicht.`);
|
||||
const einrichtung = JSON.parse(lies(`einrichtung:${walletId}`) || '{}');
|
||||
const rest = parseInt(lies(`wallet:${walletId}`) || '0', 10);
|
||||
return { code, walletId, einrichtung, rest };
|
||||
}
|
||||
|
||||
function zeige({ code, einrichtung, rest }) {
|
||||
const heute = new Date().toISOString().slice(0, 10);
|
||||
console.log(`
|
||||
Einrichtung: ${einrichtung.name}
|
||||
Code: \x1b[1m${code}\x1b[0m
|
||||
Übrig: ${rest} Briefe
|
||||
Gültig bis: ${einrichtung.ablauf}${einrichtung.ablauf < heute ? ' \x1b[31m(abgelaufen)\x1b[0m' : ''}
|
||||
Link: https://behoerdenklar.de/demo?code=${code}
|
||||
`);
|
||||
}
|
||||
|
||||
const [befehl, a, b] = process.argv.slice(2);
|
||||
|
||||
if (befehl === 'neu') {
|
||||
const name = a;
|
||||
const praefix = String(b || '').toUpperCase();
|
||||
if (!name || !/^[A-Z]{2,12}$/.test(praefix)) {
|
||||
abbruch('So geht es: node tools/einrichtungs-code.js neu "MBE Donnersberg" MBE --briefe 100 --monate 1');
|
||||
}
|
||||
const briefe = parseInt(option('briefe', '100'), 10);
|
||||
const ablauf = option('bis', plusMonate(parseInt(option('monate', '1'), 10)));
|
||||
const code = `${praefix}-${zufall(4)}`;
|
||||
if (lies(`code:${code}`)) abbruch('Zufällig schon vergeben — einfach nochmal ausführen.');
|
||||
const walletId = crypto.randomUUID();
|
||||
schreib(`wallet:${walletId}`, String(briefe));
|
||||
schreib(`einrichtung:${walletId}`, JSON.stringify({ name, ablauf }));
|
||||
schreib(`code:${code}`, walletId);
|
||||
console.log('\n \x1b[32m✓ Code angelegt\x1b[0m');
|
||||
zeige({ code, einrichtung: { name, ablauf }, rest: briefe });
|
||||
} else if (befehl === 'stand') {
|
||||
zeige(ladeCode(a));
|
||||
} else if (befehl === 'aufladen') {
|
||||
const c = ladeCode(a);
|
||||
const dazu = parseInt(b, 10);
|
||||
if (!(dazu > 0)) abbruch('So geht es: node tools/einrichtungs-code.js aufladen MBE-K7QX 100');
|
||||
schreib(`wallet:${c.walletId}`, String(c.rest + dazu));
|
||||
console.log(`\n \x1b[32m✓ ${dazu} Briefe dazu\x1b[0m`);
|
||||
zeige({ ...c, rest: c.rest + dazu });
|
||||
} else if (befehl === 'verlaengern') {
|
||||
const c = ladeCode(a);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(b || '')) abbruch('So geht es: node tools/einrichtungs-code.js verlaengern MBE-K7QX 2027-06-30');
|
||||
const einrichtung = { ...c.einrichtung, ablauf: b };
|
||||
schreib(`einrichtung:${c.walletId}`, JSON.stringify(einrichtung));
|
||||
console.log('\n \x1b[32m✓ verlängert\x1b[0m');
|
||||
zeige({ ...c, einrichtung });
|
||||
} else {
|
||||
console.log(require('fs').readFileSync(__filename, 'utf8').split('*/')[0].replace(/^\/\*\*?|^ \* ?/gm, ''));
|
||||
}
|
||||
@@ -29,7 +29,7 @@
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Datenschutzerklärung</h1>
|
||||
<p class="stand">für die App und Webseite „BehördenKlar" — Stand: 18.08.2026</p>
|
||||
<p class="stand">für die App und Webseite „BehördenKlar" — Stand: 22.10.2026</p>
|
||||
|
||||
<div class="karte">
|
||||
<h2>1. Verantwortlicher</h2>
|
||||
@@ -215,6 +215,47 @@
|
||||
für die gesetzlichen Fristen auf (Art. 6 Abs. 1 lit. c DSGVO).
|
||||
</p>
|
||||
|
||||
<h2>9a. Zugang über eine Beratungsstelle (Einrichtungs-Code)</h2>
|
||||
<p>
|
||||
Beratungsstellen können ihren Ratsuchenden einen Code geben, mit dem
|
||||
BehördenKlar für sie kostenlos ist. Der Code verweist auf ein
|
||||
Guthaben der Einrichtung, das wie in Abschnitt 9 beschrieben unter
|
||||
einer zufälligen Kennung verwaltet wird. Wir erfahren dabei nicht, wer
|
||||
den Code nutzt, und die Beratungsstelle sieht keine Briefe oder
|
||||
Ergebnisse, nur wie viele Analysen noch übrig sind. Gegen das Erraten
|
||||
von Codes zählen wir falsche Eingaben pro IP-Adresse (Speicherung
|
||||
max. 48 Stunden, Art. 6 Abs. 1 lit. f DSGVO).
|
||||
</p>
|
||||
|
||||
<h2>9b. Übergabe zwischen Handy und Computer</h2>
|
||||
<p>
|
||||
Sie können ein Ergebnis per QR-Code von einem Gerät auf ein anderes
|
||||
bringen, zum Beispiel vom Computer der Beratungsstelle auf Ihr Handy,
|
||||
oder mit dem Handy fotografieren und das Ergebnis am Computer anzeigen.
|
||||
Dabei gilt:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Ende-zu-Ende-Verschlüsselung.</strong> Das Ergebnis wird auf
|
||||
dem einen Gerät mit AES-256 verschlüsselt und erst auf dem anderen
|
||||
Gerät wieder entschlüsselt. Den Schlüssel enthält nur der QR-Code
|
||||
(im Teil der Adresse nach dem „#", den Browser nicht an Server
|
||||
senden). <strong>Wir können den Inhalt nicht lesen.</strong>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kurze Zwischenspeicherung.</strong> Das verschlüsselte
|
||||
Ergebnis liegt bis zur Abholung auf unserem Server (Cloudflare),
|
||||
höchstens 15 Minuten, und wird beim Abholen bzw. danach automatisch
|
||||
gelöscht. Zum Schutz vor Missbrauch begrenzen wir die Zahl der
|
||||
Übertragungen pro IP-Adresse (Art. 6 Abs. 1 lit. f DSGVO).
|
||||
</li>
|
||||
<li>
|
||||
Wer den QR-Code fotografiert oder abfotografiert, kann das Ergebnis
|
||||
innerhalb dieser 15 Minuten öffnen. Zeigen Sie den Code deshalb nur
|
||||
der Person, für die er bestimmt ist.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>10. Reichweitenmessung (Webseite)</h2>
|
||||
<p>
|
||||
Auf dieser Webseite nutzen wir Cloudflare Web Analytics (Cloudflare,
|
||||
|
||||
+430
-15
@@ -208,6 +208,25 @@
|
||||
background: linear-gradient(104deg, transparent 1%, var(--marker) 3%, var(--marker) 97%, transparent 99%);
|
||||
font-size: 16px;
|
||||
}
|
||||
/* Handy <-> PC */
|
||||
.cta-zwei { background: #fff; color: var(--amtsblau); font-size: 16.5px; padding: 13px 20px; margin-top: 12px; }
|
||||
.verbunden {
|
||||
display: none; margin-bottom: 16px; padding: 12px 16px;
|
||||
background: #eaf1fa; border: 2px solid var(--amtsblau); font-size: 16px;
|
||||
}
|
||||
.qr-box { display: none; margin-top: 16px; padding: 18px; background: #fff; border: 2px dashed var(--amtsblau); text-align: center; }
|
||||
.qr-box .qr { width: 220px; height: 220px; margin: 10px auto; }
|
||||
.qr-box .qr svg { width: 100%; height: 100%; display: block; }
|
||||
.qr-box ol { text-align: start; margin: 8px auto 0; max-width: 420px; padding-inline-start: 22px; font-size: 15.5px; }
|
||||
.qr-box .zustand { margin-top: 12px; font-weight: 700; font-size: 16.5px; }
|
||||
.qr-box .abbrechen { margin-top: 10px; background: none; border: none; color: var(--grau); text-decoration: underline; font-family: inherit; font-size: 14.5px; cursor: pointer; }
|
||||
.umschalter { display: none; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.umschalter button {
|
||||
font-family: inherit; font-size: 15px; padding: 6px 14px; cursor: pointer;
|
||||
border: 1.5px solid var(--tinte); background: #fff; color: var(--tinte);
|
||||
}
|
||||
.umschalter button[aria-pressed="true"] { background: var(--tinte); color: var(--papier); }
|
||||
.link-knopf { background: none; border: none; color: var(--amtsblau); font-family: inherit; font-size: inherit; text-decoration: underline; cursor: pointer; padding: 0; }
|
||||
footer { margin-top: 48px; font-size: 14.5px; color: var(--grau); }
|
||||
footer a { color: var(--grau); }
|
||||
</style>
|
||||
@@ -264,13 +283,21 @@
|
||||
<div class="antrag">
|
||||
<p class="formular-kopf">Antrag auf Klartext · 2 Gratis-Analysen</p>
|
||||
|
||||
<p class="verbunden" id="verbundenBanner"></p>
|
||||
|
||||
<div id="eingabe">
|
||||
<p id="kaufOk" style="display: none; background: #e7f4ea; border: 1px solid #b7dfc1; color: #1a7f37; padding: 12px 14px; border-radius: 8px; margin-bottom: 14px; font-weight: 600;">
|
||||
✅ Kauf erfolgreich! Ihr Guthaben wurde aktiviert.
|
||||
</p>
|
||||
<p id="guthabenBanner" class="bonus-zeile" style="display: none;">
|
||||
💳 Ihr Guthaben: <strong id="guthabenZahl">0</strong> Analysen — laden Sie den nächsten Brief hoch.
|
||||
<span id="guthabenText">💳 Ihr Guthaben:</span> <strong id="guthabenZahl">0</strong> Analysen — laden Sie den nächsten Brief hoch.
|
||||
<br><button type="button" class="link-knopf" id="guthabenHandyKnopf" style="font-size: 14.5px;">📱 Auch auf dem Handy nutzen</button>
|
||||
</p>
|
||||
<div class="qr-box" id="guthabenQrBox">
|
||||
<strong>Guthaben aufs Handy holen</strong>
|
||||
<div class="qr" id="guthabenQr"></div>
|
||||
<p class="klein" style="margin-top: 0;">Mit der Handy-Kamera scannen. Danach können Sie Briefe auch direkt am Handy erklären lassen.</p>
|
||||
</div>
|
||||
<p style="margin-bottom: 14px; font-size: 15.5px;">
|
||||
🌐 <label for="sprachWahl">Erklärung in dieser Sprache:</label>
|
||||
<select id="sprachWahl" style="font-family: inherit; font-size: 15.5px; padding: 5px 8px; border: 1.5px solid var(--tinte); background: #fff; margin-inline-start: 6px;"></select>
|
||||
@@ -303,8 +330,31 @@
|
||||
|
||||
<input type="file" id="datei" accept="image/jpeg,image/png,application/pdf" style="display: none;">
|
||||
<button class="cta" id="startKnopf" disabled>📷 Brief auswählen & erklären lassen</button>
|
||||
<button class="cta cta-zwei" id="handyKnopf" type="button" style="display: none;">📱 Lieber mit dem Handy fotografieren</button>
|
||||
<div class="qr-box" id="handyBox">
|
||||
<strong>Mit dem Handy fotografieren</strong>
|
||||
<div class="qr" id="handyQr"></div>
|
||||
<ol>
|
||||
<li>Handy-Kamera auf den Code richten und den Link öffnen.</li>
|
||||
<li>Am Handy den Brief fotografieren.</li>
|
||||
<li>Die Erklärung erscheint hier <em>und</em> auf dem Handy.</li>
|
||||
</ol>
|
||||
<label class="konsens" id="handyDeutschZeile" style="justify-content: center; margin: 14px 0 0;">
|
||||
<input type="checkbox" id="handyDeutsch" checked>
|
||||
<span>Hier am Bildschirm auf Deutsch zeigen, am Handy in der gewählten Sprache</span>
|
||||
</label>
|
||||
<p class="zustand" id="handyZustand">⏳ Warte auf das Handy …</p>
|
||||
<p class="klein" style="margin-top: 6px;">Verschlüsselt: Den Schlüssel kennt nur dieser QR-Code. Der Code gilt 15 Minuten.</p>
|
||||
<button type="button" class="abbrechen" id="handyAbbrechen">Abbrechen</button>
|
||||
</div>
|
||||
|
||||
<div class="cf-turnstile" data-sitekey="0x4AAAAAAESi2p41B915R4hW" data-action="turnstile-spin-v1" style="margin-top: 14px;"></div>
|
||||
<script>
|
||||
// Lokaler Test (wrangler dev): offizieller Turnstile-Testschlüssel, der immer durchlässt
|
||||
if (/^(localhost|127\.0\.0\.1)$/.test(location.hostname)) {
|
||||
document.querySelector('.cf-turnstile').setAttribute('data-sitekey', '1x00000000000000000000AA');
|
||||
}
|
||||
</script>
|
||||
|
||||
<p class="klein">
|
||||
Geht mit Foto (JPG/PNG) oder PDF, max. 5 MB. Tipp: gutes Licht,
|
||||
@@ -318,6 +368,13 @@
|
||||
Ihrem Handy, nicht von uns:</strong> Diese Seite greift weder auf
|
||||
Kamera noch auf Mikrofon zu.
|
||||
</p>
|
||||
|
||||
<details id="codeTeil" style="margin-top: 14px; border-top: 1px solid rgba(33, 33, 29, 0.2);">
|
||||
<summary>🤝 Sie haben einen Code von einer Beratungsstelle?</summary>
|
||||
<p>Dann ist BehördenKlar für Sie kostenlos. Geben Sie den Code hier ein:</p>
|
||||
<input class="wl-input" id="codeFeld" placeholder="z. B. MBE-K7QX" autocapitalize="characters" autocomplete="off" spellcheck="false" style="margin-top: 10px; text-transform: uppercase;">
|
||||
<button class="cta" id="codeKnopf" type="button">Code einlösen</button>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="status" id="status">
|
||||
@@ -377,6 +434,7 @@
|
||||
|
||||
<section id="ergebnis">
|
||||
<h2><span class="nr">§ 3</span> Ihr Brief in Klartext</h2>
|
||||
<div class="umschalter" id="sprachUmschalter" role="group" aria-label="Sprache der Erklärung"></div>
|
||||
|
||||
<div class="brief-karte">
|
||||
<span class="karten-label neutral" id="ergBrieftyp">Brief</span>
|
||||
@@ -415,6 +473,15 @@
|
||||
<div id="ergBegriffe"></div>
|
||||
</div>
|
||||
|
||||
<div id="mitnehmenTeil" style="display: none; margin-top: 22px;">
|
||||
<button class="cta cta-zwei" id="mitnehmenKnopf" type="button">📱 Ergebnis aufs Handy mitnehmen</button>
|
||||
<div class="qr-box" id="mitnehmenBox">
|
||||
<strong>Ergebnis aufs Handy</strong>
|
||||
<div class="qr" id="mitnehmenQr"></div>
|
||||
<p class="klein" style="margin-top: 0;">Mit der Handy-Kamera scannen. Dort gibt es den Kalender-Eintrag und die Anruf-Hilfe. Verschlüsselt, der Code gilt 15 Minuten.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hinweis-recht">
|
||||
⚖️ BehördenKlar erklärt Briefe, ersetzt aber keine Rechtsberatung. Bei
|
||||
wichtigen Entscheidungen hilft eine Beratungsstelle oder ein Anwalt.
|
||||
@@ -465,10 +532,14 @@
|
||||
</div>
|
||||
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
<script src="qrcode.js"></script>
|
||||
<script src="uebergabe.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var BASIS = 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev';
|
||||
var BASIS = /^(localhost|127\.0\.0\.1)$/.test(location.hostname)
|
||||
? 'http://localhost:8799' // lokaler Test mit `wrangler dev`
|
||||
: 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev';
|
||||
var ENDPUNKT = BASIS + '/demo';
|
||||
var KAUFEN_ENDPUNKT = BASIS + '/kaufen';
|
||||
var STATUS_ENDPUNKT = BASIS + '/kauf-status';
|
||||
@@ -508,6 +579,10 @@
|
||||
var werbung = document.getElementById('werbung');
|
||||
var guthabenBanner = document.getElementById('guthabenBanner');
|
||||
var guthabenZahl = document.getElementById('guthabenZahl');
|
||||
var guthabenText = document.getElementById('guthabenText');
|
||||
var codeTeil = document.getElementById('codeTeil');
|
||||
var codeFeld = document.getElementById('codeFeld');
|
||||
var codeKnopf = document.getElementById('codeKnopf');
|
||||
var kaufOk = document.getElementById('kaufOk');
|
||||
var paketListe = document.getElementById('paketListe');
|
||||
var kaufKonsens = document.getElementById('kaufKonsens');
|
||||
@@ -521,6 +596,32 @@
|
||||
function guthaben() { return parseInt(localStorage.getItem(GUTHABEN_SCHLUESSEL) || '0', 10); }
|
||||
function setzeGuthaben(n) { localStorage.setItem(GUTHABEN_SCHLUESSEL, String(Math.max(0, n | 0))); }
|
||||
function hatGuthaben() { return !!walletToken() && guthaben() > 0; }
|
||||
// Einrichtungs-Code: Name der Beratungsstelle, über die das Guthaben läuft
|
||||
var EINRICHTUNG_SCHLUESSEL = 'bk_einrichtung';
|
||||
function einrichtung() { return localStorage.getItem(EINRICHTUNG_SCHLUESSEL) || ''; }
|
||||
function setzeEinrichtung(name) {
|
||||
if (name) localStorage.setItem(EINRICHTUNG_SCHLUESSEL, name);
|
||||
else localStorage.removeItem(EINRICHTUNG_SCHLUESSEL);
|
||||
}
|
||||
// Handy ↔ PC: Ist dieses Gerät gerade das Handy, das für einen PC
|
||||
// fotografiert? Ein Guthaben aus dem QR-Code (w) gilt nur für diese
|
||||
// Sitzung und wird hier NICHT gespeichert.
|
||||
var handyModus = null;
|
||||
var leseModus = false; // Ergebnis vom PC übernommen, nur anzeigen
|
||||
var istPc = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
||||
function walletFuerAnalyse() {
|
||||
if (handyModus && handyModus.w) return handyModus.w;
|
||||
return hatGuthaben() ? walletToken() : '';
|
||||
}
|
||||
function meldeAnPc(daten, fach) {
|
||||
if (!handyModus) return Promise.resolve();
|
||||
return BKUebergabe.senden(handyModus.ue, fach || 'status', daten).catch(function () {});
|
||||
}
|
||||
function zeigeGuthabenBanner(n) {
|
||||
guthabenText.textContent = einrichtung() ? '🤝 Kostenlos über ' + einrichtung() + ':' : '💳 Ihr Guthaben:';
|
||||
guthabenZahl.textContent = n;
|
||||
guthabenBanner.style.display = 'block';
|
||||
}
|
||||
|
||||
// Schlüsselname bewusst 'v2': setzt evtl. falsch gezählte Stände (aus der
|
||||
// Testphase) bei bestehenden Besuchern automatisch einmalig zurück.
|
||||
@@ -529,17 +630,27 @@
|
||||
function setzeBenutzt(n) { localStorage.setItem(SPEICHER_SCHLUESSEL, String(n)); }
|
||||
|
||||
function zeigeStand() {
|
||||
// Bezahlter Modus: Eingabe offen, Guthaben-Stand zeigen, Gratis-Sperre egal
|
||||
if (hatGuthaben()) {
|
||||
if (handyModus && handyModus.w) {
|
||||
eingabe.style.display = 'block';
|
||||
guthabenBanner.style.display = 'block';
|
||||
guthabenZahl.textContent = guthaben();
|
||||
guthabenBanner.style.display = 'none';
|
||||
emailTeil.style.display = 'none';
|
||||
fehlerBox.style.display = 'none';
|
||||
kaufBlock.style.display = 'none';
|
||||
codeTeil.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
// Bezahlter Modus: Eingabe offen, Guthaben-Stand zeigen, Gratis-Sperre egal
|
||||
if (hatGuthaben()) {
|
||||
eingabe.style.display = 'block';
|
||||
zeigeGuthabenBanner(guthaben());
|
||||
emailTeil.style.display = 'none';
|
||||
fehlerBox.style.display = 'none';
|
||||
kaufBlock.style.display = 'none';
|
||||
codeTeil.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
guthabenBanner.style.display = 'none';
|
||||
codeTeil.style.display = 'block';
|
||||
// Kauf ist IMMER möglich — auch sofort, ohne die Gratis-Analysen zu nutzen.
|
||||
kaufBlock.style.display = 'block';
|
||||
// 2. Versuch: E-Mail-Feld einblenden; alles verbraucht: Gratis-Weg sperren
|
||||
@@ -556,7 +667,7 @@
|
||||
konsens.addEventListener('change', function () { startKnopf.disabled = !konsens.checked; });
|
||||
startKnopf.addEventListener('click', function () {
|
||||
fehlerBox.style.display = 'none';
|
||||
if (!hatGuthaben() && benutzt() >= 1 && !emailFeld.value.trim()) {
|
||||
if (!walletFuerAnalyse() && benutzt() >= 1 && !emailFeld.value.trim()) {
|
||||
emailTeil.style.display = 'block';
|
||||
emailFeld.focus();
|
||||
zeigeFehler('Bitte geben Sie zuerst Ihre E-Mail-Adresse ein — dann wählen Sie den Brief aus.');
|
||||
@@ -625,7 +736,8 @@
|
||||
try {
|
||||
var istPdf = datei.type === 'application/pdf';
|
||||
var base64 = istPdf ? await pdfAlsBase64(datei) : await bildVerkleinern(datei);
|
||||
var bezahlt = hatGuthaben();
|
||||
var wallet = walletFuerAnalyse();
|
||||
var bezahlt = !!wallet;
|
||||
var body = {
|
||||
bild: base64,
|
||||
mimeType: istPdf ? 'application/pdf' : 'image/jpeg',
|
||||
@@ -633,8 +745,10 @@
|
||||
};
|
||||
var email = emailFeld.value.trim();
|
||||
body.zielsprache = zielsprache();
|
||||
if (bezahlt) body.walletToken = walletToken();
|
||||
if (bezahlt) body.walletToken = wallet;
|
||||
else if (benutzt() >= 1 && email) body.email = email;
|
||||
if (handyModus && handyModus.d) body.auchDeutsch = true;
|
||||
meldeAnPc({ schritt: 'analyse' });
|
||||
|
||||
var antwort = await fetch(ENDPUNKT, {
|
||||
method: 'POST',
|
||||
@@ -647,6 +761,12 @@
|
||||
status.style.display = 'none';
|
||||
eingabe.style.display = 'block';
|
||||
if (window.turnstile) window.turnstile.reset();
|
||||
meldeAnPc({ schritt: 'fehler', meldung: daten.meldung || '' });
|
||||
// Guthaben vom PC: Fehler nur anzeigen, am Handy nichts umstellen
|
||||
if (handyModus && handyModus.w) {
|
||||
zeigeFehler(daten.meldung || 'Etwas ist schiefgelaufen. Bitte erneut versuchen.');
|
||||
return;
|
||||
}
|
||||
if (daten.code === 'email_noetig') {
|
||||
// Nur das E-Mail-Feld zeigen — NICHT als verbraucht markieren.
|
||||
// (Sonst würde ein serverseitiges "E-Mail nötig", z. B. bei
|
||||
@@ -660,6 +780,14 @@
|
||||
zeigeStand();
|
||||
return;
|
||||
}
|
||||
if (daten.code === 'kontingent_leer' || daten.code === 'kontingent_abgelaufen') {
|
||||
setzeWallet('');
|
||||
setzeGuthaben(0);
|
||||
setzeEinrichtung(null);
|
||||
zeigeStand();
|
||||
zeigeFehler(daten.meldung);
|
||||
return;
|
||||
}
|
||||
if (daten.code === 'kein_guthaben') {
|
||||
setzeGuthaben(0);
|
||||
guthabenBanner.style.display = 'none';
|
||||
@@ -674,15 +802,29 @@
|
||||
// Token erneuern — sonst würde die nächste Analyse ein "Seite neu laden" verlangen
|
||||
if (window.turnstile) window.turnstile.reset();
|
||||
|
||||
// Handy fotografiert für den PC: Ergebnis verschlüsselt hinüberschicken
|
||||
await meldeAnPc({
|
||||
analyse: daten.analyse, analyse_de: daten.analyse_de, sprache: daten.sprache,
|
||||
guthaben: daten.guthaben, einrichtung: daten.einrichtung,
|
||||
}, 'ergebnis');
|
||||
var analyse = bereiteVersionen(daten, false);
|
||||
|
||||
if (handyModus && handyModus.w) {
|
||||
zeigeAnalyse(analyse);
|
||||
eingabe.style.display = 'block';
|
||||
document.getElementById('ergebnis').scrollIntoView({ behavior: 'smooth' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (daten.versuch === 'bezahlt' || typeof daten.guthaben === 'number') {
|
||||
setzeGuthaben(daten.guthaben);
|
||||
zeigeErgebnis(daten.analyse, null, daten.guthaben);
|
||||
zeigeErgebnis(analyse, null, daten.guthaben);
|
||||
return;
|
||||
}
|
||||
|
||||
setzeBenutzt(Math.max(benutzt(), daten.versuch));
|
||||
if (email) document.getElementById('wlEmail').value = email;
|
||||
zeigeErgebnis(daten.analyse, daten.versuch);
|
||||
zeigeErgebnis(analyse, daten.versuch);
|
||||
} catch (e) {
|
||||
status.style.display = 'none';
|
||||
eingabe.style.display = 'block';
|
||||
@@ -708,7 +850,33 @@
|
||||
return { farbe: farbe, text: punkt + ' ' + text };
|
||||
}
|
||||
|
||||
function zeigeErgebnis(analyse, versuch, guthabenRest) {
|
||||
// Eine Analyse kann in zwei Sprachen vorliegen (PC Deutsch, Handy Muttersprache).
|
||||
var versionen = [];
|
||||
var aktiveVersion = 0;
|
||||
var letzteDaten = null; // { analyse, analyse_de, sprache } — für „Mitnehmen"
|
||||
function bereiteVersionen(d, deutschZuerst) {
|
||||
letzteDaten = { analyse: d.analyse, analyse_de: d.analyse_de, sprache: d.sprache || 'de' };
|
||||
versionen = [{ code: letzteDaten.sprache, analyse: d.analyse }];
|
||||
if (d.analyse_de) versionen[deutschZuerst ? 'unshift' : 'push']({ code: 'de', analyse: d.analyse_de });
|
||||
aktiveVersion = 0;
|
||||
return versionen[0].analyse;
|
||||
}
|
||||
function zeichneUmschalter() {
|
||||
var box = document.getElementById('sprachUmschalter');
|
||||
box.innerHTML = '';
|
||||
box.style.display = versionen.length > 1 ? 'flex' : 'none';
|
||||
versionen.forEach(function (v, i) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = SPRACH_NAMEN[v.code] || v.code;
|
||||
b.setAttribute('aria-pressed', i === aktiveVersion ? 'true' : 'false');
|
||||
b.addEventListener('click', function () { aktiveVersion = i; zeigeAnalyse(v.analyse); });
|
||||
box.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
/** Nur die Anzeige einer Analyse — ohne Guthaben/Gratis-Ablauf. */
|
||||
function zeigeAnalyse(analyse) {
|
||||
status.style.display = 'none';
|
||||
// Optionale Karten zuruecksetzen. Sonst bleibt beim zweiten Brief z. B.
|
||||
// die Checkliste des ERSTEN Briefs stehen — zwei Briefe vermischt.
|
||||
@@ -751,8 +919,15 @@
|
||||
|
||||
zeigeKalender(analyse);
|
||||
zeigeAnruf(analyse);
|
||||
zeichneUmschalter();
|
||||
document.getElementById('mitnehmenTeil').style.display = istPc && !handyModus && !leseModus ? 'block' : 'none';
|
||||
document.getElementById('mitnehmenBox').style.display = 'none';
|
||||
|
||||
document.getElementById('ergebnis').style.display = 'block';
|
||||
}
|
||||
|
||||
function zeigeErgebnis(analyse, versuch, guthabenRest) {
|
||||
zeigeAnalyse(analyse);
|
||||
|
||||
// Bezahlter Modus: Guthaben-Stand zeigen, Eingabe für den nächsten Brief öffnen
|
||||
if (typeof guthabenRest === 'number') {
|
||||
@@ -760,8 +935,7 @@
|
||||
kaufOk.style.display = 'none';
|
||||
if (guthabenRest > 0) {
|
||||
eingabe.style.display = 'block';
|
||||
guthabenBanner.style.display = 'block';
|
||||
guthabenZahl.textContent = guthabenRest;
|
||||
zeigeGuthabenBanner(guthabenRest);
|
||||
emailTeil.style.display = 'none';
|
||||
kaufBlock.style.display = 'none';
|
||||
} else {
|
||||
@@ -1039,6 +1213,7 @@
|
||||
if (res.ok && res.d && res.d.token) {
|
||||
setzeWallet(res.d.token);
|
||||
setzeGuthaben(res.d.guthaben);
|
||||
setzeEinrichtung(null);
|
||||
status.style.display = 'none';
|
||||
kaufOk.style.display = 'block';
|
||||
zeigeStand();
|
||||
@@ -1055,10 +1230,241 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Einrichtungs-Code einlösen ------------------------------------
|
||||
function loeseCodeEin(code) {
|
||||
code = String(code || '').trim().toUpperCase();
|
||||
if (!code) { codeFeld.focus(); return; }
|
||||
if (walletToken() && guthaben() > 0 && !einrichtung() &&
|
||||
!window.confirm('Sie haben noch gekauftes Guthaben. Wenn Sie den Code einlösen, nutzt diese Seite den Code. Ihr gekauftes Guthaben bleibt über den Link in Ihrer Kauf-E-Mail erhalten. Weiter?')) {
|
||||
return;
|
||||
}
|
||||
codeKnopf.disabled = true;
|
||||
fehlerBox.style.display = 'none';
|
||||
fetch(BASIS + '/code', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ code: code }),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
|
||||
.then(function (res) {
|
||||
codeKnopf.disabled = false;
|
||||
if (!res.ok || !res.d.token) {
|
||||
codeTeil.open = true;
|
||||
codeFeld.value = code;
|
||||
zeigeFehler(res.d.meldung || 'Der Code konnte nicht eingelöst werden.');
|
||||
return;
|
||||
}
|
||||
setzeWallet(res.d.token);
|
||||
setzeGuthaben(res.d.guthaben);
|
||||
setzeEinrichtung(res.d.einrichtung);
|
||||
zeigeStand();
|
||||
if (res.d.guthaben <= 0) {
|
||||
zeigeFehler('Die Analysen von ' + res.d.einrichtung + ' sind aufgebraucht. Bitte fragen Sie dort nach.');
|
||||
return;
|
||||
}
|
||||
kaufOk.textContent = '✅ Code eingelöst. BehördenKlar ist für Sie kostenlos, über ' + res.d.einrichtung + '.';
|
||||
kaufOk.style.display = 'block';
|
||||
})
|
||||
.catch(function () {
|
||||
codeKnopf.disabled = false;
|
||||
zeigeFehler('Verbindung fehlgeschlagen. Bitte prüfen Sie Ihr Internet und versuchen Sie es erneut.');
|
||||
});
|
||||
}
|
||||
codeKnopf.addEventListener('click', function () { loeseCodeEin(codeFeld.value); });
|
||||
codeFeld.addEventListener('keydown', function (e) { if (e.key === 'Enter') loeseCodeEin(codeFeld.value); });
|
||||
|
||||
// ---- Handy ↔ PC ------------------------------------------------------
|
||||
var verbundenBanner = document.getElementById('verbundenBanner');
|
||||
var handyKnopf = document.getElementById('handyKnopf');
|
||||
var handyBox = document.getElementById('handyBox');
|
||||
var handyQr = document.getElementById('handyQr');
|
||||
var handyZustand = document.getElementById('handyZustand');
|
||||
var handyDeutsch = document.getElementById('handyDeutsch');
|
||||
var handyDeutschZeile = document.getElementById('handyDeutschZeile');
|
||||
var UEBERGABE_MS = BKUebergabe.LEBENSDAUER_MIN * 60 * 1000;
|
||||
|
||||
function zeigeQr(el, adresse) {
|
||||
el.innerHTML = BKUebergabe.qrSvg(adresse);
|
||||
el.setAttribute('data-link', adresse); // zum Testen ohne Kamera
|
||||
}
|
||||
|
||||
// PC: QR-Code zeigen und warten, bis das Handy fotografiert hat
|
||||
var pcWarten = null; // { ue, bis, timer }
|
||||
var pruefeLaeuft = false;
|
||||
function zeichneHandyQr() {
|
||||
if (!pcWarten) return;
|
||||
var sprache = zielsprache();
|
||||
handyDeutschZeile.style.display = sprache === 'de' ? 'none' : 'flex';
|
||||
zeigeQr(handyQr, BKUebergabe.adresse({
|
||||
a: 'k', s: pcWarten.ue.id, k: pcWarten.ue.k, l: sprache,
|
||||
d: sprache !== 'de' && handyDeutsch.checked ? '1' : '',
|
||||
w: hatGuthaben() ? walletToken() : '',
|
||||
}));
|
||||
}
|
||||
function stoppeWarten() {
|
||||
if (pcWarten && pcWarten.timer) clearInterval(pcWarten.timer);
|
||||
pcWarten = null;
|
||||
}
|
||||
function schliesseHandyBox() {
|
||||
handyBox.style.display = 'none';
|
||||
handyKnopf.style.display = istPc ? 'block' : 'none';
|
||||
}
|
||||
handyKnopf.addEventListener('click', async function () {
|
||||
stoppeWarten();
|
||||
fehlerBox.style.display = 'none';
|
||||
try {
|
||||
pcWarten = { ue: await BKUebergabe.neu(), bis: Date.now() + UEBERGABE_MS };
|
||||
} catch (e) {
|
||||
zeigeFehler('Dieser Browser kann die verschlüsselte Übergabe nicht. Bitte einen aktuellen Browser verwenden.');
|
||||
return;
|
||||
}
|
||||
zeichneHandyQr();
|
||||
handyZustand.textContent = '⏳ Warte auf das Handy …';
|
||||
handyKnopf.style.display = 'none';
|
||||
handyBox.style.display = 'block';
|
||||
handyBox.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
pcWarten.timer = setInterval(pruefeHandy, 1500);
|
||||
});
|
||||
handyDeutsch.addEventListener('change', zeichneHandyQr);
|
||||
document.getElementById('handyAbbrechen').addEventListener('click', function () {
|
||||
if (pcWarten) BKUebergabe.loeschen(pcWarten.ue);
|
||||
stoppeWarten();
|
||||
schliesseHandyBox();
|
||||
});
|
||||
|
||||
async function pruefeHandy() {
|
||||
if (!pcWarten || pruefeLaeuft) return;
|
||||
if (Date.now() > pcWarten.bis) {
|
||||
stoppeWarten();
|
||||
schliesseHandyBox();
|
||||
zeigeFehler('Der QR-Code ist abgelaufen. Tippen Sie noch einmal auf „Lieber mit dem Handy fotografieren".');
|
||||
return;
|
||||
}
|
||||
pruefeLaeuft = true;
|
||||
try {
|
||||
var ue = pcWarten.ue;
|
||||
var f = await BKUebergabe.holen(ue);
|
||||
if (!pcWarten || pcWarten.ue !== ue) return; // inzwischen abgebrochen
|
||||
if (f.ergebnis) {
|
||||
stoppeWarten();
|
||||
BKUebergabe.loeschen(ue);
|
||||
schliesseHandyBox();
|
||||
zeigeVomHandy(f.ergebnis);
|
||||
} else if (f.status) {
|
||||
handyZustand.textContent =
|
||||
f.status.schritt === 'analyse' ? '⏳ Das Handy hat fotografiert. Der Brief wird erklärt …' :
|
||||
f.status.schritt === 'fehler' ? '⚠️ Am Handy hat es nicht geklappt: ' + (f.status.meldung || '') + ' Bitte dort noch einmal versuchen.' :
|
||||
'✅ Handy verbunden. Jetzt am Handy den Brief fotografieren.';
|
||||
}
|
||||
} catch (e) {
|
||||
// kurz keine Verbindung — beim nächsten Durchlauf erneut versuchen
|
||||
} finally {
|
||||
pruefeLaeuft = false;
|
||||
}
|
||||
}
|
||||
|
||||
function zeigeVomHandy(e) {
|
||||
// Hat das Handy das Guthaben dieses PCs benutzt, den neuen Stand übernehmen
|
||||
if (typeof e.guthaben === 'number' && hatGuthaben()) setzeGuthaben(e.guthaben);
|
||||
zeigeAnalyse(bereiteVersionen(e, true));
|
||||
werbung.style.display = 'none';
|
||||
zeigeStand();
|
||||
document.getElementById('ergebnis').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
// PC: Ergebnis per QR-Code aufs Handy mitgeben
|
||||
document.getElementById('mitnehmenKnopf').addEventListener('click', async function () {
|
||||
var knopf = this;
|
||||
if (!letzteDaten) return;
|
||||
knopf.disabled = true;
|
||||
try {
|
||||
var ue = await BKUebergabe.neu();
|
||||
await BKUebergabe.senden(ue, 'ergebnis', letzteDaten);
|
||||
zeigeQr(document.getElementById('mitnehmenQr'), BKUebergabe.adresse({ a: 'm', s: ue.id, k: ue.k }));
|
||||
document.getElementById('mitnehmenBox').style.display = 'block';
|
||||
} catch (e) {
|
||||
zeigeFehler('Das Ergebnis konnte nicht vorbereitet werden. Bitte prüfen Sie die Internetverbindung.');
|
||||
}
|
||||
knopf.disabled = false;
|
||||
});
|
||||
|
||||
// PC: Guthaben bzw. Zugang der Beratungsstelle aufs Handy holen
|
||||
document.getElementById('guthabenHandyKnopf').addEventListener('click', function () {
|
||||
var box = document.getElementById('guthabenQrBox');
|
||||
if (box.style.display === 'block') { box.style.display = 'none'; return; }
|
||||
zeigeQr(document.getElementById('guthabenQr'), BKUebergabe.adresse({
|
||||
a: 'g', w: walletToken(), n: guthaben(), e: einrichtung(),
|
||||
}));
|
||||
box.style.display = 'block';
|
||||
});
|
||||
|
||||
/** Handy: Seite wurde über einen QR-Code vom PC geöffnet. */
|
||||
async function pruefeUebergabe(h) {
|
||||
if (!h || !h.a) return;
|
||||
if (h.a === 'g') { uebernehmeGuthaben(h); return; }
|
||||
var ue = await BKUebergabe.oeffne(h.s, h.k).catch(function () { return null; });
|
||||
if (!ue) {
|
||||
zeigeFehler('Dieser QR-Code ist ungültig. Bitte am Computer einen neuen erzeugen.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (h.a === 'k') {
|
||||
handyModus = { ue: ue, w: h.w || '', d: h.d === '1' };
|
||||
if (h.l && SPRACH_NAMEN[h.l]) {
|
||||
try { localStorage.setItem(SPRACH_SCHLUESSEL, h.l); } catch (e) {}
|
||||
document.getElementById('sprachWahl').value = h.l;
|
||||
}
|
||||
verbundenBanner.textContent = '🔗 Mit dem Computer verbunden. Fotografieren Sie jetzt Ihren Brief. Die Erklärung erscheint hier und auf dem Bildschirm.';
|
||||
verbundenBanner.style.display = 'block';
|
||||
handyKnopf.style.display = 'none';
|
||||
zeigeStand();
|
||||
meldeAnPc({ schritt: 'verbunden' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (h.a === 'm') {
|
||||
leseModus = true;
|
||||
eingabe.style.display = 'none';
|
||||
kaufBlock.style.display = 'none';
|
||||
status.querySelector('.gross').textContent = 'Das Ergebnis wird geladen …';
|
||||
status.style.display = 'block';
|
||||
var f = null;
|
||||
try { f = await BKUebergabe.holen(ue); } catch (e) {}
|
||||
status.style.display = 'none';
|
||||
if (!f || !f.ergebnis) {
|
||||
leseModus = false;
|
||||
zeigeStand();
|
||||
zeigeFehler('Dieser QR-Code ist abgelaufen oder wurde schon benutzt (er gilt ' + BKUebergabe.LEBENSDAUER_MIN + ' Minuten und nur einmal). Bitte am Computer einen neuen erzeugen.');
|
||||
return;
|
||||
}
|
||||
BKUebergabe.loeschen(ue);
|
||||
verbundenBanner.textContent = '📱 Vom Computer übernommen. Tragen Sie die Frist gleich in Ihren Kalender ein, dann vergessen Sie nichts.';
|
||||
verbundenBanner.style.display = 'block';
|
||||
zeigeAnalyse(bereiteVersionen(f.ergebnis, false));
|
||||
document.getElementById('ergebnis').scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
function uebernehmeGuthaben(h) {
|
||||
if (!h.w) return;
|
||||
if (walletToken() && walletToken() !== h.w && guthaben() > 0 &&
|
||||
!window.confirm('Auf diesem Handy ist schon Guthaben gespeichert. Durch das vom Computer ersetzen? Ihr bisheriges Guthaben bleibt über den Link in Ihrer Kauf-E-Mail erhalten.')) {
|
||||
return;
|
||||
}
|
||||
setzeWallet(h.w);
|
||||
setzeGuthaben(parseInt(h.n, 10) || 1);
|
||||
setzeEinrichtung(h.e || null);
|
||||
kaufOk.textContent = h.e ? '✅ Übernommen. BehördenKlar ist für Sie kostenlos, über ' + h.e + '.' : '✅ Guthaben übernommen.';
|
||||
kaufOk.style.display = 'block';
|
||||
zeigeStand();
|
||||
}
|
||||
|
||||
function pruefeRueckkehr() {
|
||||
var p = new URLSearchParams(window.location.search);
|
||||
var w = p.get('wallet');
|
||||
if (w) { setzeWallet(w); setzeGuthaben(Math.max(guthaben(), 1)); reinigeUrl(); return; }
|
||||
if (w) { setzeWallet(w); setzeGuthaben(Math.max(guthaben(), 1)); setzeEinrichtung(null); reinigeUrl(); return; }
|
||||
var c = p.get('code');
|
||||
if (c) { reinigeUrl(); loeseCodeEin(c); return; }
|
||||
var kauf = p.get('kauf');
|
||||
if (kauf === 'abbruch') {
|
||||
reinigeUrl();
|
||||
@@ -1093,12 +1499,21 @@
|
||||
});
|
||||
feld.addEventListener('change', function () {
|
||||
try { localStorage.setItem(SPRACH_SCHLUESSEL, feld.value); } catch (e) {}
|
||||
zeichneHandyQr();
|
||||
});
|
||||
})();
|
||||
|
||||
rendrePakete();
|
||||
var startHash = BKUebergabe.ausAdresse();
|
||||
if (startHash && window.history.replaceState) {
|
||||
// Schlüssel nicht im Browser-Verlauf stehen lassen
|
||||
window.history.replaceState(null, document.title, window.location.pathname + window.location.search);
|
||||
}
|
||||
pruefeRueckkehr();
|
||||
zeigeStand();
|
||||
handyKnopf.style.display = istPc ? 'block' : 'none';
|
||||
document.getElementById('guthabenHandyKnopf').style.display = istPc ? '' : 'none';
|
||||
pruefeUebergabe(startHash);
|
||||
})();
|
||||
</script>
|
||||
<!-- Cloudflare Web Analytics: cookielose Reichweitenmessung, keine Nutzer-Profile -->
|
||||
|
||||
+2297
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Übergabe Handy ↔ PC — Ende-zu-Ende verschlüsselt.
|
||||
*
|
||||
* Ein Gerät erzeugt eine Übergabe (zufällige ID + AES-256-Schlüssel) und
|
||||
* zeigt sie als QR-Code. Beides steht hinter dem `#` der Adresse; dieser
|
||||
* Teil wird von Browsern NIE an einen Server geschickt. Der Worker sieht nur
|
||||
* verschlüsselte Päckchen und löscht sie nach 15 Minuten.
|
||||
*
|
||||
* Päckchen-Format: base64url( IV[12 Byte] || AES-GCM-Chiffrat ) eines JSON.
|
||||
* Die App (src/utils/uebergabe.ts) benutzt exakt dasselbe Format.
|
||||
*/
|
||||
(function (w) {
|
||||
'use strict';
|
||||
|
||||
var LOKAL = /^(localhost|127\.0\.0\.1)$/.test(w.location.hostname);
|
||||
var BASIS = (LOKAL ? 'http://localhost:8799' : 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev') + '/uebergabe/';
|
||||
|
||||
function b64u(bytes) {
|
||||
var s = '';
|
||||
for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
function ausB64u(text) {
|
||||
var s = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
var b = new Uint8Array(s.length);
|
||||
for (var i = 0; i < s.length; i++) b[i] = s.charCodeAt(i);
|
||||
return b;
|
||||
}
|
||||
function zufall(n) { return w.crypto.getRandomValues(new Uint8Array(n)); }
|
||||
function schluessel(roh) {
|
||||
return w.crypto.subtle.importKey('raw', roh, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
/** Neue Übergabe: { id, k, key } — k ist der Schlüssel als Text für den QR-Code. */
|
||||
async function neu() {
|
||||
var roh = zufall(32);
|
||||
return { id: b64u(zufall(16)), k: b64u(roh), key: await schluessel(roh) };
|
||||
}
|
||||
|
||||
/** Übergabe aus ID + Schlüsseltext (aus dem QR-Code) öffnen. */
|
||||
async function oeffne(id, k) {
|
||||
if (!/^[A-Za-z0-9_-]{22}$/.test(id || '') || !/^[A-Za-z0-9_-]{43}$/.test(k || '')) return null;
|
||||
return { id: id, k: k, key: await schluessel(ausB64u(k)) };
|
||||
}
|
||||
|
||||
async function verschluesseln(ue, daten) {
|
||||
var iv = zufall(12);
|
||||
var klar = new TextEncoder().encode(JSON.stringify(daten));
|
||||
var ct = new Uint8Array(await w.crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, ue.key, klar));
|
||||
var alles = new Uint8Array(iv.length + ct.length);
|
||||
alles.set(iv, 0); alles.set(ct, iv.length);
|
||||
return b64u(alles);
|
||||
}
|
||||
|
||||
/** Entschlüsseln; null, wenn das Päckchen nicht von dieser Übergabe stammt. */
|
||||
async function entschluesseln(ue, text) {
|
||||
try {
|
||||
var b = ausB64u(text);
|
||||
var klar = await w.crypto.subtle.decrypt({ name: 'AES-GCM', iv: b.slice(0, 12) }, ue.key, b.slice(12));
|
||||
return JSON.parse(new TextDecoder().decode(klar));
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
/** Päckchen in ein Fach legen (z. B. „status", „ergebnis"). */
|
||||
async function senden(ue, fach, daten) {
|
||||
var r = await fetch(BASIS + ue.id + '/' + fach, { method: 'PUT', body: await verschluesseln(ue, daten) });
|
||||
if (!r.ok) throw new Error('Übergabe fehlgeschlagen (' + r.status + ')');
|
||||
}
|
||||
|
||||
/** Alle Fächer holen und entschlüsseln: { status: {...}, ergebnis: {...} }. */
|
||||
async function holen(ue) {
|
||||
var r = await fetch(BASIS + ue.id, { cache: 'no-store' });
|
||||
if (!r.ok) throw new Error('Übergabe nicht erreichbar (' + r.status + ')');
|
||||
var faecher = (await r.json()).faecher || {};
|
||||
var aus = {};
|
||||
for (var f in faecher) {
|
||||
var d = await entschluesseln(ue, faecher[f]);
|
||||
if (d) aus[f] = d;
|
||||
}
|
||||
return aus;
|
||||
}
|
||||
|
||||
function loeschen(ue) {
|
||||
return fetch(BASIS + ue.id, { method: 'DELETE' }).catch(function () {});
|
||||
}
|
||||
|
||||
/** Adresse für den QR-Code. `werte` landet komplett hinter dem `#`. */
|
||||
function adresse(werte) {
|
||||
var teile = [];
|
||||
for (var k in werte) if (werte[k] !== undefined && werte[k] !== null && werte[k] !== '') {
|
||||
teile.push(k + '=' + encodeURIComponent(werte[k]));
|
||||
}
|
||||
return w.location.origin + w.location.pathname + '#' + teile.join('&');
|
||||
}
|
||||
|
||||
/** Werte hinter dem `#` lesen (oder null, wenn keine da sind). */
|
||||
function ausAdresse() {
|
||||
var h = w.location.hash.replace(/^#/, '');
|
||||
if (!h) return null;
|
||||
var werte = {};
|
||||
h.split('&').forEach(function (t) {
|
||||
var i = t.indexOf('=');
|
||||
if (i > 0) werte[t.slice(0, i)] = decodeURIComponent(t.slice(i + 1));
|
||||
});
|
||||
return werte;
|
||||
}
|
||||
|
||||
/** QR-Code als SVG-Text (braucht qrcode.js). */
|
||||
function qrSvg(text) {
|
||||
var qr = w.qrcode(0, 'M');
|
||||
qr.addData(text);
|
||||
qr.make();
|
||||
return qr.createSvgTag({ cellSize: 4, margin: 2, scalable: true });
|
||||
}
|
||||
|
||||
w.BKUebergabe = {
|
||||
neu: neu, oeffne: oeffne, senden: senden, holen: holen, loeschen: loeschen,
|
||||
adresse: adresse, ausAdresse: ausAdresse, qrSvg: qrSvg,
|
||||
LEBENSDAUER_MIN: 15,
|
||||
};
|
||||
})(window);
|
||||
Reference in new issue
Block a user