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:
Torsten Schulz (local)
2026-03-11 15:47:58 +01:00
parent 2ddb63b932
commit 7196fae28e
11 changed files with 780 additions and 13 deletions

View File

@@ -0,0 +1,72 @@
import clickTtAccountService from '../services/clickTtAccountService.js';
import HttpError from '../exceptions/HttpError.js';
class ClickTtAccountController {
async getAccount(req, res, next) {
try {
const account = await clickTtAccountService.getAccount(req.user.id);
res.status(200).json({ account });
} catch (error) {
next(error);
}
}
async getStatus(req, res, next) {
try {
const status = await clickTtAccountService.checkAccountStatus(req.user.id);
res.status(200).json(status);
} catch (error) {
next(error);
}
}
async upsertAccount(req, res, next) {
try {
const { username, password, savePassword, userPassword } = req.body;
if (!username) {
throw new HttpError('Benutzername erforderlich', 400);
}
if (password && !userPassword) {
throw new HttpError('App-Passwort erforderlich zum Setzen des HTTV-/click-TT-Passworts', 400);
}
const account = await clickTtAccountService.upsertAccount(
req.user.id,
username,
password,
savePassword || false,
userPassword
);
res.status(200).json({
message: 'HTTV-/click-TT-Account erfolgreich gespeichert',
account
});
} catch (error) {
next(error);
}
}
async deleteAccount(req, res, next) {
try {
const deleted = await clickTtAccountService.deleteAccount(req.user.id);
if (!deleted) {
throw new HttpError('Kein HTTV-/click-TT-Account gefunden', 404);
}
res.status(200).json({ message: 'HTTV-/click-TT-Account gelöscht' });
} catch (error) {
next(error);
}
}
async verifyLogin(req, res, next) {
try {
const result = await clickTtAccountService.verifyLogin(req.user.id, req.body.password);
res.status(200).json({ success: true, message: 'Login erfolgreich', ...result });
} catch (error) {
next(error);
}
}
}
export default new ClickTtAccountController();

View File

@@ -0,0 +1,107 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import { encryptData, decryptData } from '../utils/encrypt.js';
const ClickTtAccount = sequelize.define('ClickTtAccount', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
unique: true,
references: {
model: 'user',
key: 'id'
},
onDelete: 'CASCADE',
field: 'user_id'
},
username: {
type: DataTypes.STRING,
allowNull: false,
set(value) {
this.setDataValue('username', encryptData(value));
},
get() {
const encryptedValue = this.getDataValue('username');
return encryptedValue ? decryptData(encryptedValue) : null;
}
},
encryptedPassword: {
type: DataTypes.TEXT('long'),
allowNull: true,
field: 'encrypted_password'
},
savePassword: {
type: DataTypes.BOOLEAN,
defaultValue: false,
allowNull: false,
field: 'save_password'
},
lastLoginAttempt: {
type: DataTypes.DATE,
allowNull: true,
field: 'last_login_attempt'
},
lastLoginSuccess: {
type: DataTypes.DATE,
allowNull: true,
field: 'last_login_success'
},
playwrightStorageState: {
type: DataTypes.TEXT('long'),
allowNull: true,
field: 'playwright_storage_state',
set(value) {
if (value === null || value === undefined) {
this.setDataValue('playwrightStorageState', null);
} else {
const jsonString = typeof value === 'string' ? value : JSON.stringify(value);
this.setDataValue('playwrightStorageState', encryptData(jsonString));
}
},
get() {
const encrypted = this.getDataValue('playwrightStorageState');
if (!encrypted) return null;
try {
return JSON.parse(decryptData(encrypted));
} catch (_err) {
return null;
}
}
}
}, {
underscored: true,
tableName: 'click_tt_account',
timestamps: true,
hooks: {
beforeSave: async (instance) => {
if (!instance.savePassword) {
instance.encryptedPassword = null;
}
}
}
});
ClickTtAccount.prototype.setPassword = function(password) {
if (password && this.savePassword) {
this.encryptedPassword = encryptData(password);
} else {
this.encryptedPassword = null;
}
};
ClickTtAccount.prototype.getPassword = function() {
if (!this.encryptedPassword) return null;
try {
return decryptData(this.encryptedPassword);
} catch (_err) {
return null;
}
};
export default ClickTtAccount;

View File

@@ -41,6 +41,7 @@ import OfficialTournament from './OfficialTournament.js';
import OfficialCompetition from './OfficialCompetition.js';
import OfficialCompetitionMember from './OfficialCompetitionMember.js';
import MyTischtennis from './MyTischtennis.js';
import ClickTtAccount from './ClickTtAccount.js';
import MyTischtennisUpdateHistory from './MyTischtennisUpdateHistory.js';
import MyTischtennisFetchLog from './MyTischtennisFetchLog.js';
import ApiLog from './ApiLog.js';
@@ -322,6 +323,8 @@ DiaryDate.hasMany(Accident, { foreignKey: 'diaryDateId', as: 'accidents' });
User.hasOne(MyTischtennis, { foreignKey: 'userId', as: 'myTischtennis' });
MyTischtennis.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasOne(ClickTtAccount, { foreignKey: 'userId', as: 'clickTtAccount' });
ClickTtAccount.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(MyTischtennisUpdateHistory, { foreignKey: 'userId', as: 'updateHistory' });
MyTischtennisUpdateHistory.belongsTo(User, { foreignKey: 'userId', as: 'user' });
@@ -407,6 +410,7 @@ export {
OfficialCompetition,
OfficialCompetitionMember,
MyTischtennis,
ClickTtAccount,
MyTischtennisUpdateHistory,
MyTischtennisFetchLog,
ApiLog,

View File

@@ -0,0 +1,15 @@
import express from 'express';
import clickTtAccountController from '../controllers/clickTtAccountController.js';
import { authenticate } from '../middleware/authMiddleware.js';
const router = express.Router();
router.use(authenticate);
router.get('/account', clickTtAccountController.getAccount);
router.get('/status', clickTtAccountController.getStatus);
router.post('/account', clickTtAccountController.upsertAccount);
router.delete('/account', clickTtAccountController.deleteAccount);
router.post('/verify', clickTtAccountController.verifyLogin);
export default router;

View File

@@ -13,7 +13,7 @@ import {
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -39,6 +39,7 @@ import accidentRoutes from './routes/accidentRoutes.js';
import trainingStatsRoutes from './routes/trainingStatsRoutes.js';
import officialTournamentRoutes from './routes/officialTournamentRoutes.js';
import myTischtennisRoutes from './routes/myTischtennisRoutes.js';
import clickTtAccountRoutes from './routes/clickTtAccountRoutes.js';
import teamRoutes from './routes/teamRoutes.js';
import clubTeamRoutes from './routes/clubTeamRoutes.js';
import teamDocumentRoutes from './routes/teamDocumentRoutes.js';
@@ -117,6 +118,7 @@ app.use('/api/accident', accidentRoutes);
app.use('/api/training-stats', trainingStatsRoutes);
app.use('/api/official-tournaments', officialTournamentRoutes);
app.use('/api/mytischtennis', myTischtennisRoutes);
app.use('/api/clicktt-account', clickTtAccountRoutes);
app.use('/api/teams', teamRoutes);
app.use('/api/club-teams', clubTeamRoutes);
app.use('/api/team-documents', teamDocumentRoutes);
@@ -317,6 +319,7 @@ app.use((err, req, res, next) => {
await safeSync(Accident);
await safeSync(UserToken);
await safeSync(MyTischtennis);
await safeSync(ClickTtAccount);
await safeSync(MyTischtennisUpdateHistory);
await safeSync(MyTischtennisFetchLog);
await safeSync(ApiLog);

View 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&region=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();

View File

@@ -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' });