feat(navigation): enhance adult verification handling and notifications

- Updated navigationController to simplify the eroticChat menu structure.
- Enhanced adminService to notify users of adult verification status changes, including previous status.
- Improved AppNavigation and related components to register and unregister socket listeners for adult verification updates.
- Added localized messages for adult verification notifications in English, German, and Spanish.
- Introduced a verification hint in the EroticAccessView to guide users on document submission.
This commit is contained in:
Torsten Schulz (local)
2026-03-27 13:23:44 +01:00
parent 82223676a6
commit 07604cc9fa
8 changed files with 175 additions and 35 deletions

View File

@@ -96,9 +96,7 @@ const menuStructure = {
}, },
eroticChat: { eroticChat: {
visible: ["over18"], visible: ["over18"],
action: "openEroticChat", action: "openEroticChat"
view: "window",
class: "eroticChatWindow"
} }
} }
}, },

View File

@@ -35,6 +35,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { getAdultVerificationBaseDir, getLegacyAdultVerificationBaseDir } from '../utils/storagePaths.js'; import { getAdultVerificationBaseDir, getLegacyAdultVerificationBaseDir } from '../utils/storagePaths.js';
import { notifyUser } from '../utils/socket.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -385,6 +386,8 @@ class AdminService {
where: { userId: user.id, paramTypeId: paramType.id } where: { userId: user.id, paramTypeId: paramType.id }
}); });
const previousStatus = existing?.value || 'none';
if (existing) { if (existing) {
await existing.update({ value: status }); await existing.update({ value: status });
} else { } else {
@@ -395,6 +398,12 @@ class AdminService {
}); });
} }
await notifyUser(targetHashedId, 'reloadmenu', {});
await notifyUser(targetHashedId, 'adultVerificationChanged', {
status,
previousStatus
});
return { success: true }; return { success: true };
} }

View File

@@ -184,6 +184,7 @@
import { mapGetters, mapActions } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import apiClient from '@/utils/axios.js'; import apiClient from '@/utils/axios.js';
import { EventBus } from '@/utils/eventBus.js'; import { EventBus } from '@/utils/eventBus.js';
import { showInfo } from '@/utils/feedback.js';
export default { export default {
name: 'AppNavigation', name: 'AppNavigation',
@@ -198,7 +199,11 @@ export default {
pinnedSubKey: null, pinnedSubKey: null,
suppressHover: false, suppressHover: false,
hoverReleaseTimer: null, hoverReleaseTimer: null,
isMobileNav: false isMobileNav: false,
_forumsChangedHandler: null,
_friendLoginChangedHandler: null,
_reloadMenuHandler: null,
_adultVerificationChangedHandler: null
}; };
}, },
computed: { computed: {
@@ -212,10 +217,9 @@ export default {
this.collapseMenus(); this.collapseMenus();
}, },
socket(newSocket) { socket(newSocket) {
this.unregisterSocketListeners();
if (newSocket) { if (newSocket) {
newSocket.on('forumschanged', this.fetchForums); this.registerSocketListeners(newSocket);
newSocket.on('friendloginchanged', this.fetchFriends);
newSocket.on('reloadmenu', this.loadMenu);
} }
} }
}, },
@@ -230,14 +234,12 @@ export default {
window.addEventListener('resize', this.updateViewportState); window.addEventListener('resize', this.updateViewportState);
document.addEventListener('click', this.handleDocumentClick); document.addEventListener('click', this.handleDocumentClick);
document.addEventListener('keydown', this.handleDocumentKeydown); document.addEventListener('keydown', this.handleDocumentKeydown);
if (this.socket) {
this.registerSocketListeners(this.socket);
}
}, },
beforeUnmount() { beforeUnmount() {
const sock = this.socket; this.unregisterSocketListeners();
if (sock) {
sock.off('forumschanged');
sock.off('friendloginchanged');
sock.off('reloadmenu');
}
window.removeEventListener('resize', this.updateViewportState); window.removeEventListener('resize', this.updateViewportState);
document.removeEventListener('click', this.handleDocumentClick); document.removeEventListener('click', this.handleDocumentClick);
document.removeEventListener('keydown', this.handleDocumentKeydown); document.removeEventListener('keydown', this.handleDocumentKeydown);
@@ -248,6 +250,38 @@ export default {
methods: { methods: {
...mapActions(['loadMenu', 'logout']), ...mapActions(['loadMenu', 'logout']),
registerSocketListeners(sock) {
if (!sock) return;
this._forumsChangedHandler = () => this.fetchForums();
this._friendLoginChangedHandler = () => this.fetchFriends();
this._reloadMenuHandler = () => this.loadMenu();
this._adultVerificationChangedHandler = async (payload = {}) => {
await this.loadMenu();
if (payload.status === 'approved') {
showInfo(this, this.$t('socialnetwork.erotic.notifications.approved'));
} else if (payload.status === 'rejected') {
showInfo(this, this.$t('socialnetwork.erotic.notifications.rejected'));
}
};
sock.on('forumschanged', this._forumsChangedHandler);
sock.on('friendloginchanged', this._friendLoginChangedHandler);
sock.on('reloadmenu', this._reloadMenuHandler);
sock.on('adultVerificationChanged', this._adultVerificationChangedHandler);
},
unregisterSocketListeners() {
const sock = this.socket;
if (!sock) return;
if (this._forumsChangedHandler) sock.off('forumschanged', this._forumsChangedHandler);
if (this._friendLoginChangedHandler) sock.off('friendloginchanged', this._friendLoginChangedHandler);
if (this._reloadMenuHandler) sock.off('reloadmenu', this._reloadMenuHandler);
if (this._adultVerificationChangedHandler) sock.off('adultVerificationChanged', this._adultVerificationChangedHandler);
this._forumsChangedHandler = null;
this._friendLoginChangedHandler = null;
this._reloadMenuHandler = null;
this._adultVerificationChangedHandler = null;
},
updateViewportState() { updateViewportState() {
this.isMobileNav = window.innerWidth <= 960; this.isMobileNav = window.innerWidth <= 960;
if (!this.isMobileNav) { if (!this.isMobileNav) {
@@ -458,8 +492,8 @@ export default {
/** /**
* Einheitliche KlickLogik: * Einheitliche KlickLogik:
* 1) Nur aufklappen, wenn noch Untermenüs existieren * 1) Nur aufklappen, wenn noch Untermenüs existieren
* 2) Bei `view`: Dialog/Window öffnen * 2) Bei `action`: custom action aufrufen
* 3) Bei `action`: custom action aufrufen * 3) Bei `view`: Dialog/Window öffnen
* 4) Sonst: normale Router-Navigation * 4) Sonst: normale Router-Navigation
*/ */
handleItem(item, event, key = null) { handleItem(item, event, key = null) {
@@ -481,7 +515,14 @@ export default {
if (this.hasChildren(item)) return; if (this.hasChildren(item)) return;
// 2) view → Dialog/Window // 2) custom action (openForum, openChat, ...)
if (item.action && typeof this[item.action] === 'function') {
this[item.action](item.params, event);
this.collapseMenus();
return;
}
// 3) view → Dialog/Window
if (item.view) { if (item.view) {
const dialogRef = this.$root.$refs[item.class]; const dialogRef = this.$root.$refs[item.class];
if (!dialogRef) { if (!dialogRef) {
@@ -500,13 +541,6 @@ export default {
return; return;
} }
// 3) custom action (openForum, openChat, ...)
if (item.action && typeof this[item.action] === 'function') {
this[item.action](item.params, event);
this.collapseMenus();
return;
}
// 4) StandardNavigation // 4) StandardNavigation
if (item.path) { if (item.path) {
this.$router.push(item.path); this.$router.push(item.path);

View File

@@ -261,6 +261,12 @@
"documentLabel": "Nachweisdatei", "documentLabel": "Nachweisdatei",
"noteLabel": "Kurze Notiz für die Moderation", "noteLabel": "Kurze Notiz für die Moderation",
"settingsLink": "Account-Einstellungen öffnen", "settingsLink": "Account-Einstellungen öffnen",
"verificationHintTitle": "Hinweis zum Nachweis",
"verificationHintBody": "Du kannst ein Foto senden. Wenn dein Alter darauf nicht eindeutig erkennbar ist, wird der Antrag abgelehnt und du musst stattdessen einen Ausweis einreichen.",
"notifications": {
"approved": "Dein Erotikbereich wurde von der Moderation freigeschaltet.",
"rejected": "Dein Antrag auf den Erotikbereich wurde abgelehnt. Wenn dein Alter auf Fotos nicht eindeutig erkennbar ist, sende bitte einen Ausweis."
},
"picturesTitle": "Erotikbilder", "picturesTitle": "Erotikbilder",
"picturesIntro": "Eigene Inhalte bleiben strikt vom normalen Galeriebereich getrennt. Hier verwaltest du nur Bilder für den freigeschalteten Erotikbereich.", "picturesIntro": "Eigene Inhalte bleiben strikt vom normalen Galeriebereich getrennt. Hier verwaltest du nur Bilder für den freigeschalteten Erotikbereich.",
"uploadTitle": "Erotikbild hochladen", "uploadTitle": "Erotikbild hochladen",

View File

@@ -261,6 +261,12 @@
"documentLabel": "Verification document", "documentLabel": "Verification document",
"noteLabel": "Short note for moderation", "noteLabel": "Short note for moderation",
"settingsLink": "Open account settings", "settingsLink": "Open account settings",
"verificationHintTitle": "Verification note",
"verificationHintBody": "You may submit a photo. If your age is not clearly recognizable there, the request will be rejected and you will need to submit an ID instead.",
"notifications": {
"approved": "Your erotic area access has been approved by moderation.",
"rejected": "Your erotic area request was rejected. If your age is not clearly recognizable in photos, please submit an ID."
},
"picturesTitle": "Erotic pictures", "picturesTitle": "Erotic pictures",
"picturesIntro": "Your content stays strictly separate from the normal gallery. Manage only images for the unlocked erotic area here.", "picturesIntro": "Your content stays strictly separate from the normal gallery. Manage only images for the unlocked erotic area here.",
"uploadTitle": "Upload erotic picture", "uploadTitle": "Upload erotic picture",

View File

@@ -261,6 +261,12 @@
"documentLabel": "Documento de verificación", "documentLabel": "Documento de verificación",
"noteLabel": "Breve nota para moderación", "noteLabel": "Breve nota para moderación",
"settingsLink": "Abrir ajustes de la cuenta", "settingsLink": "Abrir ajustes de la cuenta",
"verificationHintTitle": "Nota sobre la verificación",
"verificationHintBody": "Puedes enviar una foto. Si tu edad no se reconoce con claridad, la solicitud será rechazada y tendrás que enviar un documento de identidad.",
"notifications": {
"approved": "La moderación ha aprobado tu acceso al área erótica.",
"rejected": "Tu solicitud para el área erótica fue rechazada. Si tu edad no se reconoce claramente en las fotos, envía un documento de identidad."
},
"picturesTitle": "Imágenes eróticas", "picturesTitle": "Imágenes eróticas",
"picturesIntro": "Tus contenidos permanecen estrictamente separados de la galería normal. Aquí gestionas solo imágenes del área erótica desbloqueada.", "picturesIntro": "Tus contenidos permanecen estrictamente separados de la galería normal. Aquí gestionas solo imágenes del área erótica desbloqueada.",
"uploadTitle": "Subir imagen erótica", "uploadTitle": "Subir imagen erótica",

View File

@@ -69,7 +69,7 @@
<script> <script>
import apiClient from '@/utils/axios.js'; import apiClient from '@/utils/axios.js';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { showApiError, showError, showSuccess } from '@/utils/feedback.js'; import { showApiError, showError, showInfo, showSuccess } from '@/utils/feedback.js';
export default { export default {
name: "AccountSettingsView", name: "AccountSettingsView",
@@ -88,7 +88,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapGetters(['user']), ...mapGetters(['user', 'socket']),
requiresOldPassword() { requiresOldPassword() {
return this.newpassword.trim().length > 0; return this.newpassword.trim().length > 0;
}, },
@@ -115,6 +115,33 @@ export default {
} }
}, },
methods: { methods: {
async loadAccount() {
const response = await apiClient.post('/api/settings/account', { userId: this.user.id });
this.username = response.data.username;
this.showInSearch = response.data.showinsearch;
this.email = response.data.email;
this.isAdult = !!response.data.isAdult;
this.adultVerificationStatus = response.data.adultVerificationStatus || 'none';
this.adultVerificationRequest = response.data.adultVerificationRequest || null;
},
registerSocketListeners(sock) {
if (!sock) return;
this._adultVerificationChangedHandler = async (payload = {}) => {
await this.loadAccount();
if (payload.status === 'approved') {
showInfo(this, this.$t('socialnetwork.erotic.notifications.approved'));
} else if (payload.status === 'rejected') {
showInfo(this, this.$t('socialnetwork.erotic.notifications.rejected'));
}
};
sock.on('adultVerificationChanged', this._adultVerificationChangedHandler);
},
unregisterSocketListeners() {
if (this.socket && this._adultVerificationChangedHandler) {
this.socket.off('adultVerificationChanged', this._adultVerificationChangedHandler);
}
this._adultVerificationChangedHandler = null;
},
async changeAccount() { async changeAccount() {
try { try {
// Prüfe ob ein neues Passwort eingegeben wurde // Prüfe ob ein neues Passwort eingegeben wurde
@@ -172,19 +199,27 @@ export default {
}, },
async mounted() { async mounted() {
const response = await apiClient.post('/api/settings/account', { userId: this.user.id }); if (this.socket) {
this.username = response.data.username; this.registerSocketListeners(this.socket);
this.showInSearch = response.data.showinsearch; }
this.email = response.data.email; await this.loadAccount();
this.isAdult = !!response.data.isAdult;
this.adultVerificationStatus = response.data.adultVerificationStatus || 'none';
this.adultVerificationRequest = response.data.adultVerificationRequest || null;
// Stelle sicher, dass Passwort-Felder leer sind // Stelle sicher, dass Passwort-Felder leer sind
this.newpassword = ''; this.newpassword = '';
this.newpasswordretype = ''; this.newpasswordretype = '';
this.oldpassword = ''; this.oldpassword = '';
}, },
watch: {
socket(newSocket) {
this.unregisterSocketListeners();
if (newSocket) {
this.registerSocketListeners(newSocket);
}
}
},
beforeUnmount() {
this.unregisterSocketListeners();
}
}; };
</script> </script>

View File

@@ -27,6 +27,11 @@
</router-link> </router-link>
</div> </div>
<div class="erotic-access-hint">
<strong>{{ $t('socialnetwork.erotic.verificationHintTitle') }}</strong>
<span>{{ $t('socialnetwork.erotic.verificationHintBody') }}</span>
</div>
<form v-if="canRequestVerification" class="erotic-access-form" @submit.prevent="requestVerification"> <form v-if="canRequestVerification" class="erotic-access-form" @submit.prevent="requestVerification">
<label> <label>
<span>{{ $t('socialnetwork.erotic.documentLabel') }}</span> <span>{{ $t('socialnetwork.erotic.documentLabel') }}</span>
@@ -47,7 +52,7 @@
<script> <script>
import apiClient from '@/utils/axios.js'; import apiClient from '@/utils/axios.js';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { showApiError, showSuccess } from '@/utils/feedback.js'; import { showApiError, showInfo, showSuccess } from '@/utils/feedback.js';
export default { export default {
name: 'EroticAccessView', name: 'EroticAccessView',
@@ -59,7 +64,7 @@ export default {
}; };
}, },
computed: { computed: {
...mapGetters(['user']), ...mapGetters(['user', 'socket']),
status() { status() {
return this.account?.adultVerificationStatus || 'none'; return this.account?.adultVerificationStatus || 'none';
}, },
@@ -89,6 +94,25 @@ export default {
const response = await apiClient.post('/api/settings/account', { userId: this.user.id }); const response = await apiClient.post('/api/settings/account', { userId: this.user.id });
this.account = response.data; this.account = response.data;
}, },
registerSocketListeners(sock) {
if (!sock) return;
this._adultVerificationChangedHandler = async (payload = {}) => {
await this.loadAccount();
if (payload.status === 'approved') {
showInfo(this, this.$t('socialnetwork.erotic.notifications.approved'));
this.$router.replace('/socialnetwork/erotic/pictures');
} else if (payload.status === 'rejected') {
showInfo(this, this.$t('socialnetwork.erotic.notifications.rejected'));
}
};
sock.on('adultVerificationChanged', this._adultVerificationChangedHandler);
},
unregisterSocketListeners() {
if (this.socket && this._adultVerificationChangedHandler) {
this.socket.off('adultVerificationChanged', this._adultVerificationChangedHandler);
}
this._adultVerificationChangedHandler = null;
},
handleFileChange(event) { handleFileChange(event) {
this.documentFile = event.target.files?.[0] || null; this.documentFile = event.target.files?.[0] || null;
}, },
@@ -114,10 +138,24 @@ export default {
} }
}, },
async mounted() { async mounted() {
if (this.socket) {
this.registerSocketListeners(this.socket);
}
await this.loadAccount(); await this.loadAccount();
if (this.account?.adultAccessEnabled) { if (this.account?.adultAccessEnabled) {
this.$router.replace('/socialnetwork/erotic/pictures'); this.$router.replace('/socialnetwork/erotic/pictures');
} }
},
watch: {
socket(newSocket) {
this.unregisterSocketListeners();
if (newSocket) {
this.registerSocketListeners(newSocket);
}
}
},
beforeUnmount() {
this.unregisterSocketListeners();
} }
}; };
</script> </script>
@@ -175,7 +213,8 @@ export default {
} }
.erotic-access-request, .erotic-access-request,
.erotic-access-form { .erotic-access-form,
.erotic-access-hint {
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
@@ -187,6 +226,13 @@ export default {
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.erotic-access-hint {
padding: 14px 16px;
border-radius: var(--radius-md);
background: rgba(164, 98, 72, 0.08);
color: var(--color-text-secondary);
}
.erotic-access-actions { .erotic-access-actions {
display: flex; display: flex;
gap: 12px; gap: 12px;