diff --git a/README.md b/README.md
index bcec546..db5d882 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,7 @@ die APK in die Package Registry (`netdiag-apk`). Siehe `.forgejo/workflows/build
src/lib/
api.ts JSON-API-Client (JWT, 401-Refresh, Timeout)
auth.svelte.ts Anmelde-Status
+ sso.ts Anmeldung per AWL-Konto (Chrome-Tab, PKCE, Rücksprung)
db.ts Offline-Speicher (SQLite nativ / localStorage Browser)
sync.svelte.ts Sync-Queue -> Dolibarr (idempotent über clientUuid)
scanner.ts Brücke zum nativen Plugin (+ Browser-Mock)
@@ -69,6 +70,13 @@ src/routes/
Werkzeuge mit Live-Anzeige — eigene Seite statt
Dialog, weil sie über Minuten/Stunden laufen
android/ natives Android-Projekt inkl. Kotlin-Plugin (einzige Quelle)
+ NetDiagScannerPlugin.kt alle Plugin-Methoden (Scan, Monitor, WLAN, SIP, Update,
+ Biometrie) — @PluginMethod-Namen = JS-Aufrufe
+ Dns.kt / Snmp.kt / Sip.kt eigene Protokoll-Clients (UDP/TCP), je ein
+ Ergebnistyp statt null — ein nicht erreichbarer Dienst
+ darf nie wie ein fehlerfreier aussehen
+ Biometrie.kt BiometricPrompt + Keystore (siehe „Anmeldung")
+ MonitorService.kt Foreground-Service für Dauertest und Geräte-Monitor
native-plugin/ nur noch Anleitung/Hinweise zum Plugin
tools/ Prüfwerkzeuge ohne Gerät (siehe unten)
```
@@ -137,12 +145,31 @@ die im Feld nicht gilt (KB #1086).
## Anmeldung
Die App hängt am zentralen Auth-Modul **awlauth** (7. AWL-App). Das Token kommt aus
-`auth.php` und geht bei jeder Anfrage als `Authorization: Bearer` mit — auch beim
+`auth.php` (Passwort) oder `sso.php` (AWL-Konto, siehe unten) und geht bei jeder Anfrage als
+`Authorization: Bearer` mit — auch beim
APK-Download und beim PDF-Abruf. **Nie in der Adresse:** ein Token in der URL steht in
jedem Zugriffs- und Proxy-Log und gilt sieben Tage für die gesamte Kunden-API.
(Einzige, befristete Ausnahme: `update.php?download=1` für App-Fassungen im Feld —
siehe ChangeLog des Moduls 1.3.0.)
+### Mit AWL-Konto anmelden (seit Modul 1.4.0 / awlauth 1.6.0)
+
+Knopf „Mit AWL-Konto anmelden" öffnet die awlauth-Anmeldeseite im **Chrome-Tab**
+(`@capacitor/browser`). Der Tab teilt sich die Anmeldung mit Chrome: wer im Handy-Browser
+schon angemeldet ist (PWA, ERP), ist ohne Eingabe zurück in der App. Sonst Passwort oder
+Fingerabdruck auf der awlauth-Seite, danach „Zurück zu NetDiag".
+
+- Rücksprung über das eigene Schema `de.data-it-solution.netdiag:/sso?code=…&state=…` —
+ Intent-Filter im `AndroidManifest.xml`, Konstante `SSO_REDIRECT` in `src/lib/sso.ts`.
+ **Beide müssen übereinstimmen**, und awlauth vergleicht die Adresse Zeichen für Zeichen.
+- PKCE (S256) + state: Geheimnis und state liegen bis zum Rücksprung in den Preferences
+ (Android darf die App beenden, während der Chrome-Tab vorne ist), gelten einmal und
+ höchstens zehn Minuten.
+- Der Rücksprung wird im Layout verarbeitet (`appUrlOpen` + `getLaunchUrl`), nicht auf der
+ Login-Seite.
+- Fragt `sso.php` (GET) `available:false` zurück (awlauth zu alt, App dort abgeschaltet),
+ zeigt die App den Grund; das Passwortfeld bleibt immer.
+
### Fingerabdruck statt Passwort
Einschaltbar unter Einstellungen → Konto. Was dabei passiert:
@@ -174,7 +201,7 @@ verfügbar (KB #855).
## Bedienung
-1. **Anmelden** (Dolibarr-Zugang; auf dem Gerät zusätzlich Server-URL).
+1. **Anmelden** — „Mit AWL-Konto anmelden" oder Dolibarr-Zugang (auf dem Gerät zusätzlich Server-URL).
2. **Aufträge** — aktive direkt sichtbar, abgeschlossene per Checkbox, Suche.
Alternativ über **Kunden** suchen.
3. Auftrag/Kunde antippen → Diagnose-Protokoll öffnet sich.
@@ -188,6 +215,26 @@ verfügbar (KB #855).
Selten gebrauchte Aktionen (Protokoll löschen) liegen hinter den drei Punkten
in der Kopfzeile — bewusst nicht neben dem grünen Abschließen-Knopf.
+## Was noch offen ist
+
+Die Arbeitsdateien `ROADMAP.md` / `ROADMAP_UMSETZUNG.md` sind abgearbeitet und entfernt
+(19.08.2026). Was aus ihnen **nicht** erledigt war, steht hier — damit es nicht
+mit den Dateien verschwindet:
+
+- **WLAN-Kanalanalyse auf echtem Empfang gegenprüfen.** Der Emulator hat keine
+ WLAN-Hardware und liefert grundsätzlich 0 Netze; alle Diagramme sind bisher nur mit
+ Demodaten geprüft. Auf dem Handy nachfahren: Kanal am Router wechseln, zwei
+ Momentaufnahmen vergleichen, Zeitverlauf ansehen.
+- **Ortung-aus-Zweig (Android < 13).** Auf einem API-33-AVD nicht erreichbar, deshalb
+ nur per Codereview geprüft — die Meldung „Ortung ausschalten verhindert den WLAN-Scan"
+ samt Sprung in die Systemeinstellung ist am echten Altgerät noch nie gelaufen.
+- **Vorschlags-Chips im ToolDialog.** Im Kopfbereich erledigt (`inputmode`), die Chips
+ für häufige Werte (Gateway, letzte Ziele) fehlen weiterhin.
+
+Dazu einer, der beim Modul liegt: die befristete `?jwt=`-Ausnahme in `update.php` fällt,
+sobald die APK-Fassung ≥ 20260819-2141 überall installiert ist (Hinweis steht dort im
+Code und im ChangeLog 1.3.0).
+
## Ohne Gerät prüfen
- **Browser** (`npm run dev`): Mock-Scanner liefert Beispieldaten.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index e99466a..b400f65 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -23,6 +23,19 @@
+
+
+
+
+
+
+
+
{
+ return request('sso.php');
+}
+
+/**
+ * Einmal-Code der awlauth-Seite einlösen — speichert das Token bei Erfolg.
+ * Antwortform wie bei `login()`.
+ */
+export async function ssoEinloesen(code: string, verifier: string): Promise {
+ const res = await request('sso.php', {
+ method: 'POST',
+ body: JSON.stringify({ code, code_verifier: verifier }),
+ });
+ await setToken(res.token);
+ return res;
+}
+
/**
* Mit einem bereits vorhandenen Token anmelden (Fingerabdruck-Weg).
*
diff --git a/src/lib/auth.svelte.ts b/src/lib/auth.svelte.ts
index 1408561..377862d 100644
--- a/src/lib/auth.svelte.ts
+++ b/src/lib/auth.svelte.ts
@@ -13,7 +13,9 @@ import {
loginMitToken,
setAuthFailureHandler,
tokenNurImSpeicher,
+ type LoginResult,
} from './api';
+import { ssoAbschliessen } from './sso';
import { scanner } from './scanner';
import type { NetUser } from './types';
@@ -112,7 +114,21 @@ class AuthState {
/** Anmelden */
async login(loginName: string, password: string): Promise {
- const res = await apiLogin(loginName, password);
+ await this.uebernehmen(await apiLogin(loginName, password));
+ }
+
+ /**
+ * Rücksprung von der awlauth-Anmeldeseite verarbeiten (siehe sso.ts).
+ * Liefert false, wenn gar keine Anmeldung offen war — dann ist nichts passiert.
+ */
+ async loginMitSso(url: string): Promise {
+ const res = await ssoAbschliessen(url);
+ if (!res) return false;
+ await this.uebernehmen(res);
+ return true;
+ }
+
+ private async uebernehmen(res: LoginResult): Promise {
this.user = res.user;
this.loggedIn = true;
await Preferences.set({ key: 'user', value: JSON.stringify(res.user) });
diff --git a/src/lib/sso.ts b/src/lib/sso.ts
new file mode 100644
index 0000000..7077816
--- /dev/null
+++ b/src/lib/sso.ts
@@ -0,0 +1,128 @@
+/**
+ * Anmeldung über die awlauth-Seite (SSO) statt Passwort in der App.
+ *
+ * Ablauf:
+ * 1. `ssoStarten()` fragt den Server (netdiag/api/sso.php), ob der Weg
+ * eingerichtet ist, erzeugt state + PKCE-Geheimnis und öffnet die
+ * awlauth-Seite im Chrome-Tab (`@capacitor/browser`).
+ * 2. Ist der Benutzer im Handy-Browser schon angemeldet (z. B. aus einer der
+ * PWAs), springt awlauth sofort zurück — sonst nach Passwort oder
+ * Fingerabdruck auf der awlauth-Seite. Rücksprung über das eigene Schema
+ * `de.data-it-solution.netdiag:/sso?code=…&state=…` (Intent-Filter im
+ * AndroidManifest).
+ * 3. `ssoAbschliessen(url)` prüft state, schließt den Tab und löst den Code
+ * mit dem PKCE-Geheimnis beim Server ein.
+ *
+ * Der Chrome-Tab und nicht der WebView: nur der Tab teilt sich den
+ * Cookie-Speicher mit Chrome — der WebView der App hat einen eigenen, dort
+ * käme eine bestehende Anmeldung nie an (KB #1451).
+ *
+ * state + Geheimnis liegen in den Preferences, nicht nur im Speicher: Android
+ * darf die App beenden, während der Benutzer im Chrome-Tab tippt. Der
+ * Rücksprung startet sie dann neu, und ohne die Ablage wäre die Anmeldung
+ * verloren. Der Eintrag gilt genau einmal und höchstens zehn Minuten.
+ */
+
+import { Browser } from '@capacitor/browser';
+import { Preferences } from '@capacitor/preferences';
+import { ApiError, getServerUrl, ssoEinloesen, ssoKonfig, type LoginResult } from './api';
+
+/** Rücksprungadresse — muss zum Intent-Filter im AndroidManifest passen */
+export const SSO_REDIRECT = 'de.data-it-solution.netdiag:/sso';
+
+const OFFEN_KEY = 'sso_offen';
+const MAX_ALTER_MS = 10 * 60_000;
+
+interface OffeneAnmeldung {
+ state: string;
+ verifier: string;
+ seit: number;
+}
+
+/** Base64url ohne Auffüllzeichen (RFC 7636 Anhang A) */
+function b64url(bytes: Uint8Array): string {
+ let s = '';
+ for (const b of bytes) s += String.fromCharCode(b);
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+function zufall(laenge: number): string {
+ const a = new Uint8Array(laenge);
+ crypto.getRandomValues(a);
+ return b64url(a);
+}
+
+/** PKCE-Prüfwert (S256) zum Geheimnis */
+export async function pkceChallenge(verifier: string): Promise {
+ const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
+ return b64url(new Uint8Array(hash));
+}
+
+/** Ist diese Adresse ein Rücksprung von der awlauth-Seite? */
+export function istSsoRuecksprung(url: string): boolean {
+ return url.startsWith(`${SSO_REDIRECT}?`);
+}
+
+/**
+ * awlauth-Seite im Chrome-Tab öffnen. Wirft mit Klartextgrund, wenn der
+ * Server den Weg nicht anbietet (awlauth zu alt, App dort abgeschaltet …).
+ */
+export async function ssoStarten(): Promise {
+ const k = await ssoKonfig();
+ if (!k.available) {
+ throw new ApiError(503, k.reason || 'Anmeldung per AWL-Konto ist auf dem Server nicht eingerichtet');
+ }
+ if (k.redirectUri !== SSO_REDIRECT) {
+ throw new ApiError(0, 'Server und App passen nicht zusammen (Rücksprungadresse) — App aktualisieren');
+ }
+
+ const verifier = zufall(32); // 43 Zeichen
+ const state = zufall(16);
+ const challenge = await pkceChallenge(verifier);
+ const offen: OffeneAnmeldung = { state, verifier, seit: Date.now() };
+ await Preferences.set({ key: OFFEN_KEY, value: JSON.stringify(offen) });
+
+ const q = new URLSearchParams({
+ client_id: k.clientId,
+ redirect_uri: SSO_REDIRECT,
+ state,
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ });
+ await Browser.open({ url: `${getServerUrl()}${k.authorizePath}?${q.toString()}` });
+}
+
+/**
+ * Rücksprung verarbeiten.
+ *
+ * Liefert `null`, wenn gar keine Anmeldung offen ist — etwa weil derselbe
+ * Rücksprung beim Kaltstart einmal über `getLaunchUrl()` und einmal über das
+ * Ereignis ankommt. Das ist kein Fehler und wird still übergangen.
+ */
+export async function ssoAbschliessen(url: string): Promise {
+ const roh = (await Preferences.get({ key: OFFEN_KEY })).value;
+ if (!roh) return null;
+ // Einmalig: sofort entfernen, auch wenn das Einlösen gleich scheitert
+ await Preferences.remove({ key: OFFEN_KEY });
+
+ // Tab schließen, sobald die App wieder vorne ist — er hat seine Arbeit getan
+ Browser.close().catch(() => {});
+
+ let offen: OffeneAnmeldung;
+ try {
+ offen = JSON.parse(roh) as OffeneAnmeldung;
+ } catch {
+ return null;
+ }
+ if (!offen.verifier || Date.now() - offen.seit > MAX_ALTER_MS) {
+ throw new ApiError(0, 'Die Anmeldung hat zu lange gedauert — bitte erneut versuchen');
+ }
+
+ const params = new URL(url).searchParams;
+ const code = params.get('code') ?? '';
+ // state schützt davor, eine fremde Anmeldung untergeschoben zu bekommen
+ if (!code || params.get('state') !== offen.state) {
+ throw new ApiError(0, 'Ungültige Rückmeldung der Anmeldeseite — bitte erneut versuchen');
+ }
+ return ssoEinloesen(code, offen.verifier);
+}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 40a4773..6df000d 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -12,6 +12,9 @@
import { closeTopOverlay } from '$lib/overlay.svelte';
import { checkForUpdate, installUpdate, type UpdateInfo } from '$lib/updater';
import { initDebugLog } from '$lib/debuglog.svelte';
+ import { istSsoRuecksprung } from '$lib/sso';
+ import { App } from '@capacitor/app';
+ import { Capacitor } from '@capacitor/core';
import Toast from '$lib/components/Toast.svelte';
let { children } = $props();
@@ -36,6 +39,24 @@
}
});
+ /**
+ * Rücksprung von der awlauth-Anmeldeseite (Anmeldung per AWL-Konto).
+ * Kommt je nach Lage als Ereignis (App lief im Hintergrund) oder als
+ * Start-Adresse (Android hatte die App beendet, während der Chrome-Tab vorne
+ * war). Ist keine Anmeldung offen, passiert nichts.
+ */
+ async function ssoRuecksprung(url: string) {
+ if (!istSsoRuecksprung(url)) return;
+ try {
+ if (!(await auth.loginMitSso(url))) return;
+ await sync.start();
+ toast.show('Angemeldet', 'success');
+ goto(HOME);
+ } catch (e) {
+ toast.show(e instanceof Error ? e.message : 'Anmeldung fehlgeschlagen', 'error', 8000);
+ }
+ }
+
// Update herunterladen und Installer öffnen — Fortschritt im Banner
async function runUpdate() {
if (!updateInfo || updateBusy) return;
@@ -56,6 +77,14 @@
await initDb();
if (auth.loggedIn) await sync.start();
+ // Anmeldung per AWL-Konto: Rücksprung aus dem Chrome-Tab (erst nach
+ // auth.init(), sonst fehlt die Server-Adresse fürs Einlösen)
+ if (Capacitor.isNativePlatform()) {
+ void App.addListener('appUrlOpen', ({ url }) => void ssoRuecksprung(url));
+ const start = await App.getLaunchUrl();
+ if (start?.url) void ssoRuecksprung(start.url);
+ }
+
// Hardware-Backbutton (Modul-Scope, Single-Instance — KB #480/#549)
registerBackListener({
handleOverlay: () => closeTopOverlay(),
diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte
index 496d593..0aea81c 100644
--- a/src/routes/login/+page.svelte
+++ b/src/routes/login/+page.svelte
@@ -5,13 +5,15 @@
import { getServerUrl, setServerUrl, ApiError } from '$lib/api';
import { toast } from '$lib/toast.svelte';
import { Capacitor } from '@capacitor/core';
- import { Eye, EyeOff, Fingerprint } from 'lucide-svelte';
+ import { Eye, EyeOff, Fingerprint, KeyRound } from 'lucide-svelte';
+ import { ssoStarten } from '$lib/sso';
let server = $state(getServerUrl());
let loginName = $state('');
let password = $state('');
let busy = $state(false);
let fingerBusy = $state(false);
+ let ssoBusy = $state(false);
/**
* Fingerabdruck-Anmeldung. Erscheint nur, wenn auf diesem Gerät auch
@@ -54,15 +56,41 @@
if (pwEl) pwEl.type = pwVisible ? 'text' : 'password';
}
+ /** Server-Adresse übernehmen — gilt für Passwort und AWL-Konto gleichermaßen */
+ async function serverUebernehmen() {
+ if (!needsServer) return;
+ if (!server.trim()) throw new ApiError(0, 'Server-Adresse fehlt');
+ await setServerUrl(server.trim());
+ }
+
+ /**
+ * Anmeldung per AWL-Konto: öffnet die awlauth-Seite im Chrome-Tab. Das
+ * Ergebnis kommt als Rücksprung im Layout an (+layout.svelte, ssoRuecksprung),
+ * nicht hier — die Seite kann bis dahin längst neu aufgebaut worden sein.
+ */
+ async function mitAwlKonto() {
+ if (ssoBusy) return;
+ ssoBusy = true;
+ try {
+ await serverUebernehmen();
+ await ssoStarten();
+ } catch (err) {
+ toast.show(
+ err instanceof ApiError ? err.message : 'Anmeldeseite konnte nicht geöffnet werden',
+ 'error',
+ 8000,
+ );
+ } finally {
+ ssoBusy = false;
+ }
+ }
+
async function submit(e: Event) {
e.preventDefault();
if (busy) return;
busy = true;
try {
- if (needsServer) {
- if (!server.trim()) throw new ApiError(0, 'Server-Adresse fehlt');
- await setServerUrl(server.trim());
- }
+ await serverUebernehmen();
await auth.login(loginName.trim(), password);
await sync.start();
toast.show('Angemeldet', 'success');
@@ -101,6 +129,25 @@
/>
{/if}
+
+ {#if needsServer}
+
+
+