Mit AWL-Konto anmelden: awlauth-Seite im Chrome-Tab statt Passwort in der App [apk]
All checks were successful
Build APK / build-apk (push) Successful in 6m6s

Der Knopf oeffnet die awlauth-Anmeldeseite im Chrome-Tab (@capacitor/browser),
der die Anmeldung mit Chrome teilt - wer im Handy-Browser angemeldet ist, ist
ohne Eingabe zurueck in der App. Ruecksprung ueber das eigene Schema
de.data-it-solution.netdiag:/sso (Intent-Filter), abgesichert mit state und
PKCE; verarbeitet im Layout (appUrlOpen + getLaunchUrl). Passwort und
Fingerabdruck bleiben.

Mitgenommen: die bisher nicht committete README-Ergaenzung vom 19.08.
(Plugin-Dateien, "Was noch offen ist").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-10-01 10:00:16 +02:00
parent 3f29a373ac
commit ad5a102565
8 changed files with 320 additions and 9 deletions

View file

@ -38,6 +38,7 @@ die APK in die Package Registry (`netdiag-apk`). Siehe `.forgejo/workflows/build
src/lib/ src/lib/
api.ts JSON-API-Client (JWT, 401-Refresh, Timeout) api.ts JSON-API-Client (JWT, 401-Refresh, Timeout)
auth.svelte.ts Anmelde-Status auth.svelte.ts Anmelde-Status
sso.ts Anmeldung per AWL-Konto (Chrome-Tab, PKCE, Rücksprung)
db.ts Offline-Speicher (SQLite nativ / localStorage Browser) db.ts Offline-Speicher (SQLite nativ / localStorage Browser)
sync.svelte.ts Sync-Queue -> Dolibarr (idempotent über clientUuid) sync.svelte.ts Sync-Queue -> Dolibarr (idempotent über clientUuid)
scanner.ts Brücke zum nativen Plugin (+ Browser-Mock) scanner.ts Brücke zum nativen Plugin (+ Browser-Mock)
@ -69,6 +70,13 @@ src/routes/
Werkzeuge mit Live-Anzeige — eigene Seite statt Werkzeuge mit Live-Anzeige — eigene Seite statt
Dialog, weil sie über Minuten/Stunden laufen Dialog, weil sie über Minuten/Stunden laufen
android/ natives Android-Projekt inkl. Kotlin-Plugin (einzige Quelle) 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 native-plugin/ nur noch Anleitung/Hinweise zum Plugin
tools/ Prüfwerkzeuge ohne Gerät (siehe unten) tools/ Prüfwerkzeuge ohne Gerät (siehe unten)
``` ```
@ -137,12 +145,31 @@ die im Feld nicht gilt (KB #1086).
## Anmeldung ## Anmeldung
Die App hängt am zentralen Auth-Modul **awlauth** (7. AWL-App). Das Token kommt aus 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 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. 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 — (Einzige, befristete Ausnahme: `update.php?download=1` für App-Fassungen im Feld —
siehe ChangeLog des Moduls 1.3.0.) 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 ### Fingerabdruck statt Passwort
Einschaltbar unter Einstellungen → Konto. Was dabei passiert: Einschaltbar unter Einstellungen → Konto. Was dabei passiert:
@ -174,7 +201,7 @@ verfügbar (KB #855).
## Bedienung ## 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. 2. **Aufträge** — aktive direkt sichtbar, abgeschlossene per Checkbox, Suche.
Alternativ über **Kunden** suchen. Alternativ über **Kunden** suchen.
3. Auftrag/Kunde antippen → Diagnose-Protokoll öffnet sich. 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 Selten gebrauchte Aktionen (Protokoll löschen) liegen hinter den drei Punkten
in der Kopfzeile — bewusst nicht neben dem grünen Abschließen-Knopf. 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 ## Ohne Gerät prüfen
- **Browser** (`npm run dev`): Mock-Scanner liefert Beispieldaten. - **Browser** (`npm run dev`): Mock-Scanner liefert Beispieldaten.

View file

@ -23,6 +23,19 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!--
Rücksprung von der awlauth-Anmeldeseite (Anmeldung per AWL-Konto):
de.data-it-solution.netdiag:/sso?code=…&state=… — muss zu
SSO_REDIRECT in src/lib/sso.ts passen. Ein fremdes Schema kann sich
jede App registrieren; einen abgefangenen Code macht PKCE wertlos.
-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="de.data-it-solution.netdiag" />
</intent-filter>
</activity> </activity>
<provider <provider

View file

@ -29,6 +29,7 @@
"dependencies": { "dependencies": {
"@capacitor-community/sqlite": "^6.0.0", "@capacitor-community/sqlite": "^6.0.0",
"@capacitor/app": "^6.0.0", "@capacitor/app": "^6.0.0",
"@capacitor/browser": "^6.0.0",
"@capacitor/core": "^6.0.0", "@capacitor/core": "^6.0.0",
"@capacitor/filesystem": "^6.0.0", "@capacitor/filesystem": "^6.0.0",
"@capacitor/network": "^6.0.0", "@capacitor/network": "^6.0.0",

View file

@ -162,6 +162,34 @@ export async function login(loginName: string, password: string): Promise<LoginR
return res; return res;
} }
/** Antwort von sso.php (GET): ist die Anmeldung per AWL-Konto eingerichtet? */
export interface SsoKonfig {
available: boolean;
reason?: string;
clientId: string;
redirectUri: string;
/** Pfad der awlauth-Anmeldeseite, relativ zur Server-Adresse */
authorizePath: string;
}
/** Fragt ab, ob der Server die Anmeldung per AWL-Konto anbietet (siehe sso.ts) */
export function ssoKonfig(): Promise<SsoKonfig> {
return request<SsoKonfig>('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<LoginResult> {
const res = await request<LoginResult>('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). * Mit einem bereits vorhandenen Token anmelden (Fingerabdruck-Weg).
* *

View file

@ -13,7 +13,9 @@ import {
loginMitToken, loginMitToken,
setAuthFailureHandler, setAuthFailureHandler,
tokenNurImSpeicher, tokenNurImSpeicher,
type LoginResult,
} from './api'; } from './api';
import { ssoAbschliessen } from './sso';
import { scanner } from './scanner'; import { scanner } from './scanner';
import type { NetUser } from './types'; import type { NetUser } from './types';
@ -112,7 +114,21 @@ class AuthState {
/** Anmelden */ /** Anmelden */
async login(loginName: string, password: string): Promise<void> { async login(loginName: string, password: string): Promise<void> {
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<boolean> {
const res = await ssoAbschliessen(url);
if (!res) return false;
await this.uebernehmen(res);
return true;
}
private async uebernehmen(res: LoginResult): Promise<void> {
this.user = res.user; this.user = res.user;
this.loggedIn = true; this.loggedIn = true;
await Preferences.set({ key: 'user', value: JSON.stringify(res.user) }); await Preferences.set({ key: 'user', value: JSON.stringify(res.user) });

128
src/lib/sso.ts Normal file
View file

@ -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<string> {
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<void> {
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<LoginResult | null> {
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);
}

View file

@ -12,6 +12,9 @@
import { closeTopOverlay } from '$lib/overlay.svelte'; import { closeTopOverlay } from '$lib/overlay.svelte';
import { checkForUpdate, installUpdate, type UpdateInfo } from '$lib/updater'; import { checkForUpdate, installUpdate, type UpdateInfo } from '$lib/updater';
import { initDebugLog } from '$lib/debuglog.svelte'; 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'; import Toast from '$lib/components/Toast.svelte';
let { children } = $props(); 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 // Update herunterladen und Installer öffnen — Fortschritt im Banner
async function runUpdate() { async function runUpdate() {
if (!updateInfo || updateBusy) return; if (!updateInfo || updateBusy) return;
@ -56,6 +77,14 @@
await initDb(); await initDb();
if (auth.loggedIn) await sync.start(); 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) // Hardware-Backbutton (Modul-Scope, Single-Instance — KB #480/#549)
registerBackListener({ registerBackListener({
handleOverlay: () => closeTopOverlay(), handleOverlay: () => closeTopOverlay(),

View file

@ -5,13 +5,15 @@
import { getServerUrl, setServerUrl, ApiError } from '$lib/api'; import { getServerUrl, setServerUrl, ApiError } from '$lib/api';
import { toast } from '$lib/toast.svelte'; import { toast } from '$lib/toast.svelte';
import { Capacitor } from '@capacitor/core'; 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 server = $state(getServerUrl());
let loginName = $state(''); let loginName = $state('');
let password = $state(''); let password = $state('');
let busy = $state(false); let busy = $state(false);
let fingerBusy = $state(false); let fingerBusy = $state(false);
let ssoBusy = $state(false);
/** /**
* Fingerabdruck-Anmeldung. Erscheint nur, wenn auf diesem Gerät auch * Fingerabdruck-Anmeldung. Erscheint nur, wenn auf diesem Gerät auch
@ -54,15 +56,41 @@
if (pwEl) pwEl.type = pwVisible ? 'text' : 'password'; 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) { async function submit(e: Event) {
e.preventDefault(); e.preventDefault();
if (busy) return; if (busy) return;
busy = true; busy = true;
try { try {
if (needsServer) { await serverUebernehmen();
if (!server.trim()) throw new ApiError(0, 'Server-Adresse fehlt');
await setServerUrl(server.trim());
}
await auth.login(loginName.trim(), password); await auth.login(loginName.trim(), password);
await sync.start(); await sync.start();
toast.show('Angemeldet', 'success'); toast.show('Angemeldet', 'success');
@ -101,6 +129,25 @@
/> />
</label> </label>
{/if} {/if}
{#if needsServer}
<!-- Anmeldung über die awlauth-Seite: ohne Eingabe, wenn im Handy-Browser schon angemeldet -->
<button
type="button"
class="flex items-center justify-center gap-2 rounded-lg bg-sky-600 py-2.5 font-semibold text-white active:bg-sky-700 disabled:opacity-50"
onclick={mitAwlKonto}
disabled={ssoBusy}
>
<KeyRound size={20} />
{ssoBusy ? 'Anmeldeseite wird geöffnet …' : 'Mit AWL-Konto anmelden'}
</button>
<div class="flex items-center gap-3 text-xs text-zinc-500">
<span class="h-px flex-1 bg-zinc-700"></span>
oder mit Passwort
<span class="h-px flex-1 bg-zinc-700"></span>
</div>
{/if}
<label class="flex flex-col gap-1 text-sm"> <label class="flex flex-col gap-1 text-sm">
<span class="text-zinc-400">Benutzer</span> <span class="text-zinc-400">Benutzer</span>
<input <input
@ -139,7 +186,9 @@
</div> </div>
</label> </label>
<button <button
class="mt-2 rounded-lg bg-sky-600 py-2.5 font-semibold text-white active:bg-sky-700 disabled:opacity-50" class="mt-2 rounded-lg py-2.5 font-semibold disabled:opacity-50 {needsServer
? 'border border-zinc-700 text-zinc-200 active:bg-zinc-800'
: 'bg-sky-600 text-white active:bg-sky-700'}"
type="submit" type="submit"
disabled={busy} disabled={busy}
> >