feat(auth): implement token rotation and session management for persistent Android login
This commit is contained in:
@@ -12,7 +12,8 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- Bild-Loading: Coil
|
- Bild-Loading: Coil
|
||||||
- Lokale DB / Caching: Room + DataStore (Preferences)
|
- Lokale DB / Caching: Room + DataStore (Preferences)
|
||||||
- Background/Sync: WorkManager
|
- Background/Sync: WorkManager
|
||||||
- Auth: JWT-Handling, Unterstützung für Passkeys (Android Passkeys / WebAuthn Interop über FIDO2 APIs)
|
- Auth: kurzlebiges JWT-Access-Token plus rotierendes, widerrufbares Refresh-Token pro Android-Gerätesitzung; Speicherung in `EncryptedSharedPreferences`/Android Keystore; Unterstützung für Passkeys (Android Passkeys / WebAuthn Interop über FIDO2 APIs)
|
||||||
|
- Auth-Sicherheitsentscheidung: kein statischer App-Key bzw. kein in der APK hinterlegtes Client-Secret. Native Apps können ein gemeinsames Secret nicht vertraulich halten. Optional später: Refresh-Sitzung an ein pro Installation im Android Keystore erzeugtes Schlüsselpaar binden.
|
||||||
- Rich-Text: WebView-basierte Anzeige; Editoren: ggf. hybride Lösung (Server-side HTML editor + WebView) oder `RichEditor`-Libs
|
- Rich-Text: WebView-basierte Anzeige; Editoren: ggf. hybride Lösung (Server-side HTML editor + WebView) oder `RichEditor`-Libs
|
||||||
- Crash-Reporting & Monitoring: Firebase Crashlytics oder Sentry
|
- Crash-Reporting & Monitoring: Firebase Crashlytics oder Sentry
|
||||||
|
|
||||||
@@ -96,10 +97,18 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- [x] Mitgliedschafts-PDF-Endpunkte mit Cookie-Jar und `FileProvider`
|
- [x] Mitgliedschafts-PDF-Endpunkte mit Cookie-Jar und `FileProvider`
|
||||||
- [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
|
- [x] Passwort-Login-Endpunkt und Token-Übergabe an den Interceptor
|
||||||
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
|
- [x] Verschlüsselte Token-Persistenz sowie Status/Logout per Bearer-Token
|
||||||
- [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche und Refresh-Strategie
|
- [ ] Bearer-Unterstützung aller später portierten geschützten Bereiche
|
||||||
|
- [ ] `POST /api/auth/refresh` anbinden und Access-Token bei Ablauf automatisch erneuern
|
||||||
|
- [ ] OkHttp-`Authenticator` mit genau einem synchronisierten Refresh-Versuch pro fehlgeschlagenem Request ergänzen
|
||||||
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
|
[x] 12. Auth: Login/Register/Logout + sichere Token-Speicherung (EncryptedSharedPreferences)
|
||||||
- [x] Login/Logout und verschlüsselte Token-Speicherung
|
- [x] Login/Logout und verschlüsselte Token-Speicherung
|
||||||
- [x] Registrierung und Passwort-Reset
|
- [x] Registrierung und Passwort-Reset
|
||||||
|
- [ ] Backend: JWT-Access-Token von aktuell 7 Tagen auf kurze Laufzeit (Ziel: ca. 15 Minuten) reduzieren
|
||||||
|
- [ ] Backend: langlebige, zufällige Refresh-Token pro Gerätesitzung mit serverseitig gespeichertem Token-Hash einführen
|
||||||
|
- [ ] Backend: Refresh-Token bei jeder Erneuerung rotieren und Wiederverwendung eines verbrauchten Tokens als Sitzungsdiebstahl behandeln
|
||||||
|
- [ ] Backend: Logout, Kontodeaktivierung und Passwortänderung widerrufen betroffene Refresh-Sitzungen
|
||||||
|
- [ ] App: Access- und Refresh-Token verschlüsselt speichern, Sitzung beim App-Start durch Refresh wiederherstellen
|
||||||
|
- [ ] Optional nach MVP: pro Installation ein nicht exportierbares Keystore-Schlüsselpaar erzeugen und Refresh-Requests daran binden
|
||||||
[ ] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
|
[ ] 13. Passkeys: Integration prüfen (FIDO2 / Passkeys) und Fallback auf Passwort
|
||||||
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
|
[ ] 14. Image-Upload: Multipart-Upload + Coil für Anzeige + Bildkompression (u. a. Sharp-Äquivalent evtl. serverseitig)
|
||||||
[ ] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
|
[ ] 15. Rich-Text: Anzeige von HTML (Compose + WebView) und ggf. Editor via WebView-bridge
|
||||||
@@ -118,12 +127,14 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- [x] A. Auth (Login/Logout)
|
- [x] A. Auth (Login/Logout)
|
||||||
- [x] Passwort-Login und Logout in der aktuellen App-Sitzung
|
- [x] Passwort-Login und Logout in der aktuellen App-Sitzung
|
||||||
- [x] Persistente Statuswiederherstellung/Logout für die Auth-Endpunkte
|
- [x] Persistente Statuswiederherstellung/Logout für die Auth-Endpunkte
|
||||||
|
- [ ] Dauerhaftes Eingeloggtbleiben durch rotierendes Refresh-Token pro Android-Gerätesitzung
|
||||||
- [x] B. Home, Termine, Spielplan, Galerie (anzeigen)
|
- [x] B. Home, Termine, Spielplan, Galerie (anzeigen)
|
||||||
- [x] C. Kontaktformular (absenden)
|
- [x] C. Kontaktformular (absenden)
|
||||||
- [ ] D. Bildanzeige + Caching
|
- [ ] D. Bildanzeige + Caching
|
||||||
- [x] E. Theme & Fonts
|
- [x] E. Theme & Fonts
|
||||||
|
|
||||||
6) Nächste Aktionen (sofort)
|
6) Nächste Aktionen (sofort)
|
||||||
|
- Dauerhaftes Android-Login umsetzen: Backend-Refresh-Sitzungen, Token-Rotation, serverseitigen Widerruf und App-Refresh-Flow ergänzen.
|
||||||
- Passkey-Anmeldung über Android Credential Manager anbinden.
|
- Passkey-Anmeldung über Android Credential Manager anbinden.
|
||||||
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
|
- Die noch fehlenden öffentlichen Routen aus `10a` und die Newsletter-Screens aus `10b` nativ portieren.
|
||||||
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
|
- Saisonwahl für Mannschaftsübersicht/-details wie in der Web-UI ergänzen.
|
||||||
@@ -146,6 +157,7 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- 2026-05-27: Mannschaftsdetail um die Web-Untertabs `Matches` und `Tabelle` erweitert; Tabellenzeilen werden aus `/api/spielplan/table` geladen und die eigene Mannschaft hervorgehoben.
|
- 2026-05-27: Mannschaftsdetail um die Web-Untertabs `Matches` und `Tabelle` erweitert; Tabellenzeilen werden aus `/api/spielplan/table` geladen und die eigene Mannschaft hervorgehoben.
|
||||||
- 2026-05-27: Tabellenraster in den Mannschaftsdetails mit gemeinsamen Spaltenbreiten für Tablet und Smartphone ausgerichtet; die Zustandswiederherstellung dynamischer Mannschaftslinks korrigiert.
|
- 2026-05-27: Tabellenraster in den Mannschaftsdetails mit gemeinsamen Spaltenbreiten für Tablet und Smartphone ausgerichtet; die Zustandswiederherstellung dynamischer Mannschaftslinks korrigiert.
|
||||||
- 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln.
|
- 2026-05-27: Die verbleibenden öffentlichen Screens aus Punkt 10 portiert: Verein/CMS-Inhalte, Vorstand, Satzung/PDF, Links, Vereinsmeisterschaften mit Personenbild-Dialog, Spielsysteme und TT-Regeln.
|
||||||
|
- 2026-05-27: Architektur für dauerhaftes Android-Login festgelegt: kein eingebetteter App-Key, sondern kurzlebige Access-Tokens und rotierende, widerrufbare Refresh-Tokens pro Gerätesitzung; optionale spätere Gerätebindung per Keystore-Schlüsselpaar.
|
||||||
|
|
||||||
8) Android-Testumgebungen
|
8) Android-Testumgebungen
|
||||||
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
|
- Lokal im Emulator: `./gradlew :app:installLocalDebug` verwendet `http://10.0.2.2:3100/` und die App-ID `de.harheimertc.local`.
|
||||||
@@ -154,5 +166,31 @@ Kurz: Ziel ist eine native Android-App mit Kotlin + Jetpack Compose, die die Web
|
|||||||
- Produktion: `./gradlew :app:installProductionDebug` verwendet `https://harheimertc.de/` und die App-ID `de.harheimertc`.
|
- Produktion: `./gradlew :app:installProductionDebug` verwendet `https://harheimertc.de/` und die App-ID `de.harheimertc`.
|
||||||
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
|
- Nur APKs erzeugen: `./gradlew :app:assembleLocalDebug :app:assembleInstantTestDebug :app:assembleProductionDebug`.
|
||||||
|
|
||||||
|
9) Dauerhaftes Android-Login: Architektur und Umsetzung
|
||||||
|
- Ausgangslage: Das Backend gibt derzeit ein sieben Tage gültiges JWT aus. Die App speichert es bereits verschlüsselt und sendet es als Bearer-Token. Die vorhandene serverseitige Sessiondatei wird beim Authentifizieren geschützter Requests derzeit nicht zur Widerrufsprüfung herangezogen.
|
||||||
|
- Ziel: Ein Benutzer bleibt auf einem bekannten Gerät angemeldet, ohne dass ein langfristig gültiges Bearer-JWT oder ein extrahierbares App-Secret verwendet wird.
|
||||||
|
- Token-Modell:
|
||||||
|
- Access-Token: JWT mit kurzer Laufzeit, Zielwert ca. 15 Minuten; wird für normale API-Requests als Bearer-Token verwendet.
|
||||||
|
- Refresh-Token: kryptografisch zufälliges, undurchsichtiges Token mit längerer Laufzeit, Zielwert z. B. 90 Tage mit Erneuerung bei aktiver Nutzung.
|
||||||
|
- Server speichert ausschließlich den Hash des Refresh-Tokens zusammen mit `sessionId`, `userId`, `createdAt`, `lastUsedAt`, `expiresAt`, `revokedAt` und optional Gerätebezeichnung.
|
||||||
|
- Backend-Arbeitspaket:
|
||||||
|
- Login-Antwort um `accessToken`, `refreshToken`, `sessionId` und Ablaufmetadaten erweitern; bestehendes `token` nur befristet kompatibel halten.
|
||||||
|
- `POST /api/auth/refresh` implementieren: gültiges Refresh-Token konsumieren, rotieren und ein neues Token-Paar zurückgeben.
|
||||||
|
- Token-Wiederverwendung erkennen: Wird ein rotiertes Refresh-Token erneut präsentiert, die betroffene Token-Familie bzw. Gerätesitzung widerrufen.
|
||||||
|
- `POST /api/auth/logout` auf Widerruf der Gerätesitzung erweitern; optional Endpunkte zum Anzeigen und Widerrufen eigener Geräte-Sitzungen vorsehen.
|
||||||
|
- Kontodeaktivierung und Passwortänderung müssen sämtliche Refresh-Sitzungen des Benutzers widerrufen.
|
||||||
|
- Rate-Limits und Audit-Events für Login, Refresh-Erfolg/-Fehlschlag, Wiederverwendung und Widerruf ergänzen.
|
||||||
|
- Android-Arbeitspaket:
|
||||||
|
- `AuthRepository` auf Access-Token, Refresh-Token und Session-ID erweitern; Speicherung weiter über Keystore-geschützte Preferences.
|
||||||
|
- `ApiService`/DTOs um Refresh-Request und Token-Paar-Antwort ergänzen.
|
||||||
|
- Einen OkHttp-`Authenticator` einsetzen, der auf `401` einmalig ein Access-Token erneuert, parallele Refreshes synchronisiert und den ursprünglichen Request wiederholt.
|
||||||
|
- Beim App-Start zunächst Access-Token prüfen und bei Ablauf transparent mit dem Refresh-Token erneuern; nur bei fehlgeschlagenem Refresh zum Login zurückkehren.
|
||||||
|
- Beim Logout lokale Tokens auch bei Netzwerkfehler entfernen; serverseitiger Widerruf erfolgt best effort bzw. bei nächster Konnektivität.
|
||||||
|
- Sicherheitsregeln:
|
||||||
|
- Kein gemeinsamer App-Key und kein statisches Client-Secret in Sourcecode, `BuildConfig` oder APK.
|
||||||
|
- Refresh-Tokens nie im Klartext serverseitig speichern oder protokollieren.
|
||||||
|
- Nur HTTPS für Test-/Produktionsumgebungen; Token-Werte nicht in Logging-Interceptors ausgeben.
|
||||||
|
- Optional nach MVP: App erzeugt pro Installation ein Keystore-Schlüsselpaar; Backend bindet Refresh-Sitzungen an den öffentlichen Schlüssel und prüft signierte Refresh-Anfragen.
|
||||||
|
|
||||||
---
|
---
|
||||||
Datei: [ANDROID_KOTLIN_PLAN.md](ANDROID_KOTLIN_PLAN.md)
|
Datei: [ANDROID_KOTLIN_PLAN.md](ANDROID_KOTLIN_PLAN.md)
|
||||||
|
|||||||
@@ -403,6 +403,7 @@ const isEditing = ref(false)
|
|||||||
const editingIndex = ref(-1)
|
const editingIndex = ref(-1)
|
||||||
const formData = ref({ mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: '' })
|
const formData = ref({ mannschaft: '', liga: '', staffelleiter: '', telefon: '', heimspieltag: '', spielsystem: '', mannschaftsfuehrer: '', spielerListe: [], weitere_informationen_link: '', letzte_aktualisierung: '' })
|
||||||
const moveTargetBySpielerId = ref({})
|
const moveTargetBySpielerId = ref({})
|
||||||
|
const initialMoveTargetBySpielerId = ref({})
|
||||||
const pendingSpielerNamesByTeamIndex = ref({})
|
const pendingSpielerNamesByTeamIndex = ref({})
|
||||||
|
|
||||||
function nowIsoDate() { return new Date().toISOString().split('T')[0] }
|
function nowIsoDate() { return new Date().toISOString().split('T')[0] }
|
||||||
@@ -449,7 +450,7 @@ async function loadSeasons() {
|
|||||||
if (!selectedSeason.value || !seasons.value.includes(selectedSeason.value)) {
|
if (!selectedSeason.value || !seasons.value.includes(selectedSeason.value)) {
|
||||||
selectedSeason.value = result.defaultSeason || seasons.value[0]
|
selectedSeason.value = result.defaultSeason || seasons.value[0]
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch {
|
||||||
if (!seasons.value.length) seasons.value = ['']
|
if (!seasons.value.length) seasons.value = ['']
|
||||||
if (!selectedSeason.value) selectedSeason.value = seasons.value[0] || ''
|
if (!selectedSeason.value) selectedSeason.value = seasons.value[0] || ''
|
||||||
}
|
}
|
||||||
@@ -461,7 +462,7 @@ const mannschaftenSelectOptions = computed(() => {
|
|||||||
return [...new Set([current, ...names])].filter(Boolean)
|
return [...new Set([current, ...names])].filter(Boolean)
|
||||||
})
|
})
|
||||||
|
|
||||||
function resetSpielerDraftState() { moveTargetBySpielerId.value = {}; pendingSpielerNamesByTeamIndex.value = {} }
|
function resetSpielerDraftState() { moveTargetBySpielerId.value = {}; initialMoveTargetBySpielerId.value = {}; pendingSpielerNamesByTeamIndex.value = {} }
|
||||||
function getPendingSpielerNamesForTeamIndex(teamIndex) {
|
function getPendingSpielerNamesForTeamIndex(teamIndex) {
|
||||||
if (pendingSpielerNamesByTeamIndex.value[teamIndex]) return pendingSpielerNamesByTeamIndex.value[teamIndex]
|
if (pendingSpielerNamesByTeamIndex.value[teamIndex]) return pendingSpielerNamesByTeamIndex.value[teamIndex]
|
||||||
const existing = mannschaften.value[teamIndex]; const list = existing ? getSpielerListe(existing) : []
|
const existing = mannschaften.value[teamIndex]; const list = existing ? getSpielerListe(existing) : []
|
||||||
@@ -497,29 +498,60 @@ const openEditModal = (mannschaft, index) => {
|
|||||||
formData.value = { mannschaft: mannschaft.mannschaft || '', liga: mannschaft.liga || '', staffelleiter: mannschaft.staffelleiter || '', telefon: mannschaft.telefon || '', heimspieltag: mannschaft.heimspieltag || '', spielsystem: mannschaft.spielsystem || '', mannschaftsfuehrer: mannschaft.mannschaftsfuehrer || '', spielerListe: parseSpielerString(mannschaft.spieler || ''), weitere_informationen_link: mannschaft.weitere_informationen_link || '', letzte_aktualisierung: mannschaft.letzte_aktualisierung || nowIsoDate() }
|
formData.value = { mannschaft: mannschaft.mannschaft || '', liga: mannschaft.liga || '', staffelleiter: mannschaft.staffelleiter || '', telefon: mannschaft.telefon || '', heimspieltag: mannschaft.heimspieltag || '', spielsystem: mannschaft.spielsystem || '', mannschaftsfuehrer: mannschaft.mannschaftsfuehrer || '', spielerListe: parseSpielerString(mannschaft.spieler || ''), weitere_informationen_link: mannschaft.weitere_informationen_link || '', letzte_aktualisierung: mannschaft.letzte_aktualisierung || nowIsoDate() }
|
||||||
isEditing.value = true; editingIndex.value = index; showModal.value = true; errorMessage.value = ''; resetSpielerDraftState()
|
isEditing.value = true; editingIndex.value = index; showModal.value = true; errorMessage.value = ''; resetSpielerDraftState()
|
||||||
const currentTeam = (formData.value.mannschaft || '').trim()
|
const currentTeam = (formData.value.mannschaft || '').trim()
|
||||||
for (const s of formData.value.spielerListe) { moveTargetBySpielerId.value[s.id] = currentTeam }
|
for (const s of formData.value.spielerListe) {
|
||||||
|
moveTargetBySpielerId.value[s.id] = currentTeam
|
||||||
|
initialMoveTargetBySpielerId.value[s.id] = currentTeam
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const addSpieler = () => {
|
||||||
|
const item = newSpielerItem('')
|
||||||
|
const currentTeam = (formData.value.mannschaft || '').trim()
|
||||||
|
formData.value.spielerListe.push(item)
|
||||||
|
moveTargetBySpielerId.value[item.id] = currentTeam
|
||||||
|
initialMoveTargetBySpielerId.value[item.id] = currentTeam
|
||||||
|
}
|
||||||
|
const removeSpieler = (id) => {
|
||||||
|
const idx = formData.value.spielerListe.findIndex(s => s.id === id)
|
||||||
|
if (idx === -1) return
|
||||||
|
formData.value.spielerListe.splice(idx, 1)
|
||||||
|
delete moveTargetBySpielerId.value[id]
|
||||||
|
delete initialMoveTargetBySpielerId.value[id]
|
||||||
}
|
}
|
||||||
const addSpieler = () => { const item = newSpielerItem(''); formData.value.spielerListe.push(item); moveTargetBySpielerId.value[item.id] = (formData.value.mannschaft || '').trim() }
|
|
||||||
const removeSpieler = (id) => { const idx = formData.value.spielerListe.findIndex(s => s.id === id); if (idx === -1) return; formData.value.spielerListe.splice(idx, 1); if (moveTargetBySpielerId.value[id]) delete moveTargetBySpielerId.value[id] }
|
|
||||||
const moveSpielerUp = (index) => { if (index <= 0) return; const arr = formData.value.spielerListe; const item = arr[index]; arr.splice(index, 1); arr.splice(index - 1, 0, item) }
|
const moveSpielerUp = (index) => { if (index <= 0) return; const arr = formData.value.spielerListe; const item = arr[index]; arr.splice(index, 1); arr.splice(index - 1, 0, item) }
|
||||||
const moveSpielerDown = (index) => { const arr = formData.value.spielerListe; if (index < 0 || index >= arr.length - 1) return; const item = arr[index]; arr.splice(index, 1); arr.splice(index + 1, 0, item) }
|
const moveSpielerDown = (index) => { const arr = formData.value.spielerListe; if (index < 0 || index >= arr.length - 1) return; const item = arr[index]; arr.splice(index, 1); arr.splice(index + 1, 0, item) }
|
||||||
const canMoveSpieler = (id) => { const t = (moveTargetBySpielerId.value[id] || '').trim(); const c = (formData.value.mannschaft || '').trim(); return Boolean(t) && Boolean(c) && t !== c }
|
const canMoveSpieler = (id) => {
|
||||||
|
const target = (moveTargetBySpielerId.value[id] || '').trim()
|
||||||
|
const initialTarget = (initialMoveTargetBySpielerId.value[id] || '').trim()
|
||||||
|
return Boolean(target) && Boolean(initialTarget) && target !== initialTarget
|
||||||
|
}
|
||||||
|
|
||||||
const moveSpielerToMannschaft = (spielerId) => {
|
const moveSpielerToMannschaft = (spielerId) => {
|
||||||
if (!isEditing.value || editingIndex.value < 0) return
|
if (!isEditing.value || editingIndex.value < 0) return false
|
||||||
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim(); if (!targetName) return
|
const targetName = (moveTargetBySpielerId.value[spielerId] || '').trim(); if (!targetName) return false
|
||||||
const targetIndex = mannschaften.value.findIndex((m, idx) => { if (idx === editingIndex.value) return false; return (m?.mannschaft || '').trim() === targetName })
|
const targetIndex = mannschaften.value.findIndex((m, idx) => { if (idx === editingIndex.value) return false; return (m?.mannschaft || '').trim() === targetName })
|
||||||
if (targetIndex === -1) { errorMessage.value = 'Ziel-Mannschaft nicht gefunden.'; return }
|
if (targetIndex === -1) { errorMessage.value = 'Ziel-Mannschaft nicht gefunden.'; return false }
|
||||||
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId); if (idx === -1) return
|
const idx = formData.value.spielerListe.findIndex(s => s.id === spielerId); if (idx === -1) return false
|
||||||
const spielerName = (formData.value.spielerListe[idx]?.name || '').trim(); if (!spielerName) { errorMessage.value = 'Bitte zuerst einen Spielernamen eintragen.'; return }
|
const spielerName = (formData.value.spielerListe[idx]?.name || '').trim(); if (!spielerName) { errorMessage.value = 'Bitte zuerst einen Spielernamen eintragen.'; return false }
|
||||||
formData.value.spielerListe.splice(idx, 1)
|
formData.value.spielerListe.splice(idx, 1)
|
||||||
const pendingList = getPendingSpielerNamesForTeamIndex(targetIndex); pendingList.push(spielerName)
|
const pendingList = getPendingSpielerNamesForTeamIndex(targetIndex); pendingList.push(spielerName)
|
||||||
delete moveTargetBySpielerId.value[spielerId]
|
delete moveTargetBySpielerId.value[spielerId]
|
||||||
|
delete initialMoveTargetBySpielerId.value[spielerId]
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const applySelectedSpielerTransfers = () => {
|
||||||
|
if (!isEditing.value || editingIndex.value < 0) return true
|
||||||
|
const pendingIds = formData.value.spielerListe
|
||||||
|
.filter(spieler => canMoveSpieler(spieler.id))
|
||||||
|
.map(spieler => spieler.id)
|
||||||
|
|
||||||
|
return pendingIds.every(spielerId => moveSpielerToMannschaft(spielerId))
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveMannschaft = async () => {
|
const saveMannschaft = async () => {
|
||||||
isSaving.value = true; errorMessage.value = ''
|
isSaving.value = true; errorMessage.value = ''
|
||||||
try {
|
try {
|
||||||
|
if (!applySelectedSpielerTransfers()) return
|
||||||
const spielerString = serializeSpielerList(formData.value.spielerListe)
|
const spielerString = serializeSpielerList(formData.value.spielerListe)
|
||||||
const updated = { mannschaft: formData.value.mannschaft || '', liga: formData.value.liga || '', staffelleiter: formData.value.staffelleiter || '', telefon: formData.value.telefon || '', heimspieltag: formData.value.heimspieltag || '', spielsystem: formData.value.spielsystem || '', mannschaftsfuehrer: formData.value.mannschaftsfuehrer || '', spieler: spielerString, weitere_informationen_link: formData.value.weitere_informationen_link || '', letzte_aktualisierung: formData.value.letzte_aktualisierung || nowIsoDate() }
|
const updated = { mannschaft: formData.value.mannschaft || '', liga: formData.value.liga || '', staffelleiter: formData.value.staffelleiter || '', telefon: formData.value.telefon || '', heimspieltag: formData.value.heimspieltag || '', spielsystem: formData.value.spielsystem || '', mannschaftsfuehrer: formData.value.mannschaftsfuehrer || '', spieler: spielerString, weitere_informationen_link: formData.value.weitere_informationen_link || '', letzte_aktualisierung: formData.value.letzte_aktualisierung || nowIsoDate() }
|
||||||
if (isEditing.value && editingIndex.value >= 0) { mannschaften.value[editingIndex.value] = { ...updated } } else { mannschaften.value.push({ ...updated }) }
|
if (isEditing.value && editingIndex.value >= 0) { mannschaften.value[editingIndex.value] = { ...updated } } else { mannschaften.value.push({ ...updated }) }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "harheimertc-website",
|
"name": "harheimertc-website",
|
||||||
"version": "1.5.2",
|
"version": "1.6.0",
|
||||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
Reference in New Issue
Block a user