feat(myTischtennis): integrate Playwright for CAPTCHA handling and enhance login form functionality

- Added Playwright as a dependency to handle CAPTCHA challenges during login attempts.
- Implemented a new endpoint to retrieve the login form from myTischtennis, parsing necessary fields for user input.
- Enhanced the login process to utilize Playwright for browser automation when CAPTCHA is required.
- Updated the MyTischtennisDialog component to support local login form submission instead of using an iframe.
- Refactored the MyTischtennisController to include proxy functionality for serving resources and handling login submissions.
- Improved error handling and user feedback during login attempts, ensuring a smoother user experience.
This commit is contained in:
Torsten Schulz (local)
2026-02-27 17:15:20 +01:00
parent b2017b7365
commit 4e81a1c4a7
14 changed files with 917 additions and 258 deletions

View File

@@ -126,7 +126,11 @@
</div>
<main class="main-content">
<router-view class="content fade-in"></router-view>
<router-view v-slot="{ Component }">
<div class="content fade-in">
<component :is="Component" />
</div>
</router-view>
</main>
</div>

View File

@@ -6,18 +6,29 @@
</div>
<div class="modal-body">
<!-- 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') }}
<!-- Im Login-Modus: lokales Formular, Login serverseitig via Playwright-Fallback -->
<template v-if="loginMode">
<div class="form-group">
<label for="mtt-login-email">{{ $t('myTischtennisDialog.email') }}:</label>
<input
type="email"
id="mtt-login-email"
v-model="formData.email"
readonly
/>
</div>
</div>
<div class="form-group">
<label for="mtt-login-password">{{ $t('myTischtennisDialog.password') }}:</label>
<input
type="password"
id="mtt-login-password"
v-model="formData.password"
:placeholder="$t('myTischtennisDialog.passwordPlaceholder')"
required
/>
</div>
</template>
<!-- Im Bearbeiten-Modus: Zeige normales Formular -->
<template v-else>
@@ -55,7 +66,22 @@
</p>
</div>
<!-- Auto-Update-Checkbox entfernt - automatische Abrufe wurden deaktiviert -->
<div class="form-group checkbox-group">
<label>
<input
type="checkbox"
v-model="formData.autoUpdateRatings"
:disabled="!formData.savePassword"
/>
<span>{{ $t('myTischtennisDialog.autoUpdateRatings') }}</span>
</label>
<p class="hint">
{{ $t('myTischtennisDialog.autoUpdateRatingsHint') }}
</p>
<p v-if="formData.autoUpdateRatings && !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>
@@ -81,6 +107,9 @@
<button class="btn-secondary" @click="$emit('close')" :disabled="saving">
{{ $t('myTischtennisDialog.cancel') }}
</button>
<button v-if="loginMode" class="btn-primary" @click="performLogin" :disabled="!canLogin || saving">
{{ saving ? $t('myTischtennisDialog.saving') : $t('myTischtennisDialog.login') }}
</button>
<button v-if="!loginMode" class="btn-primary" @click="saveAccount()" :disabled="!canSave || saving">
{{ saving ? $t('myTischtennisDialog.saving') : $t('myTischtennisDialog.save') }}
</button>
@@ -110,24 +139,16 @@ export default {
email: this.account?.email || '',
password: '',
savePassword: this.account?.savePassword || false,
autoUpdateRatings: false, // Automatische Updates deaktiviert
autoUpdateRatings: this.account?.autoUpdateRatings || false,
userPassword: ''
},
saving: false,
loading: false,
error: null,
urlCheckInterval: null
error: 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}`;
canLogin() {
return !!this.formData.password;
},
canSave() {
// E-Mail ist erforderlich
@@ -135,11 +156,6 @@ export default {
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;
@@ -148,66 +164,26 @@ export default {
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() {
async performLogin() {
if (!this.canLogin) return;
this.error = null;
this.saving = true;
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
await apiClient.post('/mytischtennis/verify', {
password: this.formData.password
});
this.$emit('logged-in');
} catch (error) {
// Cross-Origin-Zugriff nicht möglich - das ist normal
console.log('[MyTischtennisDialog] Cross-Origin-Zugriff nicht möglich (erwartet)');
console.error('Fehler beim Login:', error);
this.error = error.response?.data?.error
|| error.response?.data?.message
|| this.$t('myTischtennisDialog.errorSaving');
} finally {
this.saving = false;
}
},
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;
@@ -218,7 +194,7 @@ export default {
const payload = {
email: this.formData.email,
savePassword: this.formData.savePassword,
autoUpdateRatings: false // Automatische Updates immer deaktiviert
autoUpdateRatings: this.formData.savePassword ? this.formData.autoUpdateRatings : false
};
// Nur password und userPassword hinzufügen, wenn ein Passwort eingegeben wurde
@@ -231,7 +207,9 @@ export default {
this.$emit('saved');
} catch (error) {
console.error('Fehler beim Speichern:', error);
this.error = error.response?.data?.message || this.$t('myTischtennisDialog.errorSaving');
this.error = error.response?.data?.error
|| error.response?.data?.message
|| this.$t('myTischtennisDialog.errorSaving');
} finally {
this.saving = false;
}
@@ -266,33 +244,6 @@ 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

@@ -155,11 +155,6 @@ if (typeof window !== 'undefined' && (process.env.NODE_ENV === 'development' ||
window.setLanguage = setLanguage;
window.getCurrentLanguage = getCurrentLanguage;
window.getAvailableLanguages = getAvailableLanguages;
console.log('🌐 Sprache-Test-Funktionen verfügbar:');
console.log(' - setLanguage("de") - Sprache ändern');
console.log(' - getCurrentLanguage() - Aktuelle Sprache abrufen');
console.log(' - getAvailableLanguages() - Verfügbare Sprachen anzeigen');
console.log(' - Oder URL-Parameter verwenden: ?lang=de');
}
export default i18n;

View File

@@ -1,20 +1,20 @@
<template>
<div>
<h2>{{ $t('auth.login') }}</h2>
<form @submit.prevent="executeLogin">
<input v-model="email" type="email" :placeholder="$t('auth.email')" required />
<input v-model="password" type="password" :placeholder="$t('auth.password')" required />
<button type="submit">{{ $t('auth.login') }}</button>
</form>
<div class="forgot-password-link">
<router-link to="/forgot-password">{{ $t('auth.forgotPassword') }}</router-link>
<div class="login-page">
<div>
<h2>{{ $t('auth.login') }}</h2>
<form @submit.prevent="executeLogin">
<input v-model="email" type="email" :placeholder="$t('auth.email')" required />
<input v-model="password" type="password" :placeholder="$t('auth.password')" required />
<button type="submit">{{ $t('auth.login') }}</button>
</form>
<div class="forgot-password-link">
<router-link to="/forgot-password">{{ $t('auth.forgotPassword') }}</router-link>
</div>
<div class="register-link">
<p>{{ $t('auth.noAccount') }} <router-link to="/register">{{ $t('auth.register') }}</router-link></p>
</div>
</div>
<div class="register-link">
<p>{{ $t('auth.noAccount') }} <router-link to="/register">{{ $t('auth.register') }}</router-link></p>
</div>
</div>
<!-- Info Dialog -->
<InfoDialog
v-model="infoDialog.isOpen"
@@ -34,6 +34,7 @@
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
</div>
</template>
<script>
@@ -43,6 +44,11 @@ import InfoDialog from '../components/InfoDialog.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import { buildInfoConfig, buildConfirmConfig, safeErrorMessage } from '../utils/dialogUtils.js';
export default {
name: 'Login',
components: {
InfoDialog,
ConfirmDialog
},
data() {
return {
// Dialog States

View File

@@ -1,77 +1,81 @@
<template>
<div class="page-container">
<h1>{{ $t('myTischtennisAccount.title') }}</h1>
<div class="account-container">
<div v-if="loading" class="loading">{{ $t('myTischtennisAccount.loading') }}</div>
<div class="mytt-account-page">
<div class="page-container">
<h1>{{ $t('myTischtennisAccount.title') }}</h1>
<div v-else-if="account" class="account-info">
<div class="info-section">
<h2>{{ $t('myTischtennisAccount.linkedAccount') }}</h2>
<div class="info-row">
<label>{{ $t('myTischtennisAccount.email') }}</label>
<span>{{ account.email }}</span>
</div>
<div class="info-row">
<label>{{ $t('myTischtennisAccount.passwordSaved') }}</label>
<span>{{ accountStatus && accountStatus.hasPassword ? $t('myTischtennisAccount.yes') : $t('myTischtennisAccount.no') }}</span>
</div>
<div class="info-row" v-if="account.clubId">
<label>{{ $t('myTischtennisAccount.club') }}</label>
<span>{{ account.clubName }} ({{ account.clubId }}{{ account.fedNickname ? ' - ' + account.fedNickname : '' }})</span>
</div>
<div class="info-row" v-if="account.lastLoginSuccess">
<label>{{ $t('myTischtennisAccount.lastSuccessfulLogin') }}</label>
<span>{{ formatDate(account.lastLoginSuccess) }}</span>
</div>
<div class="info-row" v-if="account.lastLoginAttempt">
<label>{{ $t('myTischtennisAccount.lastLoginAttempt') }}</label>
<span>{{ formatDate(account.lastLoginAttempt) }}</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-danger" @click="deleteAccount">{{ $t('myTischtennisAccount.unlinkAccount') }}</button>
<div class="account-container">
<div v-if="loading" class="loading">{{ $t('myTischtennisAccount.loading') }}</div>
<div v-else-if="account" class="account-info">
<div class="info-section">
<h2>{{ $t('myTischtennisAccount.linkedAccount') }}</h2>
<div class="info-row">
<label>{{ $t('myTischtennisAccount.email') }}</label>
<span>{{ account.email }}</span>
</div>
<div class="info-row">
<label>{{ $t('myTischtennisAccount.passwordSaved') }}</label>
<span>{{ accountStatus && accountStatus.hasPassword ? $t('myTischtennisAccount.yes') : $t('myTischtennisAccount.no') }}</span>
</div>
<div class="info-row" v-if="account.clubId">
<label>{{ $t('myTischtennisAccount.club') }}</label>
<span>{{ account.clubName }} ({{ account.clubId }}{{ account.fedNickname ? ' - ' + account.fedNickname : '' }})</span>
</div>
<div class="info-row" v-if="account.lastLoginSuccess">
<label>{{ $t('myTischtennisAccount.lastSuccessfulLogin') }}</label>
<span>{{ formatDate(account.lastLoginSuccess) }}</span>
</div>
<div class="info-row" v-if="account.lastLoginAttempt">
<label>{{ $t('myTischtennisAccount.lastLoginAttempt') }}</label>
<span>{{ formatDate(account.lastLoginAttempt) }}</span>
</div>
<div class="button-group">
<button class="btn-primary" @click="openEditDialog">{{ $t('myTischtennisAccount.editAccount') }}</button>
<button type="button" class="btn-secondary" @click="testConnection" :disabled="verifyingLogin">
{{ verifyingLogin ? 'Login wird durchgeführt…' : $t('myTischtennisAccount.loginAgain') }}
</button>
<button class="btn-danger" @click="deleteAccount">{{ $t('myTischtennisAccount.unlinkAccount') }}</button>
</div>
<p v-if="loginFeedback.message" class="login-feedback" :class="`login-feedback--${loginFeedback.type}`">
{{ loginFeedback.message }}
</p>
</div>
</div>
</div>
<div v-else class="no-account">
<p>{{ $t('myTischtennisAccount.noAccountLinked') }}</p>
<button class="btn-primary" @click="openEditDialog">{{ $t('myTischtennisAccount.linkAccount') }}</button>
<div v-else class="no-account">
<p>{{ $t('myTischtennisAccount.noAccountLinked') }}</p>
<button class="btn-primary" @click="openEditDialog">{{ $t('myTischtennisAccount.linkAccount') }}</button>
</div>
<div class="info-box">
<h3>{{ $t('myTischtennisAccount.aboutMyTischtennis') }}</h3>
<p>{{ $t('myTischtennisAccount.aboutDescription') }}</p>
<ul>
<li>{{ $t('myTischtennisAccount.aboutFeature1') }}</li>
<li>{{ $t('myTischtennisAccount.aboutFeature2') }}</li>
<li>{{ $t('myTischtennisAccount.aboutFeature3') }}</li>
</ul>
<p><strong>{{ $t('messages.info') }}:</strong> {{ $t('myTischtennisAccount.aboutHint') }}</p>
</div>
</div>
<div class="info-box">
<h3>{{ $t('myTischtennisAccount.aboutMyTischtennis') }}</h3>
<p>{{ $t('myTischtennisAccount.aboutDescription') }}</p>
<ul>
<li>{{ $t('myTischtennisAccount.aboutFeature1') }}</li>
<li>{{ $t('myTischtennisAccount.aboutFeature2') }}</li>
<li>{{ $t('myTischtennisAccount.aboutFeature3') }}</li>
</ul>
<p><strong>{{ $t('messages.info') }}:</strong> {{ $t('myTischtennisAccount.aboutHint') }}</p>
</div>
<!-- Edit Dialog -->
<MyTischtennisDialog
v-if="showDialog"
:account="account"
:login-mode="loginMode"
@close="closeDialog"
@saved="onAccountSaved"
@logged-in="onLoggedIn"
/>
</div>
<!-- Edit Dialog -->
<MyTischtennisDialog
v-if="showDialog"
:account="account"
:login-mode="loginMode"
@close="closeDialog"
@saved="onAccountSaved"
@logged-in="onLoggedIn"
/>
</div>
<!-- Info Dialog -->
<InfoDialog
v-model="infoDialog.isOpen"
@@ -91,6 +95,7 @@
@confirm="handleConfirmResult(true)"
@cancel="handleConfirmResult(false)"
/>
</div>
</template>
<script>
@@ -129,7 +134,12 @@ export default {
account: null,
accountStatus: null,
showDialog: false,
loginMode: false
loginMode: false,
verifyingLogin: false,
loginFeedback: {
type: '',
message: ''
}
};
},
mounted() {
@@ -181,10 +191,12 @@ export default {
console.error('Fehler beim Laden des Accounts:', error);
this.account = null;
this.accountStatus = null;
this.$store.dispatch('showMessage', {
text: this.$t('myTischtennisAccount.errorLoadingAccount'),
type: 'error'
});
await this.showInfo(
this.$t('messages.error'),
this.$t('myTischtennisAccount.errorLoadingAccount'),
'',
'error'
);
} finally {
this.loading = false;
}
@@ -203,25 +215,55 @@ export default {
async onAccountSaved() {
this.closeDialog();
await this.loadAccount();
this.$store.dispatch('showMessage', {
text: this.$t('myTischtennisAccount.accountSaved'),
type: 'success'
});
await this.showInfo(this.$t('messages.success'), this.$t('myTischtennisAccount.accountSaved'), '', 'success');
},
async onLoggedIn() {
this.closeDialog();
await this.loadAccount();
this.$store.dispatch('showMessage', {
text: this.$t('myTischtennisAccount.loginSuccessful'),
type: 'success'
});
await this.showInfo(this.$t('messages.success'), this.$t('myTischtennisAccount.loginSuccessful'), '', 'success');
},
async testConnection() {
// Öffne das Login-Dialog mit vorausgefüllter E-Mail
this.showDialog = true;
this.loginMode = true;
if (this.verifyingLogin) return;
this.verifyingLogin = true;
this.loginFeedback = {
type: 'info',
message: 'Login wird durchgeführt...'
};
try {
// 1-Klick-Re-Login: zuerst gespeicherte Session/Passwort serverseitig verwenden
await apiClient.post('/mytischtennis/verify', {});
await this.loadAccount();
this.loginFeedback = {
type: 'success',
message: 'Login erfolgreich.'
};
await this.showInfo(this.$t('messages.success'), this.$t('myTischtennisAccount.loginSuccessful'), '', 'success');
} catch (error) {
// Falls gespeicherte Daten nicht ausreichen, Passwort-Dialog öffnen
const needsPassword = error?.response?.status === 400;
if (needsPassword) {
this.loginFeedback = {
type: 'error',
message: 'Bitte Passwort eingeben, um den Login erneut durchzuführen.'
};
this.showDialog = true;
this.loginMode = true;
this.verifyingLogin = false;
return;
}
const message = getSafeErrorMessage(error, this.$t('myTischtennisAccount.errorLoadingAccount'));
this.loginFeedback = {
type: 'error',
message
};
await this.showInfo(this.$t('messages.error'), message, '', 'error');
} finally {
this.verifyingLogin = false;
}
},
async deleteAccount() {
@@ -406,6 +448,28 @@ h1 {
background-color: #545b62;
}
.btn-secondary:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.login-feedback {
margin-top: 0.75rem;
font-size: 0.95rem;
}
.login-feedback--info {
color: #0c5460;
}
.login-feedback--success {
color: #155724;
}
.login-feedback--error {
color: #721c24;
}
/* Fetch Statistics */
.fetch-stats-section {
margin-top: 2rem;

View File

@@ -608,13 +608,13 @@ export default {
},
activeAssignmentClassLabel() {
if (this.activeAssignmentClassId === undefined) {
return this.$t('tournaments.selectClassPrompt');
}
let label = this.$t('tournaments.selectClassPrompt');
if (this.activeAssignmentClassId === null) {
return this.$t('tournaments.withoutClass');
label = this.$t('tournaments.withoutClass');
} else if (this.activeAssignmentClassId !== undefined) {
label = this.getClassName(this.activeAssignmentClassId) || this.$t('tournaments.unknown');
}
return this.getClassName(this.activeAssignmentClassId) || this.$t('tournaments.unknown');
return label;
},
canAssignClass() {