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

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

View 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>

View File

@@ -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 },

View 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>