diff --git a/backend/controllers/clickTtAccountController.js b/backend/controllers/clickTtAccountController.js new file mode 100644 index 00000000..d76d683c --- /dev/null +++ b/backend/controllers/clickTtAccountController.js @@ -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(); diff --git a/backend/models/ClickTtAccount.js b/backend/models/ClickTtAccount.js new file mode 100644 index 00000000..155beacf --- /dev/null +++ b/backend/models/ClickTtAccount.js @@ -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; diff --git a/backend/models/index.js b/backend/models/index.js index fdc8b4b1..4025b220 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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, diff --git a/backend/routes/clickTtAccountRoutes.js b/backend/routes/clickTtAccountRoutes.js new file mode 100644 index 00000000..9ff0f076 --- /dev/null +++ b/backend/routes/clickTtAccountRoutes.js @@ -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; diff --git a/backend/server.js b/backend/server.js index 6822009c..130264b0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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); diff --git a/backend/services/clickTtAccountService.js b/backend/services/clickTtAccountService.js new file mode 100644 index 00000000..2dba1e35 --- /dev/null +++ b/backend/services/clickTtAccountService.js @@ -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(); diff --git a/backend/services/clickTtPlayerRegistrationService.js b/backend/services/clickTtPlayerRegistrationService.js index 6a824838..f726ca8e 100644 --- a/backend/services/clickTtPlayerRegistrationService.js +++ b/backend/services/clickTtPlayerRegistrationService.js @@ -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' }); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 74481e3a..4fe247a4 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -18,6 +18,10 @@ 🔗 {{ $t('navigation.myTischtennisAccount') }} + + 🏓 + HTTV / click-TT Account + 🔐 {{ $t('navigation.permissions') }} diff --git a/frontend/src/components/ClickTtAccountDialog.vue b/frontend/src/components/ClickTtAccountDialog.vue new file mode 100644 index 00000000..c2bfa6a0 --- /dev/null +++ b/frontend/src/components/ClickTtAccountDialog.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/frontend/src/router.js b/frontend/src/router.js index 5e6b8a63..5619cbcc 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -16,6 +16,7 @@ import TrainingStatsView from './views/TrainingStatsView.vue'; import ClubSettings from './views/ClubSettings.vue'; import PredefinedActivities from './views/PredefinedActivities.vue'; import MyTischtennisAccount from './views/MyTischtennisAccount.vue'; +import ClickTtAccount from './views/ClickTtAccount.vue'; import TeamManagementView from './views/TeamManagementView.vue'; import PermissionsView from './views/PermissionsView.vue'; import LogsView from './views/LogsView.vue'; @@ -43,6 +44,7 @@ const routes = [ { path: '/club-settings', component: ClubSettings }, { path: '/predefined-activities', component: PredefinedActivities }, { path: '/mytischtennis-account', component: MyTischtennisAccount }, + { path: '/clicktt-account', component: ClickTtAccount }, { path: '/team-management', component: TeamManagementView }, { path: '/permissions', component: PermissionsView }, { path: '/logs', component: LogsView }, diff --git a/frontend/src/views/ClickTtAccount.vue b/frontend/src/views/ClickTtAccount.vue new file mode 100644 index 00000000..f881fa9a --- /dev/null +++ b/frontend/src/views/ClickTtAccount.vue @@ -0,0 +1,203 @@ + + + + +