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

@@ -1,4 +1,5 @@
import axios from 'axios';
import { chromium } from 'playwright';
const BASE_URL = 'https://www.mytischtennis.de';
@@ -24,36 +25,136 @@ class MyTischtennisClient {
async getLoginPage() {
try {
const response = await this.client.get('/login?next=%2F');
const html = response.data;
const html = typeof response.data === 'string' ? response.data : String(response.data || '');
const extractFirst = (patterns) => {
for (const pattern of patterns) {
const match = html.match(pattern);
if (match && (match[1] || match[2] || match[3])) {
return match[1] || match[2] || match[3];
}
}
return null;
};
// Parse form action and input fields for frontend login-form endpoint
const formMatch = html.match(/<form[^>]*action=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/form>/i);
const loginAction = formMatch ? (formMatch[1] || formMatch[2] || '/login') : '/login';
const formHtml = formMatch ? formMatch[3] : html;
const fields = [];
const inputRegex = /<input\b([\s\S]*?)>/gi;
let inputMatch = null;
while ((inputMatch = inputRegex.exec(formHtml)) !== null) {
const rawAttributes = inputMatch[1] || '';
const attributes = {};
// Parses key="value", key='value', key=value and boolean attributes.
const attributeRegex = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
let attributeMatch = null;
while ((attributeMatch = attributeRegex.exec(rawAttributes)) !== null) {
const key = attributeMatch[1];
const value = attributeMatch[2] ?? attributeMatch[3] ?? attributeMatch[4] ?? true;
attributes[key] = value;
}
fields.push({
name: typeof attributes.name === 'string' ? attributes.name : null,
id: typeof attributes.id === 'string' ? attributes.id : null,
type: typeof attributes.type === 'string' ? attributes.type : 'text',
placeholder: typeof attributes.placeholder === 'string' ? attributes.placeholder : null,
autocomplete: typeof attributes.autocomplete === 'string' ? attributes.autocomplete : null,
minlength: typeof attributes.minlength === 'string' ? attributes.minlength : null,
required: attributes.required === true || attributes.required === 'required',
value: typeof attributes.value === 'string' ? attributes.value : null
});
}
// Fallback: if page is JS-rendered and no input tags are server-rendered, provide usable defaults.
const hasEmailField = fields.some((f) => f?.name === 'email' || f?.type === 'email');
const hasPasswordField = fields.some((f) => f?.name === 'password' || f?.type === 'password');
if (!hasEmailField) {
fields.push({
name: 'email',
id: null,
type: 'email',
placeholder: null,
autocomplete: 'email',
minlength: null,
required: true,
value: null
});
}
if (!hasPasswordField) {
fields.push({
name: 'password',
id: null,
type: 'password',
placeholder: null,
autocomplete: 'current-password',
minlength: null,
required: true,
value: null
});
}
// Extract XSRF token from hidden input
const xsrfMatch = html.match(/<input[^>]*name="xsrf"[^>]*value="([^"]+)"/);
const xsrfToken = xsrfMatch ? xsrfMatch[1] : null;
const xsrfToken = extractFirst([
/<input[^>]*name=(?:"xsrf"|'xsrf')[^>]*value=(?:"([^"]+)"|'([^']+)')/i,
/(?:^|[,{])\s*"xsrf"\s*:\s*"([^"]+)"/i
]);
// Extract CAPTCHA token from hidden input (if present)
const captchaMatch = html.match(/<input[^>]*name="captcha"[^>]*value="([^"]+)"/);
const captchaToken = captchaMatch ? captchaMatch[1] : null;
const captchaToken = extractFirst([
/<input[^>]*name=(?:"captcha"|'captcha')[^>]*value=(?:"([^"]+)"|'([^']+)')/i,
/(?:^|[,{])\s*"captcha"\s*:\s*"([^"]+)"/i
]);
// Check if captcha_clicked is true or false
const captchaClickedMatch = html.match(/<input[^>]*name="captcha_clicked"[^>]*value="([^"]+)"/);
const captchaClicked = captchaClickedMatch ? captchaClickedMatch[1] === 'true' : false;
const captchaClickedRaw = extractFirst([
/<input[^>]*name=(?:"captcha_clicked"|'captcha_clicked')[^>]*value=(?:"([^"]+)"|'([^']+)')/i,
/(?:^|[,{])\s*"captcha_clicked"\s*:\s*"([^"]+)"/i
]);
const captchaClicked = String(captchaClickedRaw || '').toLowerCase() === 'true';
// Check if CAPTCHA is required (look for private-captcha element or captcha input)
const requiresCaptcha = html.includes('private-captcha') || html.includes('name="captcha"');
const requiresCaptcha = html.includes('private-captcha')
|| html.includes('name="captcha"')
|| html.includes("name='captcha'")
|| /captcha/i.test(html);
// Extract CAPTCHA metadata used by frontend
const captchaSiteKey = extractFirst([
/data-sitekey=(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i,
/(?:^|[,{])\s*"sitekey"\s*:\s*"([^"]+)"/i,
/(?:^|[,{])\s*"captchaSiteKey"\s*:\s*"([^"]+)"/i
]);
const captchaPuzzleEndpoint = extractFirst([
/data-puzzle-endpoint=(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i,
/(?:^|[,{])\s*"puzzle_endpoint"\s*:\s*"([^"]+)"/i,
/(?:^|[,{])\s*"captchaPuzzleEndpoint"\s*:\s*"([^"]+)"/i
]);
console.log('[myTischtennisClient.getLoginPage]', {
hasXsrfToken: !!xsrfToken,
hasCaptchaToken: !!captchaToken,
captchaClicked,
requiresCaptcha
requiresCaptcha,
fieldsCount: fields.length,
hasCaptchaSiteKey: !!captchaSiteKey,
hasCaptchaPuzzleEndpoint: !!captchaPuzzleEndpoint
});
return {
success: true,
loginAction,
fields,
xsrfToken,
captchaToken,
captchaClicked,
requiresCaptcha
requiresCaptcha,
captchaSiteKey,
captchaPuzzleEndpoint
};
} catch (error) {
console.error('Error fetching login page:', error.message);
@@ -247,6 +348,202 @@ class MyTischtennisClient {
}
}
/**
* Browser-based fallback login for CAPTCHA flows.
* @param {string} email
* @param {string} password
* @returns {Promise<Object>} Login response with token and session data
*/
async loginWithBrowserAutomation(email, password) {
let browser = null;
let context = null;
try {
console.log('[myTischtennisClient.playwright] Start browser login flow');
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--disable-dev-shm-usage']
});
context = await browser.newContext();
const page = await context.newPage();
await page.goto(`${this.baseURL}/login?next=%2F`, { waitUntil: 'domcontentloaded', timeout: 45000 });
console.log('[myTischtennisClient.playwright] Page loaded');
// Best-effort: Consent/overlay dialogs that can block form interaction.
const consentSelectors = [
'#onetrust-accept-btn-handler',
'button:has-text("Alle akzeptieren")',
'button:has-text("Akzeptieren")',
'button:has-text("Einverstanden")'
];
for (const selector of consentSelectors) {
try {
const button = page.locator(selector).first();
if (await button.count()) {
await button.click({ timeout: 1500 });
console.log('[myTischtennisClient.playwright] Consent dialog accepted');
break;
}
} catch (_e) {
// ignore and try next selector
}
}
// Fill credentials
await page.locator('input[name="email"]').first().fill(email, { timeout: 10000 });
await page.locator('input[name="password"]').first().fill(password, { timeout: 10000 });
console.log('[myTischtennisClient.playwright] Credentials filled');
// Try to interact with private-captcha if present.
const captchaHost = page.locator('private-captcha').first();
if (await captchaHost.count()) {
try {
await page.waitForTimeout(1200);
const interaction = await page.evaluate(() => {
const host = document.querySelector('private-captcha');
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox');
if (!checkbox) {
return { clicked: false, reason: 'checkbox-missing' };
}
checkbox.click();
checkbox.dispatchEvent(new Event('input', { bubbles: true }));
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
return {
clicked: true,
viaShadowRoot: true,
className: checkbox.className || null,
checked: !!checkbox.checked
};
});
console.log('[myTischtennisClient.playwright] evaluate interaction result:', interaction);
// Wait until hidden captcha fields are populated by site scripts.
try {
await page.waitForFunction(() => {
const captchaField = document.querySelector('input[name="captcha"]');
const clickedField = document.querySelector('input[name="captcha_clicked"]');
const captchaValue = (captchaField && captchaField.value ? captchaField.value.trim() : '');
const clickedValue = (clickedField && clickedField.value ? clickedField.value.toLowerCase() : '');
return captchaValue.length > 80 && (clickedValue === 'true' || clickedValue === '1');
}, { timeout: 15000 });
const captchaState = await page.evaluate(() => {
const captchaField = document.querySelector('input[name="captcha"]');
const clickedField = document.querySelector('input[name="captcha_clicked"]');
return {
captchaLen: captchaField?.value?.length || 0,
captchaClicked: clickedField?.value || null
};
});
console.log('[myTischtennisClient.playwright] Captcha value ready:', captchaState);
} catch (_waitErr) {
// Keep going; some flows still succeed without explicit hidden field update.
console.warn('[myTischtennisClient.playwright] Captcha value not ready in time');
}
} catch (captchaError) {
console.warn('[myTischtennisClient.playwright] Captcha interaction warning:', captchaError?.message || captchaError);
}
}
// Ensure captcha_clicked field is set if available.
await page.evaluate(() => {
const clickedField = document.querySelector('input[name="captcha_clicked"]');
if (clickedField && !clickedField.value) {
clickedField.value = 'true';
}
});
// Submit form
const submitButton = page.locator('button[type="submit"], input[type="submit"]').first();
if (await submitButton.count()) {
await submitButton.click({ timeout: 15000, noWaitAfter: true });
} else {
await page.keyboard.press('Enter');
}
console.log('[myTischtennisClient.playwright] Submit clicked');
// Wait for auth cookie after submit (polling avoids timing races).
let authCookieObj = null;
const maxAttempts = 20;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const cookies = await context.cookies();
authCookieObj = cookies.find((c) => c.name === 'sb-10-auth-token');
if (authCookieObj?.value) {
break;
}
await page.waitForTimeout(500);
}
if (!authCookieObj || !authCookieObj.value) {
let errorText = null;
try {
const textContent = await page.locator('body').innerText({ timeout: 1000 });
if (textContent?.includes('Captcha-Bestätigung fehlgeschlagen')) {
errorText = 'Captcha-Bestätigung fehlgeschlagen';
}
} catch (_e) {
// ignore text read errors
}
return {
success: false,
error: errorText
? `Playwright-Login fehlgeschlagen: ${errorText}`
: 'Playwright-Login fehlgeschlagen: Kein sb-10-auth-token Cookie gefunden'
};
}
// Cookie value is expected as "base64-<tokenData>"
const tokenMatch = String(authCookieObj.value).match(/^base64-(.+)$/);
if (!tokenMatch) {
return {
success: false,
error: 'Playwright-Login fehlgeschlagen: Token-Format ungültig'
};
}
let tokenData;
try {
tokenData = JSON.parse(Buffer.from(tokenMatch[1], 'base64').toString('utf-8'));
} catch (decodeError) {
return {
success: false,
error: `Playwright-Login fehlgeschlagen: Token konnte nicht dekodiert werden (${decodeError.message})`
};
}
const cookie = `sb-10-auth-token=${authCookieObj.value}`;
console.log('[myTischtennisClient.playwright] Browser login successful');
return {
success: true,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token,
expiresAt: tokenData.expires_at,
expiresIn: tokenData.expires_in,
user: tokenData.user,
cookie
};
} catch (error) {
console.error('[myTischtennisClient.playwright] Browser login failed:', error?.message || error);
return {
success: false,
error: error?.message || 'Playwright-Login fehlgeschlagen'
};
} finally {
if (context) {
try {
await context.close();
} catch (contextCloseError) {
console.warn('[myTischtennisClient.playwright] Context close warning:', contextCloseError?.message || contextCloseError);
}
}
if (browser) {
try {
await browser.close();
} catch (browserCloseError) {
console.warn('[myTischtennisClient.playwright] Browser close warning:', browserCloseError?.message || browserCloseError);
}
console.log('[myTischtennisClient.playwright] Browser closed');
}
}
}
/**
* Verify login credentials
* @param {string} email - myTischtennis email
@@ -411,4 +708,3 @@ class MyTischtennisClient {
}
export default new MyTischtennisClient();

View File

@@ -1,6 +1,52 @@
import myTischtennisService from '../services/myTischtennisService.js';
import HttpError from '../exceptions/HttpError.js';
import axios from 'axios';
import myTischtennisClient from '../clients/myTischtennisClient.js';
const MYTT_ORIGIN = 'https://www.mytischtennis.de';
const MYTT_PROXY_PREFIX = '/api/mytischtennis/proxy';
function rewriteMytischtennisContent(content) {
if (typeof content !== 'string' || !content) {
return content;
}
let rewritten = content;
// Root-relative Build/Fonts über unseren Same-Origin-Proxy laden.
rewritten = rewritten.replace(
/(["'])\/build\//g,
`$1${MYTT_PROXY_PREFIX}/build/`
);
rewritten = rewritten.replace(
/(["'])\/fonts\//g,
`$1${MYTT_PROXY_PREFIX}/fonts/`
);
// Absolute Build/Fonts-URLs ebenfalls auf den Proxy biegen.
rewritten = rewritten.replace(
/https:\/\/www\.mytischtennis\.de\/build\//g,
`${MYTT_PROXY_PREFIX}/build/`
);
rewritten = rewritten.replace(
/https:\/\/www\.mytischtennis\.de\/fonts\//g,
`${MYTT_PROXY_PREFIX}/fonts/`
);
// CSS url(/fonts/...) Fälle.
rewritten = rewritten.replace(
/url\((["']?)\/fonts\//g,
`url($1${MYTT_PROXY_PREFIX}/fonts/`
);
// Captcha-Endpunkt muss ebenfalls same-origin über Proxy erreichbar sein.
rewritten = rewritten.replace(
/(["'])\/api\/private-captcha/g,
`$1${MYTT_PROXY_PREFIX}/api/private-captcha`
);
return rewritten;
}
class MyTischtennisController {
/**
@@ -36,6 +82,49 @@ class MyTischtennisController {
}
}
/**
* GET /api/mytischtennis/login-form
* Parsed login form data from mytischtennis.de
*/
async getLoginForm(req, res, next) {
try {
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
const result = await myTischtennisClient.getLoginPage();
if (!result.success) {
throw new HttpError('Login-Formular konnte nicht geladen werden', 502);
}
const publicFields = (result.fields || [])
.filter((field) => ['email', 'password'].includes(field.type) || field.name === 'email' || field.name === 'password')
.map((field) => ({
name: field.name,
id: field.id,
type: field.type,
placeholder: field.placeholder || null,
required: !!field.required,
autocomplete: field.autocomplete || null,
minlength: field.minlength ? Number(field.minlength) : null
}));
res.status(200).json({
success: true,
form: {
action: result.loginAction,
fields: publicFields
},
captcha: {
required: !!result.requiresCaptcha,
siteKey: result.captchaSiteKey || null,
puzzleEndpoint: result.captchaPuzzleEndpoint || null,
solutionField: result.captchaSolutionField || 'captcha'
}
});
} catch (error) {
next(error);
}
}
/**
* POST /api/mytischtennis/account
* Create or update myTischtennis account
@@ -43,7 +132,9 @@ class MyTischtennisController {
async upsertAccount(req, res, next) {
try {
const userId = req.user.id;
const { email, password, savePassword, autoUpdateRatings, userPassword } = req.body;
const { email, password, savePassword, userPassword } = req.body;
const hasAutoUpdateRatings = Object.prototype.hasOwnProperty.call(req.body, 'autoUpdateRatings');
const autoUpdateRatings = hasAutoUpdateRatings ? req.body.autoUpdateRatings : undefined;
if (!email) {
throw new HttpError('E-Mail-Adresse erforderlich', 400);
@@ -59,7 +150,7 @@ class MyTischtennisController {
email,
password,
savePassword || false,
autoUpdateRatings || false,
autoUpdateRatings,
userPassword
);
@@ -226,7 +317,7 @@ class MyTischtennisController {
req.userId = userId;
// Lade die Login-Seite von mytischtennis.de
const response = await axios.get('https://www.mytischtennis.de/login?next=%2F', {
const response = await axios.get(`${MYTT_ORIGIN}/login?next=%2F`, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
@@ -262,6 +353,14 @@ class MyTischtennisController {
/action="\/login/g,
'action="/api/mytischtennis/login-submit'
);
html = rewriteMytischtennisContent(html);
// MyTischtennis bootet eine große React-App, die im Proxy-Kontext häufig mit
// Runtime-Fehlern abstürzt ("Da ist etwas schiefgelaufen"). Für den iframe-Login
// reicht die serverseitig gerenderte Form aus; deshalb Bootstrap-Skripte entfernen.
html = html.replace(/<script\b[^>]*type=(?:"|')module(?:"|')[^>]*>[\s\S]*?<\/script>/gi, '');
html = html.replace(/<script\b[^>]*src=(?:"|')[^"']*\/build\/[^"']*(?:"|')[^>]*>\s*<\/script>/gi, '');
html = html.replace(/<link\b[^>]*rel=(?:"|')modulepreload(?:"|')[^>]*>/gi, '');
}
// Setze Content-Type
@@ -275,6 +374,55 @@ class MyTischtennisController {
}
}
/**
* GET /api/mytischtennis/proxy/*
* Same-Origin-Proxy für mytischtennis Build-/Font-/Captcha-Ressourcen
*/
async proxyRemote(req, res, next) {
try {
const proxyPath = req.params[0] || '';
const queryString = new URLSearchParams(req.query || {}).toString();
const targetUrl = `${MYTT_ORIGIN}/${proxyPath}${queryString ? `?${queryString}` : ''}`;
const upstream = await axios.get(targetUrl, {
responseType: 'arraybuffer',
headers: {
'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0',
'Accept': req.headers.accept || '*/*',
'Accept-Language': req.headers['accept-language'] || 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
...(req.headers.cookie ? { 'Cookie': req.headers.cookie } : {})
},
validateStatus: () => true
});
// Wichtige Header durchreichen
const passthroughHeaders = ['content-type', 'cache-control', 'etag', 'last-modified', 'expires'];
for (const headerName of passthroughHeaders) {
const value = upstream.headers[headerName];
if (value) {
res.setHeader(headerName, value);
}
}
if (upstream.headers['set-cookie']) {
res.setHeader('Set-Cookie', upstream.headers['set-cookie']);
}
const contentType = String(upstream.headers['content-type'] || '').toLowerCase();
const isTextLike = /(text\/|javascript|json|xml|svg)/.test(contentType);
if (isTextLike) {
const asText = Buffer.from(upstream.data).toString('utf-8');
const rewritten = rewriteMytischtennisContent(asText);
return res.status(upstream.status).send(rewritten);
}
return res.status(upstream.status).send(upstream.data);
} catch (error) {
console.error('Fehler beim Proxy von mytischtennis-Ressourcen:', error.message);
next(error);
}
}
/**
* POST /api/mytischtennis/login-submit
* Proxy für Login-Form-Submission
@@ -300,14 +448,81 @@ class MyTischtennisController {
if (req.body.__token) {
delete req.body.__token;
}
// Hole Cookies aus dem Request
// Hole Cookies aus dem Request (wird auch für CAPTCHA-Fallback benötigt)
const cookies = req.headers.cookie || '';
// Normalisiere Payload
const payload = { ...(req.body || {}) };
const mask = (v) => (typeof v === 'string' && v.length > 12 ? `${v.slice(0, 12)}...(${v.length})` : v);
// Falls captcha im Browser-Kontext nicht gesetzt wurde, versuche serverseitigen Fallback
if (!payload.captcha) {
try {
const loginPageResponse = await axios.get('https://www.mytischtennis.de/login?next=%2F', {
headers: {
'Cookie': cookies,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
'Referer': 'https://www.mytischtennis.de/'
},
validateStatus: () => true
});
const html = typeof loginPageResponse.data === 'string' ? loginPageResponse.data : '';
const siteKeyMatch = html.match(/data-sitekey=(?:"([^"]+)"|'([^']+)')/i);
const puzzleEndpointMatch = html.match(/data-puzzle-endpoint=(?:"([^"]+)"|'([^']+)')/i);
const siteKey = siteKeyMatch ? (siteKeyMatch[1] || siteKeyMatch[2]) : null;
const puzzleEndpoint = puzzleEndpointMatch ? (puzzleEndpointMatch[1] || puzzleEndpointMatch[2]) : null;
if (siteKey && puzzleEndpoint) {
const puzzleResponse = await axios.get(`${puzzleEndpoint}?sitekey=${encodeURIComponent(siteKey)}`, {
headers: {
'Cookie': cookies,
'Accept': '*/*',
'Origin': 'https://www.mytischtennis.de',
'Referer': 'https://www.mytischtennis.de/'
},
validateStatus: () => true
});
if (puzzleResponse.status === 200 && typeof puzzleResponse.data === 'string' && puzzleResponse.data.trim()) {
payload.captcha = puzzleResponse.data.trim();
payload.captcha_clicked = 'true';
}
}
} catch (captchaFallbackError) {
console.warn('[submitLogin] CAPTCHA-Fallback fehlgeschlagen:', captchaFallbackError.message);
}
}
// Wenn captcha vorhanden ist, als bestätigt markieren
if (payload.captcha && !payload.captcha_clicked) {
payload.captcha_clicked = 'true';
}
console.log('[submitLogin] Incoming payload fields:', {
keys: Object.keys(payload),
hasEmail: !!payload.email,
hasPassword: !!payload.password,
xsrf: mask(payload.xsrf),
captchaClicked: payload.captcha_clicked,
captcha: mask(payload.captcha)
});
// Form-Daten sauber als x-www-form-urlencoded serialisieren
const formData = new URLSearchParams();
for (const [key, value] of Object.entries(payload)) {
if (value !== undefined && value !== null) {
formData.append(key, String(value));
}
}
// Leite den Login-Request an mytischtennis.de weiter
const response = await axios.post(
'https://www.mytischtennis.de/login?next=%2F&_data=routes%2F_auth%2B%2Flogin',
req.body, // Form-Daten
formData.toString(),
{
headers: {
'Cookie': cookies,
@@ -321,6 +536,34 @@ class MyTischtennisController {
}
);
console.log('[submitLogin] Upstream response:', {
status: response.status,
hasSetCookie: Array.isArray(response.headers['set-cookie']) && response.headers['set-cookie'].length > 0,
bodyPreview: typeof response.data === 'string'
? response.data.slice(0, 220)
: JSON.stringify(response.data || {}).slice(0, 220)
});
// Falls CAPTCHA-Bestätigung im Proxy-Flow fehlschlägt:
// Fallback auf echten Browser-Login (Playwright), dann Session direkt speichern.
const upstreamBody = typeof response.data === 'string' ? response.data : JSON.stringify(response.data || {});
const isCaptchaFailure = response.status === 400
&& (upstreamBody.includes('Captcha-Bestätigung fehlgeschlagen') || upstreamBody.includes('Captcha-Bestätigung ist erforderlich'));
if (isCaptchaFailure && userId && payload.email && payload.password) {
console.log('[submitLogin] CAPTCHA-Fehler erkannt, starte Playwright-Fallback...');
const browserLogin = await myTischtennisClient.loginWithBrowserAutomation(payload.email, payload.password);
if (browserLogin.success && browserLogin.cookie) {
await this.saveSessionFromCookie(userId, browserLogin.cookie);
return res.status(200).send(
'<!doctype html><html><body><p>Login erfolgreich. Fenster kann geschlossen werden.</p></body></html>'
);
}
console.warn('[submitLogin] Playwright-Fallback fehlgeschlagen:', browserLogin.error);
}
// Setze Cookies aus der Response
const setCookieHeaders = response.headers['set-cookie'];
if (setCookieHeaders) {

View File

@@ -27,6 +27,7 @@
"nodemailer": "^7.0.9",
"pdf-parse": "^1.1.1",
"pdfjs-dist": "^5.4.394",
"playwright": "^1.58.2",
"sequelize": "^6.37.3",
"sharp": "^0.33.5",
"socket.io": "^4.8.1"
@@ -3275,6 +3276,50 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",

View File

@@ -31,6 +31,7 @@
"nodemailer": "^7.0.9",
"pdf-parse": "^1.1.1",
"pdfjs-dist": "^5.4.394",
"playwright": "^1.58.2",
"sequelize": "^6.37.3",
"sharp": "^0.33.5",
"socket.io": "^4.8.1"

View File

@@ -10,6 +10,9 @@ const router = express.Router();
// GET /api/mytischtennis/login-page - Proxy für Login-Seite (für iframe)
router.get('/login-page', myTischtennisController.getLoginPage);
// GET /api/mytischtennis/proxy/* - Same-Origin-Proxy für mytischtennis Assets/APIs
router.get('/proxy/*', myTischtennisController.proxyRemote);
// POST /api/mytischtennis/login-submit - Proxy für Login-Form-Submission
router.post('/login-submit', myTischtennisController.submitLogin);
@@ -25,6 +28,9 @@ router.get('/account', myTischtennisController.getAccount);
// GET /api/mytischtennis/status - Check status (alle dürfen lesen)
router.get('/status', myTischtennisController.getStatus);
// GET /api/mytischtennis/login-form - Parse mytischtennis Login-Form
router.get('/login-form', myTischtennisController.getLoginForm);
// POST /api/mytischtennis/account - Create or update account (alle dürfen bearbeiten)
router.post('/account', myTischtennisController.upsertAccount);

View File

@@ -70,6 +70,7 @@ app.use(cors({
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Request Logging Middleware - loggt alle API-Requests
// Wichtig: userId wird später in authMiddleware gesetzt, aber Middleware funktioniert auch ohne

View File

@@ -58,6 +58,15 @@ class MyTischtennisService {
// Login-Versuch bei myTischtennis
loginResult = await myTischtennisClient.login(email, password);
if (!loginResult.success && loginResult.requiresCaptcha) {
console.log('[myTischtennisService.upsertAccount] CAPTCHA-Fehler, versuche Playwright-Fallback...');
const playwrightResult = await myTischtennisClient.loginWithBrowserAutomation(email, password);
if (playwrightResult.success) {
loginResult = playwrightResult;
} else {
console.warn('[myTischtennisService.upsertAccount] Playwright-Fallback fehlgeschlagen:', playwrightResult.error);
}
}
if (!loginResult.success) {
const statusCode = loginResult.requiresCaptcha ? 400 : 401;
const errorMessage = loginResult.error || 'myTischtennis-Login fehlgeschlagen. Bitte überprüfen Sie Ihre Zugangsdaten.';
@@ -74,10 +83,14 @@ class MyTischtennisService {
const now = new Date();
if (account) {
const effectiveAutoUpdateRatings = autoUpdateRatings === undefined
? account.autoUpdateRatings
: Boolean(autoUpdateRatings);
// Update existing
account.email = email;
account.savePassword = savePassword;
account.autoUpdateRatings = autoUpdateRatings;
account.autoUpdateRatings = savePassword ? effectiveAutoUpdateRatings : false;
if (password && savePassword) {
account.setPassword(password);
@@ -115,7 +128,7 @@ class MyTischtennisService {
userId,
email,
savePassword,
autoUpdateRatings,
autoUpdateRatings: savePassword ? Boolean(autoUpdateRatings) : false,
lastLoginAttempt: password ? now : null,
lastLoginSuccess: loginResult?.success ? now : null
};
@@ -225,18 +238,48 @@ class MyTischtennisService {
// Login-Versuch mit Passwort
console.log('[myTischtennisService.verifyLogin] Attempting login for user:', account.email);
const loginResult = await myTischtennisClient.login(account.email, password);
console.log('[myTischtennisService.verifyLogin] Login result:', { success: loginResult.success, error: loginResult.error, requiresCaptcha: loginResult.requiresCaptcha });
let effectiveLoginResult = loginResult;
if (!effectiveLoginResult.success && effectiveLoginResult.requiresCaptcha) {
console.log('[myTischtennisService.verifyLogin] CAPTCHA-Fehler, versuche Playwright-Fallback...');
try {
const playwrightResult = await myTischtennisClient.loginWithBrowserAutomation(account.email, password);
if (playwrightResult.success) {
effectiveLoginResult = playwrightResult;
} else {
console.warn('[myTischtennisService.verifyLogin] Playwright-Fallback fehlgeschlagen:', playwrightResult.error);
effectiveLoginResult = {
success: false,
error: playwrightResult.error || 'Playwright-Fallback fehlgeschlagen',
requiresCaptcha: true,
status: 400
};
}
} catch (playwrightError) {
console.warn('[myTischtennisService.verifyLogin] Playwright-Fallback Exception:', playwrightError?.message || playwrightError);
effectiveLoginResult = {
success: false,
error: `Playwright-Fallback Exception: ${playwrightError?.message || 'Unbekannter Fehler'}`,
requiresCaptcha: true,
status: 400
};
}
}
console.log('[myTischtennisService.verifyLogin] Login result:', {
success: effectiveLoginResult.success,
error: effectiveLoginResult.error,
requiresCaptcha: effectiveLoginResult.requiresCaptcha
});
if (loginResult.success) {
if (effectiveLoginResult.success) {
account.lastLoginSuccess = now;
account.accessToken = loginResult.accessToken;
account.refreshToken = loginResult.refreshToken;
account.expiresAt = loginResult.expiresAt;
account.cookie = loginResult.cookie;
account.userData = loginResult.user;
account.accessToken = effectiveLoginResult.accessToken;
account.refreshToken = effectiveLoginResult.refreshToken;
account.expiresAt = effectiveLoginResult.expiresAt;
account.cookie = effectiveLoginResult.cookie;
account.userData = effectiveLoginResult.user;
// Hole Club-ID und Federation
const profileResult = await myTischtennisClient.getUserProfile(loginResult.cookie);
const profileResult = await myTischtennisClient.getUserProfile(effectiveLoginResult.cookie);
if (profileResult.success) {
account.clubId = profileResult.clubId || account.clubId;
@@ -250,25 +293,31 @@ class MyTischtennisService {
return {
success: true,
accessToken: loginResult.accessToken,
refreshToken: loginResult.refreshToken,
expiresAt: loginResult.expiresAt,
user: loginResult.user,
accessToken: effectiveLoginResult.accessToken,
refreshToken: effectiveLoginResult.refreshToken,
expiresAt: effectiveLoginResult.expiresAt,
user: effectiveLoginResult.user,
clubId: account.clubId,
clubName: account.clubName
};
} else {
// Prevent stale "success" from previously valid sessions after an explicit failed login attempt.
account.accessToken = null;
account.refreshToken = null;
account.cookie = null;
account.expiresAt = null;
account.userData = null;
await account.save(); // Save lastLoginAttempt
const errorMessage = loginResult.error || 'myTischtennis-Login fehlgeschlagen';
const errorMessage = effectiveLoginResult.error || 'myTischtennis-Login fehlgeschlagen';
// Verwende den Status-Code vom myTischtennisClient, falls vorhanden, sonst 401
// Wenn CAPTCHA erforderlich ist, verwende 400 statt 401
const statusCode = loginResult.requiresCaptcha
const statusCode = effectiveLoginResult.requiresCaptcha
? 400
: (loginResult.status && loginResult.status >= 400 && loginResult.status < 600
? loginResult.status
: (effectiveLoginResult.status && effectiveLoginResult.status >= 400 && effectiveLoginResult.status < 600
? effectiveLoginResult.status
: 401);
console.error('[myTischtennisService.verifyLogin] Login failed:', errorMessage, `(Status: ${statusCode})`, loginResult.requiresCaptcha ? '(CAPTCHA erforderlich)' : '');
if (loginResult.requiresCaptcha) {
console.error('[myTischtennisService.verifyLogin] Login failed:', errorMessage, `(Status: ${statusCode})`, effectiveLoginResult.requiresCaptcha ? '(CAPTCHA erforderlich)' : '');
if (effectiveLoginResult.requiresCaptcha) {
throw new HttpError({ code: 'ERROR_MYTISCHTENNIS_CAPTCHA_REQUIRED', params: { message: errorMessage } }, statusCode);
}
throw new HttpError(errorMessage, statusCode);

View File

@@ -115,7 +115,6 @@ class SchedulerService {
/**
* Manually trigger rating updates (for testing)
* HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar
*/
async triggerRatingUpdates() {
devLog('[Scheduler] Manual rating updates trigger called');
@@ -124,7 +123,6 @@ class SchedulerService {
/**
* Manually trigger match results fetch (for testing)
* HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar
*/
async triggerMatchResultsFetch() {
devLog('[Scheduler] Manual match results fetch trigger called');

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() {