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:
72
backend/controllers/clickTtAccountController.js
Normal file
72
backend/controllers/clickTtAccountController.js
Normal 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();
|
||||
107
backend/models/ClickTtAccount.js
Normal file
107
backend/models/ClickTtAccount.js
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
15
backend/routes/clickTtAccountRoutes.js
Normal file
15
backend/routes/clickTtAccountRoutes.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
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' });
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
<span class="dropdown-icon">🔗</span>
|
||||
{{ $t('navigation.myTischtennisAccount') }}
|
||||
</router-link>
|
||||
<router-link to="/clicktt-account" class="dropdown-item" @click="userDropdownOpen = false">
|
||||
<span class="dropdown-icon">🏓</span>
|
||||
HTTV / click-TT Account
|
||||
</router-link>
|
||||
<router-link v-if="canManagePermissions" to="/permissions" class="dropdown-item" @click="userDropdownOpen = false">
|
||||
<span class="dropdown-icon">🔐</span>
|
||||
{{ $t('navigation.permissions') }}
|
||||
|
||||
147
frontend/src/components/ClickTtAccountDialog.vue
Normal file
147
frontend/src/components/ClickTtAccountDialog.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h3>{{ loginMode ? 'Login' : (account ? 'HTTV-/click-TT-Account bearbeiten' : 'HTTV-/click-TT-Account verknüpfen') }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="clicktt-username">Benutzername / Login</label>
|
||||
<input
|
||||
id="clicktt-username"
|
||||
v-model="formData.username"
|
||||
type="text"
|
||||
placeholder="TTDE / click-TT Benutzername"
|
||||
:readonly="loginMode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="clicktt-password">Passwort</label>
|
||||
<input
|
||||
id="clicktt-password"
|
||||
v-model="formData.password"
|
||||
type="password"
|
||||
:placeholder="account && account.savePassword ? 'Leer lassen, um das gespeicherte Passwort zu behalten' : 'HTTV-/click-TT-Passwort'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="!loginMode">
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="formData.savePassword" />
|
||||
<span>Passwort speichern</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="formData.password">
|
||||
<label for="clicktt-app-password">App-Passwort</label>
|
||||
<input
|
||||
id="clicktt-app-password"
|
||||
v-model="formData.userPassword"
|
||||
type="password"
|
||||
placeholder="Eigenes App-Passwort zur Bestätigung"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="$emit('close')" :disabled="saving">Abbrechen</button>
|
||||
<button v-if="loginMode" class="btn-primary" @click="performLogin" :disabled="!canLogin || saving">
|
||||
{{ saving ? 'Prüfe…' : 'Login testen' }}
|
||||
</button>
|
||||
<button v-else class="btn-primary" @click="saveAccount" :disabled="!canSave || saving">
|
||||
{{ saving ? 'Speichere…' : 'Speichern' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import apiClient from '../apiClient.js';
|
||||
|
||||
export default {
|
||||
name: 'ClickTtAccountDialog',
|
||||
props: {
|
||||
account: { type: Object, default: null },
|
||||
loginMode: { type: Boolean, default: false }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
formData: {
|
||||
username: this.account?.username || '',
|
||||
password: '',
|
||||
savePassword: this.account?.savePassword || false,
|
||||
userPassword: ''
|
||||
},
|
||||
saving: false,
|
||||
error: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
canLogin() {
|
||||
return !!this.formData.password;
|
||||
},
|
||||
canSave() {
|
||||
if (!this.formData.username.trim()) return false;
|
||||
if (this.formData.password && !this.formData.userPassword) return false;
|
||||
return true;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async performLogin() {
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
try {
|
||||
await apiClient.post('/clicktt-account/verify', { password: this.formData.password });
|
||||
this.$emit('logged-in');
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.error || error.response?.data?.message || 'Login fehlgeschlagen';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
async saveAccount() {
|
||||
this.saving = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const payload = {
|
||||
username: this.formData.username,
|
||||
savePassword: this.formData.savePassword
|
||||
};
|
||||
if (this.formData.password) {
|
||||
payload.password = this.formData.password;
|
||||
payload.userPassword = this.formData.userPassword;
|
||||
}
|
||||
await apiClient.post('/clicktt-account/account', payload);
|
||||
this.$emit('saved');
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.error || error.response?.data?.message || 'Speichern fehlgeschlagen';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; justify-content: center; align-items: center; z-index: 1000; }
|
||||
.modal { background: white; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); max-width: 600px; width: 90%; max-height: 90vh; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
.modal-header { padding: 1.5rem; border-bottom: 1px solid #dee2e6; }
|
||||
.modal-body { padding: 1.5rem; flex: 1; }
|
||||
.modal-footer { padding: 1rem 1.5rem; border-top: 1px solid #dee2e6; display: flex; justify-content: flex-end; gap: 1rem; }
|
||||
.form-group { margin-bottom: 1.5rem; }
|
||||
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 600; color: #495057; }
|
||||
.form-group input[type="text"], .form-group input[type="password"] { width: 100%; padding: 0.75rem; border: 1px solid #ced4da; border-radius: 4px; font-size: 1rem; box-sizing: border-box; }
|
||||
.checkbox-group label { display: flex; align-items: center; gap: 0.5rem; font-weight: normal; }
|
||||
.error-message { padding: 0.75rem; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; color: #721c24; margin-top: 1rem; }
|
||||
.btn-primary, .btn-secondary { padding: 0.75rem 1.5rem; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; }
|
||||
.btn-primary { background-color: #007bff; color: white; }
|
||||
.btn-secondary { background-color: #6c757d; color: white; }
|
||||
</style>
|
||||
@@ -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 },
|
||||
|
||||
203
frontend/src/views/ClickTtAccount.vue
Normal file
203
frontend/src/views/ClickTtAccount.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="mytt-account-page">
|
||||
<div class="page-container">
|
||||
<h1>HTTV / click-TT Account</h1>
|
||||
|
||||
<div class="account-container">
|
||||
<div v-if="loading" class="loading">Lade Account…</div>
|
||||
|
||||
<div v-else-if="account" class="account-info">
|
||||
<div class="info-section">
|
||||
<h2>Verknüpfter Account</h2>
|
||||
|
||||
<div class="info-row">
|
||||
<label>Benutzername</label>
|
||||
<span>{{ account.username }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<label>Passwort gespeichert</label>
|
||||
<span>{{ accountStatus && accountStatus.hasPassword ? 'Ja' : 'Nein' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="account.lastLoginSuccess">
|
||||
<label>Letzter erfolgreicher Login</label>
|
||||
<span>{{ formatDate(account.lastLoginSuccess) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="account.lastLoginAttempt">
|
||||
<label>Letzter Login-Versuch</label>
|
||||
<span>{{ formatDate(account.lastLoginAttempt) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="button-group">
|
||||
<button class="btn-primary" @click="openEditDialog">Account bearbeiten</button>
|
||||
<button type="button" class="btn-secondary" @click="testConnection" :disabled="verifyingLogin">
|
||||
{{ verifyingLogin ? 'Login wird durchgeführt…' : 'Login erneut testen' }}
|
||||
</button>
|
||||
<button class="btn-danger" @click="deleteAccount">Account löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-account">
|
||||
<p>Es ist noch kein HTTV-/click-TT-Account hinterlegt.</p>
|
||||
<button class="btn-primary" @click="openEditDialog">Account verknüpfen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ClickTtAccountDialog
|
||||
v-if="showDialog"
|
||||
:account="account"
|
||||
:login-mode="loginMode"
|
||||
@close="closeDialog"
|
||||
@saved="onAccountSaved"
|
||||
@logged-in="onLoggedIn"
|
||||
/>
|
||||
|
||||
<InfoDialog
|
||||
v-model="infoDialog.isOpen"
|
||||
:title="infoDialog.title"
|
||||
:message="infoDialog.message"
|
||||
:details="infoDialog.details"
|
||||
:type="infoDialog.type"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
v-model="confirmDialog.isOpen"
|
||||
:title="confirmDialog.title"
|
||||
:message="confirmDialog.message"
|
||||
:details="confirmDialog.details"
|
||||
:type="confirmDialog.type"
|
||||
@confirm="handleConfirmResult(true)"
|
||||
@cancel="handleConfirmResult(false)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import apiClient from '../apiClient.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorMessages.js';
|
||||
import ClickTtAccountDialog from '../components/ClickTtAccountDialog.vue';
|
||||
import InfoDialog from '../components/InfoDialog.vue';
|
||||
import ConfirmDialog from '../components/ConfirmDialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'ClickTtAccount',
|
||||
components: { ClickTtAccountDialog, InfoDialog, ConfirmDialog },
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
account: null,
|
||||
accountStatus: null,
|
||||
showDialog: false,
|
||||
loginMode: false,
|
||||
verifyingLogin: false,
|
||||
infoDialog: { isOpen: false, title: '', message: '', details: '', type: 'info' },
|
||||
confirmDialog: { isOpen: false, title: '', message: '', details: '', type: 'info', resolveCallback: null }
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadAccount();
|
||||
},
|
||||
methods: {
|
||||
async showInfo(title, message, details = '', type = 'info') {
|
||||
this.infoDialog = { isOpen: true, title, message, details, type };
|
||||
},
|
||||
async showConfirm(title, message, details = '', type = 'info') {
|
||||
return new Promise((resolve) => {
|
||||
this.confirmDialog = { isOpen: true, title, message, details, type, resolveCallback: resolve };
|
||||
});
|
||||
},
|
||||
handleConfirmResult(confirmed) {
|
||||
if (this.confirmDialog.resolveCallback) {
|
||||
this.confirmDialog.resolveCallback(confirmed);
|
||||
this.confirmDialog.resolveCallback = null;
|
||||
}
|
||||
this.confirmDialog.isOpen = false;
|
||||
},
|
||||
formatDate(dateString) {
|
||||
return new Date(dateString).toLocaleString('de-DE');
|
||||
},
|
||||
async loadAccount() {
|
||||
try {
|
||||
this.loading = true;
|
||||
const [accountResponse, statusResponse] = await Promise.all([
|
||||
apiClient.get('/clicktt-account/account'),
|
||||
apiClient.get('/clicktt-account/status')
|
||||
]);
|
||||
this.account = accountResponse.data.account;
|
||||
this.accountStatus = statusResponse.data;
|
||||
} catch (error) {
|
||||
this.account = null;
|
||||
this.accountStatus = null;
|
||||
await this.showInfo('Fehler', 'HTTV-/click-TT-Account konnte nicht geladen werden', '', 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
openEditDialog() {
|
||||
this.loginMode = false;
|
||||
this.showDialog = true;
|
||||
},
|
||||
closeDialog() {
|
||||
this.showDialog = false;
|
||||
this.loginMode = false;
|
||||
},
|
||||
async onAccountSaved() {
|
||||
this.closeDialog();
|
||||
await this.loadAccount();
|
||||
await this.showInfo('Erfolg', 'HTTV-/click-TT-Account gespeichert', '', 'success');
|
||||
},
|
||||
async onLoggedIn() {
|
||||
this.closeDialog();
|
||||
await this.loadAccount();
|
||||
await this.showInfo('Erfolg', 'Login erfolgreich', '', 'success');
|
||||
},
|
||||
async testConnection() {
|
||||
if (this.verifyingLogin) return;
|
||||
this.verifyingLogin = true;
|
||||
try {
|
||||
await apiClient.post('/clicktt-account/verify', {});
|
||||
await this.loadAccount();
|
||||
await this.showInfo('Erfolg', 'HTTV-/click-TT-Login erfolgreich', '', 'success');
|
||||
} catch (error) {
|
||||
const needsPassword = error?.response?.status === 400;
|
||||
if (needsPassword) {
|
||||
this.showDialog = true;
|
||||
this.loginMode = true;
|
||||
} else {
|
||||
await this.showInfo('Fehler', getSafeErrorMessage(error, 'HTTV-/click-TT-Login fehlgeschlagen'), '', 'error');
|
||||
}
|
||||
} finally {
|
||||
this.verifyingLogin = false;
|
||||
}
|
||||
},
|
||||
async deleteAccount() {
|
||||
const confirmed = await this.showConfirm('Account löschen', 'Soll der HTTV-/click-TT-Account wirklich gelöscht werden?', '', 'warning');
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await apiClient.delete('/clicktt-account/account');
|
||||
await this.loadAccount();
|
||||
await this.showInfo('Erfolg', 'HTTV-/click-TT-Account gelöscht', '', 'success');
|
||||
} catch (error) {
|
||||
await this.showInfo('Fehler', getSafeErrorMessage(error, 'Löschen fehlgeschlagen'), '', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { max-width: 900px; margin: 0 auto; padding: 2rem; }
|
||||
.account-container { background: white; border-radius: 12px; padding: 2rem; box-shadow: 0 2px 12px rgba(0,0,0,0.08); }
|
||||
.info-row { display: flex; gap: 1rem; margin-bottom: 1rem; }
|
||||
.info-row label { min-width: 220px; font-weight: 600; }
|
||||
.button-group { display: flex; gap: 1rem; margin-top: 1.5rem; }
|
||||
.btn-primary, .btn-secondary, .btn-danger { padding: 0.75rem 1rem; border: none; border-radius: 6px; cursor: pointer; }
|
||||
.btn-primary { background: #007bff; color: white; }
|
||||
.btn-secondary { background: #6c757d; color: white; }
|
||||
.btn-danger { background: #dc3545; color: white; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user