Implement login page proxy and CAPTCHA handling in MyTischtennisClient and Controller. Enhance login process with CAPTCHA token extraction and error handling. Update frontend to support iframe-based login and improve user experience with loading indicators.

This commit is contained in:
Torsten Schulz (local)
2025-11-23 15:18:53 +01:00
parent b74cb30cf6
commit f7a799ea7f
15 changed files with 702 additions and 284 deletions

View File

@@ -2,74 +2,75 @@
<div class="modal-overlay" @click.self="$emit('close')">
<div class="modal">
<div class="modal-header">
<h3>{{ account ? $t('myTischtennisDialog.editAccount') : $t('myTischtennisDialog.linkAccount') }}</h3>
<h3>{{ loginMode ? $t('myTischtennisDialog.login') : (account ? $t('myTischtennisDialog.editAccount') : $t('myTischtennisDialog.linkAccount')) }}</h3>
</div>
<div class="modal-body">
<div class="form-group">
<label for="mtt-email">{{ $t('myTischtennisDialog.email') }}:</label>
<input
type="email"
id="mtt-email"
v-model="formData.email"
:placeholder="$t('myTischtennisDialog.emailPlaceholder')"
required
/>
<!-- Im Login-Modus: Zeige MyTischtennis-Login-Formular in iframe -->
<div v-if="loginMode" class="login-iframe-container">
<iframe
ref="loginIframe"
:src="loginUrl"
class="login-iframe"
@load="onIframeLoad"
></iframe>
<div v-if="loading" class="iframe-loading">
{{ $t('myTischtennisDialog.loadingLoginForm') }}
</div>
</div>
<div class="form-group">
<label for="mtt-password">{{ $t('myTischtennisDialog.password') }}:</label>
<input
type="password"
id="mtt-password"
v-model="formData.password"
:placeholder="account && account.savePassword ? $t('myTischtennisDialog.passwordPlaceholderKeep') : $t('myTischtennisDialog.passwordPlaceholder')"
/>
</div>
<div class="form-group checkbox-group">
<label>
<!-- Im Bearbeiten-Modus: Zeige normales Formular -->
<template v-else>
<div class="form-group">
<label for="mtt-email">{{ $t('myTischtennisDialog.email') }}:</label>
<input
type="checkbox"
v-model="formData.savePassword"
type="email"
id="mtt-email"
v-model="formData.email"
:placeholder="$t('myTischtennisDialog.emailPlaceholder')"
required
/>
<span>{{ $t('myTischtennisDialog.savePassword') }}</span>
</label>
<p class="hint">
{{ $t('myTischtennisDialog.savePasswordHint') }}
</p>
</div>
<div class="form-group checkbox-group">
<label>
</div>
<div class="form-group">
<label for="mtt-password">{{ $t('myTischtennisDialog.password') }}:</label>
<input
type="checkbox"
v-model="formData.autoUpdateRatings"
:disabled="!formData.savePassword"
type="password"
id="mtt-password"
v-model="formData.password"
:placeholder="account && account.savePassword ? $t('myTischtennisDialog.passwordPlaceholderKeep') : $t('myTischtennisDialog.passwordPlaceholder')"
/>
<span>{{ $t('myTischtennisDialog.autoUpdateRatings') }}</span>
</label>
<p class="hint">
{{ $t('myTischtennisDialog.autoUpdateRatingsHint') }}
</p>
<p v-if="!formData.savePassword" class="warning">
{{ $t('myTischtennisDialog.autoUpdateWarning') }}
</p>
</div>
<div class="form-group" v-if="formData.password">
<label for="app-password">{{ $t('myTischtennisDialog.appPassword') }}:</label>
<input
type="password"
id="app-password"
v-model="formData.userPassword"
:placeholder="$t('myTischtennisDialog.appPasswordPlaceholder')"
required
/>
<p class="hint">
{{ $t('myTischtennisDialog.appPasswordHint') }}
</p>
</div>
</div>
<div class="form-group checkbox-group">
<label>
<input
type="checkbox"
v-model="formData.savePassword"
/>
<span>{{ $t('myTischtennisDialog.savePassword') }}</span>
</label>
<p class="hint">
{{ $t('myTischtennisDialog.savePasswordHint') }}
</p>
</div>
<!-- Auto-Update-Checkbox entfernt - automatische Abrufe wurden deaktiviert -->
<div class="form-group" v-if="formData.password">
<label for="app-password">{{ $t('myTischtennisDialog.appPassword') }}:</label>
<input
type="password"
id="app-password"
v-model="formData.userPassword"
:placeholder="$t('myTischtennisDialog.appPasswordPlaceholder')"
required
/>
<p class="hint">
{{ $t('myTischtennisDialog.appPasswordHint') }}
</p>
</div>
</template>
<div v-if="error" class="error-message">
{{ error }}
@@ -80,7 +81,7 @@
<button class="btn-secondary" @click="$emit('close')" :disabled="saving">
{{ $t('myTischtennisDialog.cancel') }}
</button>
<button class="btn-primary" @click="saveAccount" :disabled="!canSave || saving">
<button v-if="!loginMode" class="btn-primary" @click="saveAccount()" :disabled="!canSave || saving">
{{ saving ? $t('myTischtennisDialog.saving') : $t('myTischtennisDialog.save') }}
</button>
</div>
@@ -97,6 +98,10 @@ export default {
account: {
type: Object,
default: null
},
loginMode: {
type: Boolean,
default: false
}
},
data() {
@@ -105,34 +110,104 @@ export default {
email: this.account?.email || '',
password: '',
savePassword: this.account?.savePassword || false,
autoUpdateRatings: this.account?.autoUpdateRatings || false,
autoUpdateRatings: false, // Automatische Updates deaktiviert
userPassword: ''
},
saving: false,
error: null
loading: false,
error: null,
urlCheckInterval: null
};
},
computed: {
loginUrl() {
// Verwende Backend-Proxy für Login-Seite, damit Cookies im Backend-Kontext bleiben
// Verwende absolute URL für iframe
const baseUrl = import.meta.env.VITE_BACKEND || window.location.origin;
// Füge Token als Query-Parameter hinzu, damit Backend userId extrahieren kann
const token = this.$store.state.token;
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
return `${baseUrl}/api/mytischtennis/login-page${tokenParam}`;
},
canSave() {
// E-Mail ist erforderlich
if (!this.formData.email.trim()) {
return false;
}
// Im Login-Modus: Passwort ist erforderlich
if (this.loginMode) {
return !!this.formData.password;
}
// Wenn ein Passwort eingegeben wurde, muss auch das App-Passwort eingegeben werden
if (this.formData.password && !this.formData.userPassword) {
return false;
}
// Automatische Updates erfordern gespeichertes Passwort
if (this.formData.autoUpdateRatings && !this.formData.savePassword) {
return false;
}
return true;
}
},
mounted() {
if (this.loginMode) {
this.loading = true;
// URL-Überwachung wird erst gestartet, nachdem das iframe geladen wurde
}
},
beforeUnmount() {
// URL-Überwachung stoppen
if (this.urlCheckInterval) {
clearInterval(this.urlCheckInterval);
}
},
methods: {
onIframeLoad() {
this.loading = false;
console.log('[MyTischtennisDialog] Iframe geladen');
// Starte URL-Überwachung erst NACH dem Laden des iframes
// Warte 3 Sekunden, damit der Benutzer Zeit hat, sich einzuloggen
setTimeout(() => {
this.startUrlMonitoring();
}, 3000);
},
checkIframeUrl() {
try {
const iframe = this.$refs.loginIframe;
if (!iframe || !iframe.contentWindow) return;
// Versuche, die URL zu lesen (funktioniert nur bei gleicher Origin)
// Da mytischtennis.de eine andere Origin ist, können wir die URL nicht direkt lesen
// Stattdessen überwachen wir über PostMessage oder Polling
} catch (error) {
// Cross-Origin-Zugriff nicht möglich - das ist normal
console.log('[MyTischtennisDialog] Cross-Origin-Zugriff nicht möglich (erwartet)');
}
},
startUrlMonitoring() {
// Überwache, ob der Login erfolgreich war
// Prüfe, ob bereits eine gültige Session existiert
this.urlCheckInterval = setInterval(async () => {
try {
// Prüfe, ob bereits eine gültige Session existiert
// Nach erfolgreichem Login im iframe sollte submitLogin die Session gespeichert haben
const sessionResponse = await apiClient.get('/mytischtennis/session');
if (sessionResponse.data && sessionResponse.data.session && sessionResponse.data.session.accessToken) {
// Session vorhanden - Login erfolgreich!
clearInterval(this.urlCheckInterval);
this.urlCheckInterval = null;
this.$emit('logged-in');
return;
}
} catch (error) {
// Noch nicht eingeloggt oder Fehler - ignorieren
// (wird alle 3 Sekunden wieder versucht)
}
}, 3000); // Alle 3 Sekunden prüfen
},
async saveAccount() {
if (!this.canSave) return;
@@ -143,7 +218,7 @@ export default {
const payload = {
email: this.formData.email,
savePassword: this.formData.savePassword,
autoUpdateRatings: this.formData.autoUpdateRatings
autoUpdateRatings: false // Automatische Updates immer deaktiviert
};
// Nur password und userPassword hinzufügen, wenn ein Passwort eingegeben wurde
@@ -191,6 +266,33 @@ export default {
flex-direction: column;
}
.login-iframe-container {
position: relative;
width: 100%;
height: 600px;
min-height: 600px;
border: 1px solid #dee2e6;
border-radius: 4px;
overflow: hidden;
}
.login-iframe {
width: 100%;
height: 100%;
border: none;
}
.iframe-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 1rem;
background: rgba(255, 255, 255, 0.9);
border-radius: 4px;
z-index: 10;
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid #dee2e6;

View File

@@ -1155,7 +1155,11 @@
"cancel": "Abbrechen",
"saving": "Speichere...",
"save": "Speichern",
"errorSaving": "Fehler beim Speichern des Accounts"
"errorSaving": "Fehler beim Speichern des Accounts",
"login": "Einloggen",
"loggingIn": "Logge ein...",
"errorLogin": "Fehler beim Einloggen",
"loadingLoginForm": "Lade Login-Formular..."
},
"trainingDetails": {
"title": "Trainings-Details",
@@ -1536,6 +1540,7 @@
"ERROR_MYTISCHTENNIS_PASSWORD_NOT_SAVED": "Kein Passwort gespeichert. Bitte geben Sie Ihr Passwort ein.",
"ERROR_MYTISCHTENNIS_SESSION_EXPIRED": "Session abgelaufen. Bitte erneut einloggen.",
"ERROR_MYTISCHTENNIS_NO_PASSWORD_SAVED": "Kein Passwort gespeichert und Session abgelaufen. Bitte Passwort eingeben.",
"ERROR_MYTISCHTENNIS_CAPTCHA_REQUIRED": "CAPTCHA erforderlich. MyTischtennis verwendet jetzt ein CAPTCHA beim Login. Bitte loggen Sie sich einmal direkt auf mytischtennis.de ein, um das CAPTCHA zu lösen, oder kontaktieren Sie den Support.",
"ERROR_MEMBER_NOT_FOUND": "Mitglied nicht gefunden.",
"ERROR_MEMBER_ALREADY_EXISTS": "Mitglied existiert bereits.",
"ERROR_MEMBER_FIRSTNAME_REQUIRED": "Vorname ist erforderlich.",

View File

@@ -34,75 +34,12 @@
<span>{{ formatDate(account.lastLoginAttempt) }}</span>
</div>
<div class="info-row" v-if="account.lastUpdateRatings">
<label>{{ $t('myTischtennisAccount.lastFetch') }}</label>
<span>{{ formatDate(account.lastUpdateRatings) }}</span>
</div>
<div class="info-row" v-if="account.autoUpdateRatings !== undefined">
<label>{{ $t('myTischtennisAccount.autoUpdates') }}</label>
<span>{{ account.autoUpdateRatings ? $t('myTischtennisAccount.enabled') : $t('myTischtennisAccount.disabled') }}</span>
</div>
<div class="button-group">
<button class="btn-primary" @click="openEditDialog">{{ $t('myTischtennisAccount.editAccount') }}</button>
<button type="button" class="btn-secondary" @click="testConnection">{{ $t('myTischtennisAccount.loginAgain') }}</button>
<button class="btn-info" @click="openHistoryDialog" v-if="account">{{ $t('myTischtennisAccount.updateHistory') }}</button>
<button class="btn-danger" @click="deleteAccount">{{ $t('myTischtennisAccount.unlinkAccount') }}</button>
</div>
</div>
<!-- Fetch Statistics Section -->
<div class="info-section fetch-stats-section" v-if="account">
<h2>{{ $t('myTischtennisAccount.fetchStatistics') }}</h2>
<div v-if="loadingStats" class="loading-stats">{{ $t('myTischtennisAccount.loadingStatistics') }}</div>
<div v-else-if="latestFetches" class="stats-grid">
<div class="stat-card">
<div class="stat-icon">📊</div>
<div class="stat-content">
<h3>{{ $t('myTischtennisAccount.playerRatings') }}</h3>
<div v-if="latestFetches.ratings">
<p class="stat-date">{{ formatDateRelative(latestFetches.ratings.lastFetch) }}</p>
<p class="stat-detail">{{ latestFetches.ratings.recordsProcessed }} {{ $t('myTischtennisAccount.playersUpdated') }}</p>
<p class="stat-time" v-if="latestFetches.ratings.executionTime">{{ latestFetches.ratings.executionTime }}ms</p>
</div>
<p v-else class="stat-never">{{ $t('myTischtennisAccount.neverFetched') }}</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">🏓</div>
<div class="stat-content">
<h3>{{ $t('myTischtennisAccount.matchResults') }}</h3>
<div v-if="latestFetches.match_results">
<p class="stat-date">{{ formatDateRelative(latestFetches.match_results.lastFetch) }}</p>
<p class="stat-detail">{{ latestFetches.match_results.recordsProcessed }} {{ $t('myTischtennisAccount.results') }}</p>
<p class="stat-time" v-if="latestFetches.match_results.executionTime">{{ latestFetches.match_results.executionTime }}ms</p>
</div>
<p v-else class="stat-never">{{ $t('myTischtennisAccount.neverFetched') }}</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">📋</div>
<div class="stat-content">
<h3>{{ $t('myTischtennisAccount.leagueTables') }}</h3>
<div v-if="latestFetches.league_table">
<p class="stat-date">{{ formatDateRelative(latestFetches.league_table.lastFetch) }}</p>
<p class="stat-detail">{{ latestFetches.league_table.recordsProcessed }} {{ $t('myTischtennisAccount.teams') }}</p>
<p class="stat-time" v-if="latestFetches.league_table.executionTime">{{ latestFetches.league_table.executionTime }}ms</p>
</div>
<p v-else class="stat-never">{{ $t('myTischtennisAccount.neverFetched') }}</p>
</div>
</div>
</div>
<button class="btn-secondary refresh-stats-btn" @click="loadLatestFetches">
🔄 {{ $t('myTischtennisAccount.refreshStatistics') }}
</button>
</div>
</div>
<div v-else class="no-account">
@@ -126,15 +63,12 @@
<MyTischtennisDialog
v-if="showDialog"
:account="account"
:login-mode="loginMode"
@close="closeDialog"
@saved="onAccountSaved"
@logged-in="onLoggedIn"
/>
<!-- History Dialog -->
<MyTischtennisHistoryDialog
v-if="showHistoryDialog"
@close="closeHistoryDialog"
/>
</div>
@@ -163,7 +97,6 @@
import apiClient from '../apiClient.js';
import { getSafeErrorMessage } from '../utils/errorMessages.js';
import MyTischtennisDialog from '../components/MyTischtennisDialog.vue';
import MyTischtennisHistoryDialog from '../components/MyTischtennisHistoryDialog.vue';
import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
@@ -171,7 +104,6 @@ export default {
name: 'MyTischtennisAccount',
components: {
MyTischtennisDialog,
MyTischtennisHistoryDialog,
InfoDialog,
ConfirmDialog
},
@@ -194,17 +126,14 @@ export default {
resolveCallback: null
},
loading: true,
loadingStats: false,
account: null,
accountStatus: null,
latestFetches: null,
showDialog: false,
showHistoryDialog: false
loginMode: false
};
},
mounted() {
this.loadAccount();
this.loadLatestFetches();
},
methods: {
// Dialog Helper Methods
@@ -262,19 +191,13 @@ export default {
},
openEditDialog() {
this.loginMode = false;
this.showDialog = true;
},
closeDialog() {
this.showDialog = false;
},
openHistoryDialog() {
this.showHistoryDialog = true;
},
closeHistoryDialog() {
this.showHistoryDialog = false;
this.loginMode = false;
},
async onAccountSaved() {
@@ -286,33 +209,19 @@ export default {
});
},
async onLoggedIn() {
this.closeDialog();
await this.loadAccount();
this.$store.dispatch('showMessage', {
text: this.$t('myTischtennisAccount.loginSuccessful'),
type: 'success'
});
},
async testConnection() {
console.log('[testConnection] Starting connection test...');
try {
const response = await apiClient.post('/mytischtennis/verify');
console.log('[testConnection] Response:', response);
this.$store.dispatch('showMessage', {
text: this.$t('myTischtennisAccount.loginSuccessful'),
type: 'success'
});
await this.loadAccount(); // Aktualisiere Account-Daten inkl. clubId, fedNickname
} catch (error) {
console.error('[testConnection] Error:', error);
const message = getSafeErrorMessage(error, 'Login fehlgeschlagen');
if (error.response?.status === 400 && message.includes('Kein Passwort gespeichert')) {
// Passwort-Dialog öffnen
this.showDialog = true;
this.showInfo(this.$t('myTischtennisAccount.passwordRequired'), message, '', 'warning');
} else {
this.showInfo(this.$t('myTischtennisAccount.loginFailed'), message, '', 'error');
}
this.$store.dispatch('showMessage', {
text: message,
type: 'error'
});
}
// Öffne das Login-Dialog mit vorausgefüllter E-Mail
this.showDialog = true;
this.loginMode = true;
},
async deleteAccount() {
@@ -336,18 +245,6 @@ export default {
}
},
async loadLatestFetches() {
this.loadingStats = true;
try {
const response = await apiClient.get('/mytischtennis/latest-fetches');
this.latestFetches = response.data.latestFetches;
} catch (error) {
console.error('Fehler beim Laden der Fetch-Statistiken:', error);
} finally {
this.loadingStats = false;
}
},
formatDate(dateString) {
if (!dateString) return '-';
const date = new Date(dateString);