Add account deletion feature and privacy section

Implement a new endpoint for account deletion in AuthController, allowing users to permanently delete their accounts and associated data. Update AuthService to handle account deletion logic, including confirmation checks and data removal from the database. Enhance frontend with new views and components for account deletion and privacy information, including links in the side menu and profile view. Update mobile app to support account deletion and privacy sections, improving user experience and compliance with data protection standards.
This commit is contained in:
Torsten Schulz (local)
2026-05-15 11:20:08 +02:00
parent afd0d2935d
commit 328bc9e776
18 changed files with 720 additions and 9 deletions

View File

@@ -0,0 +1,72 @@
# Datenschutz und Account-Löschung für Google Play
**App:** TimeClock / Stechuhr
**Datenschutz-URL:** https://stechuhr3.tsschulz.de/datenschutz
**Account-Lösch-URL:** https://stechuhr3.tsschulz.de/account-loeschen
**Stand:** 15.05.2026
## Vor Veröffentlichung ergänzen
- Ladungsfähige Anschrift des Verantwortlichen in der Web-Datenschutzerklärung ersetzen:
`BITTE VOR VERÖFFENTLICHUNG ERGÄNZEN`
- Prüfen, ob ein Datenschutzbeauftragter benannt werden muss.
- Konkrete Backup- und Server-Log-Löschfristen ergänzen, falls diese festgelegt sind.
## Kurztext für Play Console / Datenschutzangaben
TimeClock ist eine Zeiterfassungsanwendung. Die App verarbeitet personenbezogene Daten zur Anmeldung, Zeiterfassung, Korrektur, Auswertung und Verwaltung persönlicher Arbeitszeiten.
Verantwortlich für die Datenverarbeitung ist Torsten Schulz. Kontakt: kontakt@tsschulz.de.
Die Datenschutzerklärung ist öffentlich abrufbar unter:
https://stechuhr3.tsschulz.de/datenschutz
Die öffentliche Seite zur Account- und Datenlöschung ist abrufbar unter:
https://stechuhr3.tsschulz.de/account-loeschen
## FAQ-Text für den Play Store
### Welche Daten verarbeitet TimeClock?
TimeClock verarbeitet Accountdaten wie Name, E-Mail-Adresse, Passwort-Hash und Login-Status. Außerdem verarbeitet die App Zeiterfassungsdaten wie Kommen, Gehen, Pausen, Korrekturen und Zeitstempel sowie Abwesenheitsdaten wie Urlaub, Krankheit, Feiertage und Arbeitszeitwünsche. Zusätzlich werden Einstellungen wie Bundesland, Soll-Arbeitszeit, Anzeigeoptionen und Berechtigungen gespeichert. Für Betrieb und Sicherheit können technische Daten wie IP-Adresse, Zugriffszeitpunkt, Geräte-/Browserinformationen und Server-Logs verarbeitet werden.
Bei optionaler Google-Anmeldung verarbeitet TimeClock die von Google übermittelte Google-Konto-ID, E-Mail-Adresse und den Anzeigenamen. Das Google-Passwort wird nicht an TimeClock übermittelt oder gespeichert.
### Wofür werden die Daten genutzt?
Die Daten werden genutzt, um die Zeiterfassung bereitzustellen, Nutzer zu authentifizieren, Arbeitszeiten zu speichern und auszuwerten, Korrekturen und Abwesenheiten zu verwalten, den Dienst abzusichern, Fehler zu analysieren und gesetzliche oder vertragliche Anforderungen an Arbeitszeitdaten zu erfüllen.
### Auf welcher Rechtsgrundlage werden Daten verarbeitet?
Die Verarbeitung erfolgt insbesondere auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO zur Bereitstellung des Accounts und der Zeiterfassung, Art. 6 Abs. 1 lit. c DSGVO für gesetzliche Aufbewahrungs- oder Nachweispflichten, Art. 6 Abs. 1 lit. f DSGVO für Sicherheit, Fehleranalyse, Missbrauchsschutz und Server-Logs sowie Art. 6 Abs. 1 lit. a DSGVO für optionale Funktionen, soweit eine Einwilligung erforderlich ist.
### Werden Daten verkauft oder zu Werbezwecken weitergegeben?
Nein. Personenbezogene Daten werden nicht verkauft. Eine Weitergabe erfolgt nur, soweit sie für Betrieb, Authentifizierung, Hosting, E-Mail-Versand, Fehleranalyse oder gesetzliche Pflichten erforderlich ist.
### Welche Drittanbieter können beteiligt sein?
Für den Betrieb können Hosting-/Serveranbieter und E-Mail-Dienstleister eingesetzt werden. Bei Nutzung der optionalen Google-Anmeldung ist Google als OAuth-Anbieter beteiligt.
### Wie lange werden Daten gespeichert?
Account-, Zeit- und Einstellungsdaten werden grundsätzlich gespeichert, solange der Account besteht. Nach Account-Löschung werden die aktiven Accountdaten, Anmeldedaten, Google-Verknüpfungen, Zeitbuchungen, Korrekturen, Urlaubs- und Krankmeldungen sowie persönliche Einstellungen gelöscht. Server-Logs und Backups können technisch bedingt bis zum Ablauf regulärer Löschzyklen weiterbestehen. Daten können länger aufbewahrt werden, wenn gesetzliche Pflichten oder die Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen dies erfordern.
### Wie können Nutzer ihren Account löschen?
Eingeloggte Nutzer können ihren Account in der Web-UI unter "Persönliches" löschen. Zusätzlich steht eine öffentliche Löschseite zur Verfügung:
https://stechuhr3.tsschulz.de/account-loeschen
Über diese Seite können Nutzer auch ohne installierte App eine Löschung per E-Mail an kontakt@tsschulz.de beantragen.
### Welche Daten werden bei Account-Löschung gelöscht?
Gelöscht werden Accountdaten, Anmeldedaten, Google-Verknüpfungen, Zeitbuchungen, Zeitkorrekturen, Urlaubs- und Krankmeldungen, Zeitwünsche, Wochenarbeitszeiten und persönliche Einstellungen.
### Welche Rechte haben Nutzer?
Nutzer können Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit verlangen sowie einer Verarbeitung widersprechen. Außerdem besteht ein Beschwerderecht bei einer zuständigen Datenschutzaufsichtsbehörde. Anfragen können an kontakt@tsschulz.de gerichtet werden.
### Wie werden Daten geschützt?
Die Übertragung erfolgt über HTTPS. Passwörter werden nicht im Klartext gespeichert. Zugriffstokens und OAuth-Verknüpfungen werden serverseitig verwaltet. Zugriffe werden auf das erforderliche Maß beschränkt.

View File

@@ -257,7 +257,31 @@ class AuthController {
}); });
} }
} }
/**
* Eigenen Account und personenbezogene Daten löschen
* DELETE /api/auth/account
*/
async deleteAccount(req, res) {
try {
const userId = req.user.userId;
const { confirmation } = req.body;
await authService.deleteAccount(userId, confirmation);
res.json({
success: true,
message: 'Account wurde gelöscht'
});
} catch (error) {
console.error('Fehler beim Löschen des Accounts:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
} }
module.exports = new AuthController(); module.exports = new AuthController();

View File

@@ -20,6 +20,7 @@ router.post('/logout', authenticateToken, authController.logout.bind(authControl
router.get('/me', authenticateToken, authController.getCurrentUser.bind(authController)); router.get('/me', authenticateToken, authController.getCurrentUser.bind(authController));
router.post('/change-password', authenticateToken, authController.changePassword.bind(authController)); router.post('/change-password', authenticateToken, authController.changePassword.bind(authController));
router.get('/validate', authenticateToken, authController.validateToken.bind(authController)); router.get('/validate', authenticateToken, authController.validateToken.bind(authController));
router.delete('/account', authenticateToken, authController.deleteAccount.bind(authController));
router.post('/google/link-url', authenticateToken, oauthController.createGoogleLinkUrl.bind(oauthController)); router.post('/google/link-url', authenticateToken, oauthController.createGoogleLinkUrl.bind(oauthController));
router.post('/oauth/link-current', authenticateToken, oauthController.linkPendingToCurrentUser.bind(oauthController)); router.post('/oauth/link-current', authenticateToken, oauthController.linkPendingToCurrentUser.bind(oauthController));
router.get('/identities', authenticateToken, oauthController.getIdentities.bind(oauthController)); router.get('/identities', authenticateToken, oauthController.getIdentities.bind(oauthController));

View File

@@ -2,6 +2,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const crypto = require('crypto'); const crypto = require('crypto');
const nodemailer = require('nodemailer'); const nodemailer = require('nodemailer');
const { Op } = require('sequelize');
const database = require('../config/database'); const database = require('../config/database');
/** /**
@@ -667,6 +668,86 @@ ${frontendUrl}
return true; return true;
} }
async destroyOptional(model, where, transaction) {
try {
return await model.destroy({ where, transaction });
} catch (error) {
if (error.original?.code === 'ER_NO_SUCH_TABLE') {
return 0;
}
throw error;
}
}
/**
* Eigenen Account inkl. personenbezogener Daten löschen.
* @param {number} userId - Benutzer-ID
* @param {string} confirmation - Muss "ACCOUNT LÖSCHEN" sein
* @returns {Promise<boolean>} Erfolg
*/
async deleteAccount(userId, confirmation) {
if (confirmation !== 'ACCOUNT LÖSCHEN') {
throw new Error('Bitte bestätigen Sie die Löschung mit "ACCOUNT LÖSCHEN"');
}
const {
User,
AuthInfo,
AuthToken,
AuthIdentity,
Worklog,
Timefix,
WeeklyWorktime,
Vacation,
Sick,
Timewish,
Invitation,
Watcher
} = database.getModels();
return database.sequelize.transaction(async (transaction) => {
const user = await User.findByPk(userId, { transaction });
if (!user) {
throw new Error('Benutzer nicht gefunden');
}
const authInfo = await AuthInfo.findOne({
where: { user_id: userId },
transaction
});
if (authInfo) {
await AuthToken.destroy({ where: { auth_info_id: authInfo.id }, transaction });
await AuthIdentity.destroy({ where: { auth_info_id: authInfo.id }, transaction });
}
await this.destroyOptional(Watcher, { user_id: userId }, transaction);
await this.destroyOptional(Invitation, { inviter_user_id: userId }, transaction);
await WeeklyWorktime.destroy({ where: { user_id: userId }, transaction });
await Timewish.destroy({ where: { user_id: userId }, transaction });
await Vacation.destroy({ where: { user_id: userId }, transaction });
await Sick.destroy({ where: { user_id: userId }, transaction });
await Timefix.destroy({ where: { user_id: userId }, transaction });
await Worklog.destroy({
where: {
user_id: userId,
relatedTo_id: { [Op.ne]: null }
},
transaction
});
await Worklog.destroy({ where: { user_id: userId }, transaction });
if (authInfo) {
await AuthInfo.destroy({ where: { id: authInfo.id }, transaction });
}
await User.destroy({ where: { id: userId }, transaction });
return true;
});
}
/** /**
* Benutzer-Profil abrufen * Benutzer-Profil abrufen
* @param {number} userId - Benutzer-ID * @param {number} userId - Benutzer-ID
@@ -700,4 +781,3 @@ ${frontendUrl}
} }
module.exports = new AuthService(); module.exports = new AuthService();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 176 KiB

View File

@@ -78,6 +78,8 @@ const pageTitle = computed(() => {
'settings-timewish': 'Zeitwünsche', 'settings-timewish': 'Zeitwünsche',
'settings-invite': 'Einladen', 'settings-invite': 'Einladen',
'settings-permissions': 'Berechtigungen', 'settings-permissions': 'Berechtigungen',
'privacy': 'Datenschutz',
'account-deletion': 'Account löschen',
'export': 'Export', 'export': 'Export',
'entries': 'Einträge', 'entries': 'Einträge',
'stats': 'Statistiken' 'stats': 'Statistiken'
@@ -280,4 +282,3 @@ const pageTitle = computed(() => {
font-size: 0.9rem; font-size: 0.9rem;
} }
</style> </style>

View File

@@ -82,7 +82,9 @@ const SECTIONS_USER = [
{ label: 'Paßwort ändern', to: '/settings/password' }, { label: 'Paßwort ändern', to: '/settings/password' },
{ label: 'Zeitwünsche', to: '/settings/timewish' }, { label: 'Zeitwünsche', to: '/settings/timewish' },
{ label: 'Zugriffe verwalten', to: '/settings/permissions' }, { label: 'Zugriffe verwalten', to: '/settings/permissions' },
{ label: 'Einladen', to: '/settings/invite' } { label: 'Einladen', to: '/settings/invite' },
{ label: 'Account löschen', to: '/account-loeschen' },
{ label: 'Datenschutz', to: '/datenschutz' }
] ]
} }
] ]
@@ -189,5 +191,3 @@ const toggleSection = (title) => {
font-weight: 500; font-weight: 500;
} }
</style> </style>

View File

@@ -9,6 +9,8 @@ import Register from '../views/Register.vue'
import PasswordForgot from '../views/PasswordForgot.vue' import PasswordForgot from '../views/PasswordForgot.vue'
import PasswordReset from '../views/PasswordReset.vue' import PasswordReset from '../views/PasswordReset.vue'
import OAuthCallback from '../views/OAuthCallback.vue' import OAuthCallback from '../views/OAuthCallback.vue'
import AccountDeletion from '../views/AccountDeletion.vue'
import Privacy from '../views/Privacy.vue'
import WeekOverview from '../views/WeekOverview.vue' import WeekOverview from '../views/WeekOverview.vue'
import Timefix from '../views/Timefix.vue' import Timefix from '../views/Timefix.vue'
import Vacation from '../views/Vacation.vue' import Vacation from '../views/Vacation.vue'
@@ -57,6 +59,16 @@ const router = createRouter({
name: 'oauth-callback', name: 'oauth-callback',
component: OAuthCallback component: OAuthCallback
}, },
{
path: '/datenschutz',
name: 'privacy',
component: Privacy
},
{
path: '/account-loeschen',
name: 'account-deletion',
component: AccountDeletion
},
// Geschützte Routes // Geschützte Routes
{ {
@@ -192,4 +204,3 @@ router.beforeEach(async (to, from, next) => {
}) })
export default router export default router

View File

@@ -0,0 +1,123 @@
<template>
<div class="legal-page">
<div class="legal-card">
<AppBrand class="legal-brand" />
<h1>Account und Daten löschen</h1>
<p class="updated">Stand: 15.05.2026</p>
<section>
<h2>Wie kann ich meinen Account selbst löschen?</h2>
<p>
Melden Sie sich in der Web-UI an und öffnen Sie
<router-link to="/settings/profile">Persönliches</router-link>.
Dort befindet sich der Bereich <strong>Account löschen</strong>. Nach Eingabe
von <strong>ACCOUNT LÖSCHEN</strong> und Bestätigung wird der Account dauerhaft
gelöscht.
</p>
</section>
<section>
<h2>Kann ich die Löschung auch ohne App beantragen?</h2>
<p>
Ja. Senden Sie eine Löschanfrage per E-Mail an
<a href="mailto:kontakt@tsschulz.de?subject=TimeClock%20Account%20loeschen">kontakt@tsschulz.de</a>.
Bitte nennen Sie die E-Mail-Adresse des TimeClock-Accounts. Zur Vermeidung
unberechtigter Löschungen kann eine Bestätigung der Account-Inhaberschaft
erforderlich sein.
</p>
</section>
<section>
<h2>Welche Daten werden gelöscht?</h2>
<p>
Gelöscht werden Accountdaten, Anmeldedaten, Google-Verknüpfungen,
Zeitbuchungen, Zeitkorrekturen, Urlaubs- und Krankmeldungen,
Zeitwünsche, Wochenarbeitszeiten und persönliche Einstellungen.
</p>
</section>
<section>
<h2>Welche Daten können vorübergehend verbleiben?</h2>
<p>
Server-Logs und Backups können technisch bedingt bis zum Ablauf der
regulären Löschzyklen weiterbestehen. Daten können außerdem aufbewahrt
werden, soweit gesetzliche Pflichten oder die Geltendmachung, Ausübung
oder Verteidigung von Rechtsansprüchen dies erfordern.
</p>
</section>
<section>
<h2>Wie lange dauert die Bearbeitung?</h2>
<p>
Eine Löschung über die Web-UI erfolgt unmittelbar. Löschanfragen per E-Mail
werden ohne unangemessene Verzögerung bearbeitet. Nutzer erhalten eine
Rückmeldung, falls weitere Informationen zur Identitätsprüfung erforderlich sind.
</p>
</section>
<section>
<h2>Wo finde ich die Datenschutzerklärung?</h2>
<p>
Die Datenschutzerklärung ist unter
<router-link to="/datenschutz">https://stechuhr3.tsschulz.de/datenschutz</router-link>
abrufbar.
</p>
</section>
</div>
</div>
</template>
<script setup>
import AppBrand from '../components/AppBrand.vue'
</script>
<style scoped>
.legal-page {
min-height: 100vh;
background: #f7f7f7;
padding: 32px 16px;
}
.legal-card {
max-width: 900px;
margin: 0 auto;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
padding: 28px;
}
.legal-brand {
margin-bottom: 20px;
}
h1 {
margin: 0 0 6px;
color: #222;
}
.updated {
margin: 0 0 24px;
color: #666;
}
section {
margin-top: 24px;
}
h2 {
font-size: 18px;
margin: 0 0 8px;
color: #222;
}
p {
color: #333;
line-height: 1.6;
}
a {
color: #2f8f3a;
}
</style>

View File

@@ -101,6 +101,10 @@
<router-link to="/password-forgot" class="link">Passwort vergessen</router-link> <router-link to="/password-forgot" class="link">Passwort vergessen</router-link>
| |
<router-link to="/register" class="link">Registrieren</router-link> <router-link to="/register" class="link">Registrieren</router-link>
|
<router-link to="/account-loeschen" class="link">Account löschen</router-link>
|
<router-link to="/datenschutz" class="link">Datenschutz</router-link>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -0,0 +1,209 @@
<template>
<div class="legal-page">
<div class="legal-card">
<AppBrand class="legal-brand" />
<h1>Datenschutzerklärung</h1>
<p class="updated">Stand: 15.05.2026</p>
<p class="notice">
Hinweis vor Veröffentlichung: Die ladungsfähige Anschrift des Verantwortlichen
muss vor Einreichung im Play Store ergänzt werden.
</p>
<section>
<h2>Wer ist verantwortlich?</h2>
<p>
Verantwortlich für die Datenverarbeitung in TimeClock ist Torsten Schulz.
Anschrift: <strong>BITTE VOR VERÖFFENTLICHUNG ERGÄNZEN</strong>.
Kontakt: <a href="mailto:kontakt@tsschulz.de">kontakt@tsschulz.de</a>.
</p>
<p>
Ein Datenschutzbeauftragter ist nicht benannt, sofern keine gesetzliche
Pflicht zur Benennung besteht.
</p>
</section>
<section>
<h2>Wofür wird TimeClock genutzt?</h2>
<p>
TimeClock ist eine Zeiterfassungsanwendung. Die App verarbeitet Daten zur
Anmeldung, Zeiterfassung, Korrektur, Auswertung und Verwaltung persönlicher
Arbeitszeiten.
</p>
</section>
<section>
<h2>Welche Daten werden verarbeitet?</h2>
<ul>
<li>Accountdaten: Name, E-Mail-Adresse, Passwort-Hash, Login-Status</li>
<li>Zeiterfassungsdaten: Kommen, Gehen, Pausen, Korrekturen, Zeitstempel</li>
<li>Abwesenheitsdaten: Urlaub, Krankheit, Feiertage, Arbeitszeitwünsche</li>
<li>Einstellungen: Bundesland, Soll-Arbeitszeit, Anzeigeoptionen, Berechtigungen</li>
<li>Technische Daten: IP-Adresse, Zugriffszeitpunkt, Geräte-/Browserinformationen, Server-Logs</li>
<li>Bei optionaler Google-Anmeldung: Google-Konto-ID, E-Mail-Adresse und Anzeigename</li>
</ul>
</section>
<section>
<h2>Auf welchen Rechtsgrundlagen erfolgt die Verarbeitung?</h2>
<ul>
<li>Art. 6 Abs. 1 lit. b DSGVO für Account, Login und die Bereitstellung der Zeiterfassung</li>
<li>Art. 6 Abs. 1 lit. c DSGVO, soweit gesetzliche Aufbewahrungs- oder Nachweispflichten bestehen</li>
<li>Art. 6 Abs. 1 lit. f DSGVO für Sicherheit, Fehleranalyse, Missbrauchsschutz und Server-Logs</li>
<li>Art. 6 Abs. 1 lit. a DSGVO für optionale Funktionen, soweit eine Einwilligung erforderlich ist</li>
</ul>
</section>
<section>
<h2>Wird Google-Anmeldung verwendet?</h2>
<p>
Ja, optional. Nutzer können sich mit Google anmelden oder ein bestehendes
Konto mit Google verknüpfen. Dabei werden nur die für die Anmeldung
erforderlichen Profilinformationen verwendet. Das Google-Passwort wird
nicht an TimeClock übermittelt oder gespeichert.
</p>
</section>
<section>
<h2>Wer erhält Daten?</h2>
<p>
Personenbezogene Daten werden nicht verkauft. Eine Weitergabe erfolgt nur,
soweit sie für Betrieb, Authentifizierung, Hosting, E-Mail-Versand,
Fehleranalyse oder gesetzliche Pflichten erforderlich ist. Eingesetzte
Kategorien von Empfängern sind Hosting-/Serveranbieter, E-Mail-Dienstleister
und Google als OAuth-Anbieter, wenn Google-Anmeldung genutzt wird.
</p>
</section>
<section>
<h2>Werden Daten in Drittländer übertragen?</h2>
<p>
Beim Einsatz von Google-Anmeldung kann eine Verarbeitung durch Google auch
außerhalb der EU/des EWR stattfinden. Für den regulären Betrieb der
TimeClock-Datenbank ist kein Verkauf oder keine werbliche Weitergabe von
Nutzerdaten vorgesehen.
</p>
</section>
<section>
<h2>Wie lange werden Daten gespeichert?</h2>
<p>
Account-, Zeit- und Einstellungsdaten werden grundsätzlich gespeichert,
solange der Account besteht. Nach Account-Löschung werden die aktiven
Accountdaten, Anmeldedaten, Google-Verknüpfungen, Zeitbuchungen,
Korrekturen, Urlaubs- und Krankmeldungen sowie persönliche Einstellungen
gelöscht. Server-Logs und Backups können technisch bedingt für einen
begrenzten Zeitraum weiterbestehen und werden im Rahmen der regulären
Löschzyklen entfernt. Daten können länger aufbewahrt werden, wenn gesetzliche
Pflichten oder die Geltendmachung, Ausübung oder Verteidigung von
Rechtsansprüchen dies erfordern.
</p>
</section>
<section>
<h2>Wie kann der Account gelöscht werden?</h2>
<p>
Eingeloggte Nutzer können ihren Account in der Web-UI unter
<strong>Persönliches</strong> löschen. Zusätzlich steht eine öffentliche
Löschanfrage-Seite zur Verfügung:
<router-link to="/account-loeschen">Account und Daten löschen</router-link>.
</p>
</section>
<section>
<h2>Welche Rechte haben Nutzer?</h2>
<p>
Nutzer können Auskunft, Berichtigung, Löschung, Einschränkung der
Verarbeitung und Datenübertragbarkeit verlangen sowie einer Verarbeitung
widersprechen. Außerdem besteht ein Beschwerderecht bei einer zuständigen
Datenschutzaufsichtsbehörde. Anfragen können an
<a href="mailto:kontakt@tsschulz.de">kontakt@tsschulz.de</a> gerichtet werden.
</p>
</section>
<section>
<h2>Wie werden Daten geschützt?</h2>
<p>
Die Übertragung erfolgt über HTTPS. Passwörter werden nicht im Klartext
gespeichert. Zugriffstokens und OAuth-Verknüpfungen werden serverseitig
verwaltet. Zugriffe werden auf das erforderliche Maß beschränkt.
</p>
</section>
<section>
<h2>Kann sich diese Erklärung ändern?</h2>
<p>
Ja. Diese Datenschutzerklärung kann angepasst werden, wenn sich Funktionen,
technische Abläufe oder rechtliche Anforderungen ändern.
</p>
</section>
</div>
</div>
</template>
<script setup>
import AppBrand from '../components/AppBrand.vue'
</script>
<style scoped>
.legal-page {
min-height: 100vh;
background: #f7f7f7;
padding: 32px 16px;
}
.legal-card {
max-width: 900px;
margin: 0 auto;
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
padding: 28px;
}
.legal-brand {
margin-bottom: 20px;
}
h1 {
margin: 0 0 6px;
color: #222;
}
.updated {
margin: 0 0 16px;
color: #666;
}
.notice {
padding: 10px 12px;
border: 1px solid #f0ad4e;
background: #fcf8e3;
border-radius: 4px;
color: #6f4e00;
}
section {
margin-top: 24px;
}
h2 {
font-size: 18px;
margin: 0 0 8px;
color: #222;
}
p,
li {
color: #333;
line-height: 1.6;
}
ul {
padding-left: 22px;
}
a {
color: #2f8f3a;
}
</style>

View File

@@ -106,6 +106,31 @@
</button> </button>
</div> </div>
</form> </form>
<section class="danger-zone">
<h2>Account löschen</h2>
<p>
Löscht den Account, die Anmeldung, Google-Verknüpfungen, Zeitbuchungen,
Korrekturen, Urlaubs- und Krankmeldungen sowie persönliche Einstellungen dauerhaft.
</p>
<label for="deleteConfirmation">
Zur Bestätigung bitte <strong>ACCOUNT LÖSCHEN</strong> eingeben
</label>
<input
id="deleteConfirmation"
v-model="deleteConfirmation"
type="text"
autocomplete="off"
>
<button
type="button"
class="btn btn-danger"
:disabled="deleting || deleteConfirmation !== deletePhrase"
@click="deleteAccount"
>
{{ deleting ? 'Account wird gelöscht...' : 'Account dauerhaft löschen' }}
</button>
</section>
</div> </div>
<!-- Modal-Komponente --> <!-- Modal-Komponente -->
@@ -138,6 +163,9 @@ const authStore = useAuthStore()
const availableStates = ref([]) const availableStates = ref([])
const identities = ref([]) const identities = ref([])
const loading = ref(false) const loading = ref(false)
const deleting = ref(false)
const deletePhrase = 'ACCOUNT LÖSCHEN'
const deleteConfirmation = ref('')
const { showModal, modalConfig, alert, confirm, onConfirm, onCancel } = useModal() const { showModal, modalConfig, alert, confirm, onConfirm, onCancel } = useModal()
@@ -302,6 +330,35 @@ async function unlinkGoogle() {
} }
} }
async function deleteAccount() {
const confirmed = await confirm(
'Der Account und alle personenbezogenen Daten werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.',
'Account dauerhaft löschen'
)
if (!confirmed) return
try {
deleting.value = true
const response = await fetch(`${API_URL}/auth/account`, {
method: 'DELETE',
headers: authStore.getAuthHeaders(),
body: JSON.stringify({ confirmation: deleteConfirmation.value })
})
const result = await response.json()
if (!response.ok || !result.success) {
throw new Error(result.error || 'Account konnte nicht gelöscht werden')
}
await alert('Account wurde gelöscht', 'Erfolg')
authStore.clearAuth()
router.push('/login')
} catch (error) {
await alert(`Fehler: ${error.message}`, 'Fehler')
} finally {
deleting.value = false
}
}
// Initiales Laden // Initiales Laden
onMounted(async () => { onMounted(async () => {
await Promise.all([ await Promise.all([
@@ -408,6 +465,41 @@ onMounted(async () => {
margin-top: 10px; margin-top: 10px;
} }
.danger-zone {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #f0c2c2;
display: flex;
flex-direction: column;
gap: 10px;
}
.danger-zone h2 {
margin: 0;
color: #a94442;
font-size: 20px;
}
.danger-zone p {
margin: 0;
color: #555;
line-height: 1.5;
}
.danger-zone label {
font-weight: 600;
font-size: 14px;
color: #333;
}
.danger-zone input {
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
}
.btn { .btn {
padding: 10px 20px; padding: 10px 20px;
border: none; border: none;
@@ -434,4 +526,13 @@ onMounted(async () => {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(76, 175, 80, 0.4); box-shadow: 0 4px 8px rgba(76, 175, 80, 0.4);
} }
.btn-danger {
background: #d9534f;
color: white;
}
.btn-danger:hover:not(:disabled) {
background: #c9302c;
}
</style> </style>

View File

@@ -83,6 +83,8 @@
<div class="buttons"> <div class="buttons">
Bereits registriert? Bereits registriert?
<router-link to="/login" class="link">Jetzt einloggen</router-link> <router-link to="/login" class="link">Jetzt einloggen</router-link>
|
<router-link to="/datenschutz" class="link">Datenschutz</router-link>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -23,8 +23,8 @@ android {
applicationId = "de.tsschulz.timeclock" applicationId = "de.tsschulz.timeclock"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 6 versionCode = 7
versionName = "0.8.0-alpha5" versionName = "0.8.0-alpha6"
buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"") buildConfigField("String", "API_BASE_URL", "\"${apiBaseUrl.replace("\\", "\\\\").replace("\"", "\\\"")}\"")
} }

View File

@@ -159,6 +159,7 @@ fun TimeClockApp(
settingsViewModel = settingsViewModel, settingsViewModel = settingsViewModel,
adminState = adminState, adminState = adminState,
adminViewModel = adminViewModel, adminViewModel = adminViewModel,
openUrl = openUrl,
) )
} }
} }
@@ -181,6 +182,7 @@ private fun DemoScreen(
settingsViewModel: SettingsViewModel, settingsViewModel: SettingsViewModel,
adminState: de.tsschulz.timeclock.ui.admin.AdminUiState, adminState: de.tsschulz.timeclock.ui.admin.AdminUiState,
adminViewModel: AdminViewModel, adminViewModel: AdminViewModel,
openUrl: (String) -> Unit,
) { ) {
when (route) { when (route) {
AppRoute.Week -> WeekOverviewScreen( AppRoute.Week -> WeekOverviewScreen(
@@ -289,6 +291,10 @@ private fun DemoScreen(
onSend = { settingsViewModel.sendInvite(it) }, onSend = { settingsViewModel.sendInvite(it) },
) )
} }
AppRoute.AccountDeletion -> AccountDeletionScreen(
onOpenDeletionPage = { openUrl("https://stechuhr3.tsschulz.de/account-loeschen") }
)
AppRoute.Privacy -> PrivacyScreen()
AppRoute.Holidays -> HolidaysAdminScreen( AppRoute.Holidays -> HolidaysAdminScreen(
state = adminState, state = adminState,
isTablet = isTablet, isTablet = isTablet,
@@ -302,6 +308,79 @@ private fun DemoScreen(
} }
} }
@Composable
private fun AccountDeletionScreen(onOpenDeletionPage: () -> Unit) {
TcCard {
SectionTitle("Account und Daten löschen")
PrivacySection(
"Wie lösche ich meinen Account?",
"Die vollständige Account-Löschung erfolgt in der Web-UI unter Persönliches. Dort werden Accountdaten, Anmeldedaten, Google-Verknüpfungen, Zeitbuchungen, Korrekturen, Urlaubs- und Krankmeldungen sowie persönliche Einstellungen gelöscht."
)
PrivacySection(
"Kann ich die Löschung ohne App beantragen?",
"Ja. Die öffentliche Löschseite enthält die Schritte und eine Kontaktmöglichkeit per E-Mail."
)
Row(modifier = Modifier.padding(top = TcSpacing.Lg)) {
TcButton(
text = "Löschseite öffnen",
variant = ButtonVariant.Danger,
onClick = onOpenDeletionPage,
)
}
}
}
@Composable
private fun PrivacyScreen() {
TcCard {
SectionTitle("Datenschutzerklärung")
Text("Stand: 15.05.2026", color = TcColors.TextMuted, fontSize = 13.sp)
PrivacySection(
"Verantwortlicher",
"Verantwortlich für die Datenverarbeitung in TimeClock ist Torsten Schulz. Anschrift: BITTE VOR VERÖFFENTLICHUNG ERGÄNZEN. Kontakt: kontakt@tsschulz.de."
)
PrivacySection(
"Zweck der App",
"TimeClock ist eine Zeiterfassungsanwendung. Die App verarbeitet Daten zur Anmeldung, Zeiterfassung, Auswertung und Verwaltung persönlicher Arbeitszeiten."
)
PrivacySection(
"Welche Daten werden verarbeitet?",
"Verarbeitet werden Accountdaten, Zeitbuchungen, Korrekturen, Urlaub, Krankheit, Feiertage, Arbeitszeitwünsche, Einstellungen, technische Zugriffsdaten und bei Google-Anmeldung die von Google übermittelte Konto-ID, E-Mail-Adresse und der Anzeigename."
)
PrivacySection(
"Rechtsgrundlagen",
"Die Verarbeitung erfolgt insbesondere zur Bereitstellung der Zeiterfassung, zur Erfüllung gesetzlicher Pflichten sowie für Sicherheit, Fehleranalyse und Missbrauchsschutz."
)
PrivacySection(
"Google-Anmeldung",
"Die Google-Anmeldung ist optional. Das Google-Passwort wird nicht an TimeClock übermittelt oder gespeichert."
)
PrivacySection(
"Speicherung und Löschung",
"Daten werden gespeichert, solange der Account besteht oder gesetzliche Aufbewahrungspflichten bestehen. Nutzer können ihren Account in der Web-UI unter Persönliches löschen oder eine Löschung über https://stechuhr3.tsschulz.de/account-loeschen beantragen."
)
PrivacySection(
"Rechte der Nutzer",
"Nutzer können Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit verlangen sowie einer Verarbeitung widersprechen. Anfragen können an kontakt@tsschulz.de gerichtet werden."
)
PrivacySection(
"Sicherheit",
"Die Übertragung erfolgt über HTTPS. Passwörter werden nicht im Klartext gespeichert. Zugriffstokens und OAuth-Verknüpfungen werden serverseitig verwaltet."
)
}
}
@Composable
private fun PrivacySection(title: String, body: String) {
Column(
modifier = Modifier.padding(top = TcSpacing.Lg),
verticalArrangement = Arrangement.spacedBy(TcSpacing.Xs),
) {
Text(title, color = TcColors.Text, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
Text(body, color = TcColors.Text, fontSize = 14.sp, lineHeight = 20.sp)
}
}
@Composable @Composable
private fun WeekOverviewScreen( private fun WeekOverviewScreen(
week: WeekOverviewDto?, week: WeekOverviewDto?,

View File

@@ -20,6 +20,8 @@ val userSections = listOf(
MenuItem("Zeitwünsche", AppRoute.Timewish), MenuItem("Zeitwünsche", AppRoute.Timewish),
MenuItem("Zugriffe verwalten", AppRoute.Permissions), MenuItem("Zugriffe verwalten", AppRoute.Permissions),
MenuItem("Einladen", AppRoute.Invite), MenuItem("Einladen", AppRoute.Invite),
MenuItem("Account löschen", AppRoute.AccountDeletion),
MenuItem("Datenschutz", AppRoute.Privacy),
), ),
), ),
MenuSection( MenuSection(

View File

@@ -25,6 +25,8 @@ enum class AppRoute(val title: String) {
Timewish("Zeitwünsche"), Timewish("Zeitwünsche"),
Permissions("Zugriffe verwalten"), Permissions("Zugriffe verwalten"),
Invite("Einladen"), Invite("Einladen"),
AccountDeletion("Account löschen"),
Privacy("Datenschutz"),
Holidays("Feiertage"), Holidays("Feiertage"),
Roles("Rechte"), Roles("Rechte"),
} }