feat(server, models, services, frontend): integrate Click-TT account functionality
- Added ClickTtAccount model and integrated it into the server and database synchronization processes. - Updated ClickTtPlayerRegistrationService to utilize ClickTtAccount for user account management, enhancing the registration flow. - Modified frontend components to include navigation to Click-TT account settings and updated routing to support the new account view. - Improved error handling and user feedback in the registration process, ensuring clarity in account-related operations.
This commit is contained in:
191
backend/services/clickTtAccountService.js
Normal file
191
backend/services/clickTtAccountService.js
Normal file
@@ -0,0 +1,191 @@
|
||||
import { chromium } from 'playwright';
|
||||
import User from '../models/User.js';
|
||||
import ClickTtAccount from '../models/ClickTtAccount.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
|
||||
const CLICKTT_LOGIN_URL = 'https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaTTDE.woa/wa/login?federation=HeTTV®ion=DE';
|
||||
|
||||
class ClickTtAccountService {
|
||||
async getAccount(userId) {
|
||||
const account = await ClickTtAccount.findOne({ where: { userId } });
|
||||
if (!account) return null;
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
userId: account.userId,
|
||||
username: account.username,
|
||||
savePassword: account.savePassword,
|
||||
lastLoginAttempt: account.lastLoginAttempt,
|
||||
lastLoginSuccess: account.lastLoginSuccess,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
async checkAccountStatus(userId) {
|
||||
const account = await ClickTtAccount.findOne({ where: { userId } });
|
||||
return {
|
||||
exists: !!account,
|
||||
hasUsername: !!account?.username,
|
||||
hasPassword: !!(account?.savePassword && account?.encryptedPassword),
|
||||
hasValidSession: !!account?.playwrightStorageState,
|
||||
needsConfiguration: !account || !account.username,
|
||||
needsPassword: !!account && (!account.savePassword || !account.encryptedPassword)
|
||||
};
|
||||
}
|
||||
|
||||
async upsertAccount(userId, username, password, savePassword, userPassword) {
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
throw new HttpError({ code: 'ERROR_USER_NOT_FOUND' }, 404);
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const isValidPassword = await user.validatePassword(userPassword);
|
||||
if (!isValidPassword) {
|
||||
throw new HttpError('Ungültiges Passwort', 401);
|
||||
}
|
||||
}
|
||||
|
||||
let account = await ClickTtAccount.findOne({ where: { userId } });
|
||||
const now = new Date();
|
||||
|
||||
if (account) {
|
||||
account.username = username;
|
||||
account.savePassword = savePassword;
|
||||
account.lastLoginAttempt = password ? now : account.lastLoginAttempt;
|
||||
|
||||
if (password && savePassword) {
|
||||
account.setPassword(password);
|
||||
} else if (!savePassword) {
|
||||
account.encryptedPassword = null;
|
||||
}
|
||||
} else {
|
||||
account = await ClickTtAccount.create({
|
||||
userId,
|
||||
username,
|
||||
savePassword,
|
||||
lastLoginAttempt: password ? now : null
|
||||
});
|
||||
if (password && savePassword) {
|
||||
account.setPassword(password);
|
||||
}
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const loginResult = await this._loginWithBrowserAutomation(username, password, account.playwrightStorageState ?? null);
|
||||
account.lastLoginSuccess = now;
|
||||
account.playwrightStorageState = loginResult.storageState;
|
||||
}
|
||||
|
||||
await account.save();
|
||||
|
||||
return this.getAccount(userId);
|
||||
}
|
||||
|
||||
async deleteAccount(userId) {
|
||||
const deleted = await ClickTtAccount.destroy({ where: { userId } });
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
async verifyLogin(userId, providedPassword = null) {
|
||||
const account = await ClickTtAccount.findOne({ where: { userId } });
|
||||
if (!account) {
|
||||
throw new HttpError('Kein HTTV-/click-TT-Account verknüpft', 404);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
account.lastLoginAttempt = now;
|
||||
|
||||
let password = providedPassword;
|
||||
if (!password && account.savePassword) {
|
||||
password = account.getPassword();
|
||||
}
|
||||
|
||||
const loginResult = await this._loginWithBrowserAutomation(
|
||||
account.username,
|
||||
password,
|
||||
account.playwrightStorageState ?? null
|
||||
);
|
||||
|
||||
account.lastLoginSuccess = now;
|
||||
account.playwrightStorageState = loginResult.storageState;
|
||||
await account.save();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async _loginWithBrowserAutomation(username, password, savedStorageState = null) {
|
||||
let browser;
|
||||
let context;
|
||||
let page;
|
||||
|
||||
try {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage']
|
||||
});
|
||||
context = savedStorageState
|
||||
? await browser.newContext({ storageState: savedStorageState })
|
||||
: await browser.newContext();
|
||||
page = await context.newPage();
|
||||
|
||||
await page.goto(CLICKTT_LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: 45000 });
|
||||
|
||||
const alreadyLoggedIn = await this._isLoggedIn(page);
|
||||
if (!alreadyLoggedIn) {
|
||||
if (!password) {
|
||||
throw new HttpError('Kein Passwort gespeichert. Bitte HTTV-/click-TT-Passwort hinterlegen.', 400);
|
||||
}
|
||||
|
||||
const userField = page.locator('input[name="username"], input[name="email"], input[type="email"], input[autocomplete="username"]').first();
|
||||
const passwordField = page.locator('input[name="password"], input[type="password"]').first();
|
||||
|
||||
await userField.fill(username, { timeout: 10000 });
|
||||
await passwordField.fill(password, { timeout: 10000 });
|
||||
|
||||
const submitButton = page.locator('button[type="submit"], input[type="submit"]').first();
|
||||
await submitButton.click();
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 60000 });
|
||||
|
||||
if (!(await this._isLoggedIn(page))) {
|
||||
const text = await page.locator('body').innerText().catch(() => '');
|
||||
throw new HttpError(`HTTV-/click-TT-Login fehlgeschlagen: ${String(text || '').replace(/\s+/g, ' ').trim().slice(0, 400)}`, 401);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
storageState: await context.storageState()
|
||||
};
|
||||
} finally {
|
||||
if (context) {
|
||||
try { await context.close(); } catch (_err) {}
|
||||
}
|
||||
if (browser) {
|
||||
try { await browser.close(); } catch (_err) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _isLoggedIn(page) {
|
||||
const currentUrl = page.url();
|
||||
if (/ttde-id\.liga\.nu\/oauth2/.test(currentUrl)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const logoutLink = page.getByRole('link', { name: /abmelden|logout/i }).first();
|
||||
if (await logoutLink.count()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const applicationButton = page.locator('input[type="submit"][value*="Spielberechtigungen beantragen"], a:has-text("Spielberechtigungen beantragen")').first();
|
||||
if (await applicationButton.count()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /click-tt\.de/.test(currentUrl) && !/oauth2/.test(currentUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClickTtAccountService();
|
||||
@@ -1,6 +1,6 @@
|
||||
import { chromium } from 'playwright';
|
||||
import Member from '../models/Member.js';
|
||||
import MyTischtennis from '../models/MyTischtennis.js';
|
||||
import ClickTtAccount from '../models/ClickTtAccount.js';
|
||||
import { checkAccess } from '../utils/userUtils.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
|
||||
@@ -46,18 +46,18 @@ class ClickTtPlayerRegistrationService {
|
||||
throw new HttpError('Mitglied nicht gefunden', 404);
|
||||
}
|
||||
|
||||
const account = await MyTischtennis.findOne({ where: { userId } });
|
||||
if (!account?.email) {
|
||||
throw new HttpError('Kein myTischtennis-/TTDE-Account für diesen Benutzer hinterlegt', 400);
|
||||
const account = await ClickTtAccount.findOne({ where: { userId } });
|
||||
if (!account?.username) {
|
||||
throw new HttpError('Kein HTTV-/click-TT-Account für diesen Benutzer hinterlegt', 400);
|
||||
}
|
||||
|
||||
const password = account.getPassword?.() || null;
|
||||
const savedStorageState = account.playwrightStorageState ?? null;
|
||||
if (!savedStorageState && !password) {
|
||||
throw new HttpError('Für die Click-TT-Automatisierung wird ein gespeichertes myTischtennis-Passwort oder eine gültige Browser-Session benötigt', 400);
|
||||
throw new HttpError('Für die Click-TT-Automatisierung wird ein gespeichertes HTTV-/click-TT-Passwort oder eine gültige Browser-Session benötigt', 400);
|
||||
}
|
||||
|
||||
const email = account.email;
|
||||
const username = account.username;
|
||||
const memberJson = member.toJSON();
|
||||
const primaryEmail = this._getPrimaryContactValue(memberJson, 'email') || memberJson.email || '';
|
||||
|
||||
@@ -81,7 +81,7 @@ class ClickTtPlayerRegistrationService {
|
||||
page = await context.newPage();
|
||||
this._attachNetworkLogging(page, trace);
|
||||
|
||||
await this._openAuthenticatedClickTt(page, { email, password, trace });
|
||||
await this._openAuthenticatedClickTt(page, { username, password, trace });
|
||||
await this._clickByText(page, 'Spielberechtigungen beantragen', trace);
|
||||
await this._fillSearchForm(page, memberJson);
|
||||
await this._clickByText(page, 'Personen suchen', trace);
|
||||
@@ -216,20 +216,20 @@ class ClickTtPlayerRegistrationService {
|
||||
});
|
||||
}
|
||||
|
||||
async _openAuthenticatedClickTt(page, { email, password, trace }) {
|
||||
async _openAuthenticatedClickTt(page, { username, password, trace }) {
|
||||
this._trace(trace, 'step', { name: 'open-entry', url: CLICKTT_ENTRY_URL });
|
||||
await page.goto(CLICKTT_ENTRY_URL, { waitUntil: 'domcontentloaded', timeout: 45000 });
|
||||
|
||||
await this._dismissConsentOverlays(page, trace);
|
||||
|
||||
const directLoginOrAuth = page.locator('input[name="email"], input[name="password"]').first();
|
||||
const directLoginOrAuth = page.locator('input[name="email"], input[name="username"], input[name="password"]').first();
|
||||
if (!(await directLoginOrAuth.count())) {
|
||||
this._trace(trace, 'step', { name: 'open-login-entry', url: CLICKTT_LOGIN_URL });
|
||||
await page.goto(CLICKTT_LOGIN_URL, { waitUntil: 'domcontentloaded', timeout: 45000 });
|
||||
await this._dismissConsentOverlays(page, trace);
|
||||
}
|
||||
|
||||
const needsLogin = await page.locator('input[name="email"], input[name="password"]').count();
|
||||
const needsLogin = await page.locator('input[name="email"], input[name="username"], input[name="password"]').count();
|
||||
if (!needsLogin) {
|
||||
this._trace(trace, 'step', { name: 'session-restored', url: page.url() });
|
||||
await page.waitForURL(/click-tt\.de/, { timeout: 45000 });
|
||||
@@ -241,8 +241,27 @@ class ClickTtPlayerRegistrationService {
|
||||
}
|
||||
|
||||
this._trace(trace, 'step', { name: 'fill-login-form' });
|
||||
await page.locator('input[name="email"]').first().fill(email);
|
||||
await page.locator('input[name="password"]').first().fill(password);
|
||||
const userFieldFilled = await this._fillFirstAvailable(page, [
|
||||
'input[name="email"]',
|
||||
'input[name="username"]',
|
||||
'input[type="email"]',
|
||||
'input[autocomplete="username"]',
|
||||
'input[placeholder*="Username"]',
|
||||
'input[placeholder*="E-Mail"]',
|
||||
'input[placeholder*="Email"]'
|
||||
], username);
|
||||
if (!userFieldFilled) {
|
||||
throw new HttpError('TTDE-Loginfeld fuer Benutzername/E-Mail nicht gefunden', 500);
|
||||
}
|
||||
|
||||
const passwordFieldFilled = await this._fillFirstAvailable(page, [
|
||||
'input[name="password"]',
|
||||
'input[type="password"]',
|
||||
'input[autocomplete="current-password"]'
|
||||
], password);
|
||||
if (!passwordFieldFilled) {
|
||||
throw new HttpError('TTDE-Passwortfeld nicht gefunden', 500);
|
||||
}
|
||||
|
||||
const submitLocator = page.locator('button[type="submit"], input[type="submit"]').first();
|
||||
this._trace(trace, 'step', { name: 'submit-login' });
|
||||
|
||||
Reference in New Issue
Block a user