feat: Implement account deletion feature with UI and API integration
Some checks failed
Deploy tt-tagebuch / deploy (push) Has been cancelled
Some checks failed
Deploy tt-tagebuch / deploy (push) Has been cancelled
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -198,6 +198,8 @@
|
||||
<router-link to="/impressum" class="footer-link">Impressum</router-link>
|
||||
<span class="footer-sep">·</span>
|
||||
<router-link to="/datenschutz" class="footer-link">Datenschutzerklärung</router-link>
|
||||
<span class="footer-sep">·</span>
|
||||
<router-link to="/konto-loeschen" class="footer-link">Konto löschen</router-link>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
69
frontend/src/views/KontoLoeschen.vue
Normal file
69
frontend/src/views/KontoLoeschen.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<h1>Konto und Daten löschen</h1>
|
||||
<p class="back-home"><router-link to="/">Zur Startseite</router-link></p>
|
||||
|
||||
<section>
|
||||
<h2>Löschung in der Android-App</h2>
|
||||
<p>
|
||||
In der Android-App können Sie Ihr eigenes Benutzerkonto direkt löschen:
|
||||
</p>
|
||||
<ol>
|
||||
<li>App öffnen und einloggen.</li>
|
||||
<li>Zum Bereich <strong>Mehr</strong> wechseln.</li>
|
||||
<li><strong>Eigenes Konto löschen</strong> auswählen.</li>
|
||||
<li>Das App-Passwort eingeben und die Löschung bestätigen.</li>
|
||||
</ol>
|
||||
<p>
|
||||
Dabei werden Ihr Benutzerkonto, Login-Sessions, Vereinszuordnungen und personenbezogene Integrationsdaten gelöscht.
|
||||
Geteilte Vereinsdaten bleiben erhalten, soweit sie zur Vereinsverwaltung gehören oder andere Vereinsmitglieder betreffen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Löschung per E-Mail anfordern</h2>
|
||||
<p>
|
||||
Falls Sie keinen Zugriff mehr auf die App haben, können Sie die Löschung per E-Mail anfordern:
|
||||
<a href="mailto:tsschulz@tsschulz.de?subject=Konto%20und%20Daten%20l%C3%B6schen">tsschulz@tsschulz.de</a>
|
||||
</p>
|
||||
<p>
|
||||
Bitte senden Sie die Anfrage von der E-Mail-Adresse, mit der Ihr Konto registriert ist.
|
||||
Zur Vermeidung unberechtigter Löschungen können zusätzliche Nachweise erforderlich sein.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Weitere Informationen</h2>
|
||||
<p>
|
||||
Details zur Verarbeitung, Speicherdauer und zu Ihren Betroffenenrechten finden Sie in der
|
||||
<router-link to="/datenschutz">Datenschutzerklärung</router-link>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'KontoLoeschen',
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
section + section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.back-home {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
</style>
|
||||
Binary file not shown.
@@ -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<String?>(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<String?>(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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,8 @@ data class LoginRequest(
|
||||
val password: String,
|
||||
val rememberMe: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DeleteAccountRequest(
|
||||
val password: String,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user