diff --git a/backend/controllers/authController.js b/backend/controllers/authController.js index 593db0dc..422a9b11 100644 --- a/backend/controllers/authController.js +++ b/backend/controllers/authController.js @@ -1,4 +1,4 @@ -import { register, activateUser, login, logout, requestPasswordReset, resetPassword } from '../services/authService.js'; +import { register, activateUser, login, logout, deleteOwnAccount, requestPasswordReset, resetPassword } from '../services/authService.js'; const registerUser = async (req, res, next) => { try { @@ -45,6 +45,16 @@ const logoutUser = async (req, res, next) => { } }; +const deleteAccount = async (req, res, next) => { + try { + const { password } = req.body || {}; + const result = await deleteOwnAccount(req.user?.id, password); + res.status(200).json(result); + } catch (error) { + next(error); + } +}; + const forgotPassword = async (req, res, next) => { try { const { email } = req.body; @@ -65,4 +75,4 @@ const resetUserPassword = async (req, res, next) => { } }; -export { registerUser, activate, loginUser, logoutUser, forgotPassword, resetUserPassword }; +export { registerUser, activate, loginUser, logoutUser, deleteAccount, forgotPassword, resetUserPassword }; diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js index ea5612b3..85ab7388 100644 --- a/backend/routes/authRoutes.js +++ b/backend/routes/authRoutes.js @@ -1,5 +1,6 @@ import express from 'express'; -import { registerUser, activate, loginUser, logoutUser, forgotPassword, resetUserPassword } from '../controllers/authController.js'; +import { registerUser, activate, loginUser, logoutUser, deleteAccount, forgotPassword, resetUserPassword } from '../controllers/authController.js'; +import { authenticate } from '../middleware/authMiddleware.js'; const router = express.Router(); @@ -7,6 +8,7 @@ router.post('/register', registerUser); router.get('/activate/:activationCode', activate); router.post('/login', loginUser); router.post('/logout', logoutUser); +router.delete('/account', authenticate, deleteAccount); router.post('/forgot-password', forgotPassword); router.post('/reset-password', resetUserPassword); diff --git a/backend/server.js b/backend/server.js index 5f686122..50b69a77 100644 --- a/backend/server.js +++ b/backend/server.js @@ -149,6 +149,11 @@ const SEO_ROUTE_CONFIG = { description: 'Datenschutzerklärung von Trainingstagebuch.', robots: 'index,follow', }, + '/konto-loeschen': { + title: 'Konto und Daten löschen | Trainingstagebuch', + description: 'Informationen zur Löschung des Benutzerkontos und personenbezogener Daten im Trainingstagebuch.', + robots: 'index,follow', + }, }; const SEO_NOINDEX_PREFIXES = [ diff --git a/backend/services/authService.js b/backend/services/authService.js index 4aebfcd3..f1c0a919 100644 --- a/backend/services/authService.js +++ b/backend/services/authService.js @@ -1,7 +1,18 @@ import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; +import sequelize from '../database.js'; import User from '../models/User.js'; import UserToken from '../models/UserToken.js'; +import UserClub from '../models/UserClub.js'; +import Log from '../models/Log.js'; +import ApiLog from '../models/ApiLog.js'; +import MyTischtennis from '../models/MyTischtennis.js'; +import MyTischtennisUpdateHistory from '../models/MyTischtennisUpdateHistory.js'; +import MyTischtennisFetchLog from '../models/MyTischtennisFetchLog.js'; +import ClickTtAccount from '../models/ClickTtAccount.js'; +import BillingUserSetting from '../models/BillingUserSetting.js'; +import BillingTemplate from '../models/BillingTemplate.js'; +import BillingRun from '../models/BillingRun.js'; import crypto from 'crypto'; import { sendActivationEmail, sendPasswordResetEmail } from './emailService.js'; @@ -106,6 +117,47 @@ const logout = async (token) => { return { message: 'Logout erfolgreich' }; }; +const deleteOwnAccount = async (userId, password) => { + if (!userId || !password) { + const err = new Error('Passwort ist erforderlich'); + err.status = 400; + throw err; + } + + const user = await User.findByPk(userId); + const validPassword = user && await bcrypt.compare(password, user.password); + if (!validPassword) { + const err = new Error('Ungültiges Passwort'); + err.status = 401; + throw err; + } + + await sequelize.transaction(async (transaction) => { + await UserToken.destroy({ where: { userId }, transaction }); + await UserClub.destroy({ where: { userId }, transaction }); + await Log.destroy({ where: { userId }, transaction }); + await ApiLog.update({ userId: null }, { where: { userId }, transaction }); + await MyTischtennis.destroy({ where: { userId }, transaction }); + await MyTischtennisUpdateHistory.destroy({ where: { userId }, transaction }); + await MyTischtennisFetchLog.destroy({ where: { userId }, transaction }); + await ClickTtAccount.destroy({ where: { userId }, transaction }); + await BillingUserSetting.destroy({ where: { userId }, transaction }); + await BillingTemplate.update({ createdByUserId: null }, { where: { createdByUserId: userId }, transaction }); + await BillingRun.update( + { + createdByUserId: null, + selfRecipientName: 'Gelöschter Benutzer', + iban: null, + }, + { where: { selfRecipientUserId: userId }, transaction }, + ); + await BillingRun.update({ createdByUserId: null }, { where: { createdByUserId: userId }, transaction }); + await user.destroy({ transaction }); + }); + + return { success: true }; +}; + const requestPasswordReset = async (email) => { if (!email) { const err = new Error('E-Mail-Adresse ist erforderlich.'); @@ -182,4 +234,4 @@ const resetPassword = async (token, newPassword) => { return { message: 'Passwort wurde erfolgreich geändert.' }; }; -export { register, activateUser, login, logout, requestPasswordReset, resetPassword }; +export { register, activateUser, login, logout, deleteOwnAccount, requestPasswordReset, resetPassword }; diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6bcb4c31..3c152c90 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -198,6 +198,8 @@ Impressum · Datenschutzerklärung + · + Konto löschen diff --git a/frontend/src/router.js b/frontend/src/router.js index 7559bbe3..4ccbe3c2 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -36,6 +36,7 @@ const OrdersView = () => import('./views/OrdersView.vue'); const BillingView = () => import('./views/BillingView.vue'); const Impressum = () => import('./views/Impressum.vue'); const Datenschutz = () => import('./views/Datenschutz.vue'); +const KontoLoeschen = () => import('./views/KontoLoeschen.vue'); const routes = [ { path: '/register', name: 'register', component: Register, meta: { public: true } }, @@ -73,6 +74,7 @@ const routes = [ { path: '/billing', name: 'billing', component: BillingView }, { path: '/impressum', name: 'impressum', component: Impressum, meta: { public: true } }, { path: '/datenschutz', name: 'datenschutz', component: Datenschutz, meta: { public: true } }, + { path: '/konto-loeschen', name: 'konto-loeschen', component: KontoLoeschen, meta: { public: true } }, ]; const router = createRouter({ diff --git a/frontend/src/utils/seo.js b/frontend/src/utils/seo.js index 65199ae3..1be73003 100644 --- a/frontend/src/utils/seo.js +++ b/frontend/src/utils/seo.js @@ -71,6 +71,11 @@ const ROUTE_SEO = { title: 'Datenschutzerklärung | Trainingstagebuch', description: 'Datenschutzerklärung von Trainingstagebuch.', robots: 'index,follow' + }, + '/konto-loeschen': { + title: 'Konto und Daten löschen | Trainingstagebuch', + description: 'Informationen zur Löschung des Benutzerkontos und personenbezogener Daten im Trainingstagebuch.', + robots: 'index,follow' } }; diff --git a/frontend/src/views/KontoLoeschen.vue b/frontend/src/views/KontoLoeschen.vue new file mode 100644 index 00000000..5ca6c3a9 --- /dev/null +++ b/frontend/src/views/KontoLoeschen.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/mobile-app/composeApp/release/composeApp-release.aab b/mobile-app/composeApp/release/composeApp-release.aab index 04d4b82f..d9b71136 100644 Binary files a/mobile-app/composeApp/release/composeApp-release.aab and b/mobile-app/composeApp/release/composeApp-release.aab differ diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt index 09b8d00a..f377a763 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt @@ -5972,8 +5972,10 @@ private fun SettingsScreen( } val authState by dependencies.authManager.state.collectAsState() val clubState by dependencies.clubManager.state.collectAsState() + val androidContext = LocalContext.current var sessionStatus by rememberSaveable { mutableStateOf(null) } var isLoading by rememberSaveable { mutableStateOf(false) } + var showDeleteAccountDialog by rememberSaveable { mutableStateOf(false) } val sessionValidMessage = tr("mobile.sessionValid", "Session gültig") val sessionInvalidMessage = tr("mobile.sessionInvalid", "Session ungültig") val sessionCheckFailedMessage = tr("mobile.sessionCheckFailed", "Session check fehlgeschlagen") @@ -6084,11 +6086,22 @@ private fun SettingsScreen( ) { Text(tr("billing.title", "Abrechnung")) } } SectionTitle(tr("mobile.legal", "Rechtliches")) - Text( - tr("mobile.legalInAppTodo", "Impressum/Datenschutz: in-App-Ansicht folgt (kein Browser-Aufruf)."), - style = MaterialTheme.typography.caption, - modifier = Modifier.padding(bottom = 4.dp), - ) + TextButton( + onClick = { + androidContext.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("${dependencies.apiConfig.baseUrl}/datenschutz")), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Datenschutzerklärung") } + TextButton( + onClick = { + androidContext.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("${dependencies.apiConfig.baseUrl}/impressum")), + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Impressum") } Button( onClick = { isLoading = true @@ -6134,6 +6147,17 @@ private fun SettingsScreen( Text(tr("auth.logout", "Abmelden")) } + SectionTitle("Konto und Daten") + OutlinedButton( + onClick = { showDeleteAccountDialog = true }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .heightIn(min = TouchMinHeight), + ) { + Text("Eigenes Konto löschen", color = MaterialTheme.colors.error) + } + OutlinedButton( onClick = { dependencies.applicationScope.launch { @@ -6149,6 +6173,73 @@ private fun SettingsScreen( Text(tr("club.change", "Verein wechseln")) } } + + if (showDeleteAccountDialog) { + DeleteAccountDialog( + onDismiss = { showDeleteAccountDialog = false }, + onConfirm = { password, onError -> + dependencies.applicationScope.launch { + runCatching { + dependencies.authManager.deleteAccount(password) + dependencies.clubManager.clearSelection() + dependencies.diaryManager.clear() + dependencies.membersManager.clear() + dependencies.trainingStatsManager.clear() + dependencies.scheduleManager.clear() + dependencies.pendingApprovalsManager.clear() + dependencies.permissionsAdminManager.clear() + dependencies.apiLogsManager.clear() + dependencies.clubInternalTournamentsManager.clear() + dependencies.officialTournamentsReadManager.clear() + }.onSuccess { + showDeleteAccountDialog = false + }.onFailure { + onError(it.message ?: "Konto konnte nicht gelöscht werden.") + } + } + }, + ) + } +} + +@Composable +private fun DeleteAccountDialog( + onDismiss: () -> Unit, + onConfirm: (password: String, onError: (String) -> Unit) -> Unit, +) { + var password by rememberSaveable { mutableStateOf("") } + var error by rememberSaveable { mutableStateOf(null) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Eigenes Konto löschen") }, + text = { + Column { + Text("Diese Aktion löscht dein Benutzerkonto, deine Login-Sessions, Vereinszuordnungen und personenbezogene Integrationsdaten. Geteilte Vereinsdaten bleiben erhalten.") + error?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp)) } + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Passwort zur Bestätigung") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + singleLine = true, + ) + } + }, + confirmButton = { + TextButton( + onClick = { + if (password.isBlank()) { + error = "Bitte Passwort eingeben." + } else { + onConfirm(password) { error = it } + } + }, + ) { Text("Endgültig löschen", color = MaterialTheme.colors.error) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Abbrechen") } + }, + ) } @Composable diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/AuthApi.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/AuthApi.kt index ce07e911..d05d60fe 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/AuthApi.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/AuthApi.kt @@ -1,9 +1,11 @@ package de.tsschulz.tt_tagebuch.shared.api import de.tsschulz.tt_tagebuch.shared.api.http.AuthedHttpClient +import de.tsschulz.tt_tagebuch.shared.api.models.DeleteAccountRequest import de.tsschulz.tt_tagebuch.shared.api.models.LoginRequest import de.tsschulz.tt_tagebuch.shared.api.models.LoginResponse import io.ktor.client.call.body +import io.ktor.client.request.delete import io.ktor.client.request.post import io.ktor.client.request.setBody @@ -19,4 +21,10 @@ class AuthApi( suspend fun logout() { client.http.post("/api/auth/logout") } + + suspend fun deleteAccount(password: String) { + client.http.delete("/api/auth/account") { + setBody(DeleteAccountRequest(password = password)) + } + } } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/LoginRequest.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/LoginRequest.kt index 30f6342e..f0f50a78 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/LoginRequest.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/models/LoginRequest.kt @@ -8,3 +8,8 @@ data class LoginRequest( val password: String, val rememberMe: Boolean = false, ) + +@Serializable +data class DeleteAccountRequest( + val password: String, +) diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/AuthManager.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/AuthManager.kt index 504ce780..5a84f3aa 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/AuthManager.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/AuthManager.kt @@ -54,6 +54,11 @@ class AuthManager( clearLocal() } + suspend fun deleteAccount(password: String) { + authApi.deleteAccount(password) + clearLocal() + } + suspend fun clearLocal() { tokenProvider.token = null tokenProvider.username = null