Add adult verification and erotic moderation features: Implement new routes and controller methods for managing adult verification requests, status updates, and document retrieval. Introduce erotic moderation actions and reports, enhancing administrative capabilities. Update chat and navigation controllers to support adult content filtering and access control. Enhance user parameter handling for adult verification status and requests, improving overall user experience and compliance.

This commit is contained in:
Torsten Schulz (local)
2026-03-27 09:14:54 +01:00
parent f93687c753
commit 3e6c09ab29
73 changed files with 4459 additions and 197 deletions

View File

@@ -0,0 +1,258 @@
<template>
<div class="adult-verification">
<section class="adult-verification__hero surface-card">
<span class="adult-verification__eyebrow">Administration</span>
<h1>{{ $t('admin.adultVerification.title') }}</h1>
<p>{{ $t('admin.adultVerification.intro') }}</p>
</section>
<section class="adult-verification__filters surface-card">
<button
v-for="option in filterOptions"
:key="option.value"
type="button"
:class="{ active: statusFilter === option.value }"
@click="changeFilter(option.value)"
>
{{ option.label }}
</button>
</section>
<section class="adult-verification__list surface-card">
<div v-if="loading" class="adult-verification__state">{{ $t('general.loading') }}</div>
<div v-else-if="rows.length === 0" class="adult-verification__state">{{ $t('admin.adultVerification.empty') }}</div>
<table v-else>
<thead>
<tr>
<th>{{ $t('admin.adultVerification.username') }}</th>
<th>{{ $t('admin.adultVerification.age') }}</th>
<th>{{ $t('admin.adultVerification.statusLabel') }}</th>
<th>{{ $t('admin.adultVerification.requestLabel') }}</th>
<th>{{ $t('admin.adultVerification.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>{{ row.username }}</td>
<td>{{ row.age }}</td>
<td>
<span class="adult-verification__badge" :class="`adult-verification__badge--${row.adultVerificationStatus}`">
{{ $t(`admin.adultVerification.status.${row.adultVerificationStatus}`) }}
</span>
</td>
<td class="adult-verification__request">
<template v-if="row.adultVerificationRequest">
<strong>{{ row.adultVerificationRequest.originalName }}</strong>
<span v-if="row.adultVerificationRequest.note">{{ row.adultVerificationRequest.note }}</span>
<button type="button" class="secondary" @click="openDocument(row)">
{{ $t('admin.adultVerification.openDocument') }}
</button>
</template>
<span v-else></span>
</td>
<td class="adult-verification__actions">
<button type="button" @click="setStatus(row, 'approved')">{{ $t('admin.adultVerification.approve') }}</button>
<button type="button" class="secondary" @click="setStatus(row, 'rejected')">{{ $t('admin.adultVerification.reject') }}</button>
<button
v-if="row.adultVerificationStatus !== 'pending'"
type="button"
class="secondary"
@click="setStatus(row, 'pending')"
>
{{ $t('admin.adultVerification.resetPending') }}
</button>
</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import { showApiError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'AdminAdultVerificationView',
data() {
return {
loading: false,
statusFilter: 'pending',
rows: []
};
},
computed: {
filterOptions() {
return [
{ value: 'pending', label: this.$t('admin.adultVerification.filters.pending') },
{ value: 'approved', label: this.$t('admin.adultVerification.filters.approved') },
{ value: 'rejected', label: this.$t('admin.adultVerification.filters.rejected') },
{ value: 'all', label: this.$t('admin.adultVerification.filters.all') }
];
}
},
methods: {
async load() {
this.loading = true;
try {
const response = await apiClient.get('/api/admin/users/adult-verification', {
params: { status: this.statusFilter }
});
this.rows = response.data || [];
} catch (error) {
this.rows = [];
showApiError(this, error, this.$t('admin.adultVerification.loadError'));
} finally {
this.loading = false;
}
},
async changeFilter(filter) {
this.statusFilter = filter;
await this.load();
},
async setStatus(row, status) {
try {
await apiClient.put(`/api/admin/users/${row.id}/adult-verification`, { status });
showSuccess(this, this.$t(`admin.adultVerification.messages.${status}`));
await this.load();
} catch (error) {
showApiError(this, error, this.$t('admin.adultVerification.updateError'));
}
},
async openDocument(row) {
try {
const response = await apiClient.get(`/api/admin/users/${row.id}/adult-verification/document`, {
responseType: 'blob'
});
const url = window.URL.createObjectURL(response.data);
window.open(url, '_blank', 'noopener');
window.setTimeout(() => window.URL.revokeObjectURL(url), 10000);
} catch (error) {
showApiError(this, error, this.$t('admin.adultVerification.documentError'));
}
}
},
async mounted() {
await this.load();
}
};
</script>
<style scoped>
.adult-verification {
display: grid;
gap: 18px;
}
.adult-verification__hero,
.adult-verification__filters,
.adult-verification__list {
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
padding: 22px 24px;
}
.adult-verification__eyebrow {
display: inline-flex;
margin-bottom: 8px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--color-secondary-soft);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.adult-verification__hero p,
.adult-verification__state {
margin: 0;
color: var(--color-text-secondary);
}
.adult-verification__filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.adult-verification__filters button.active {
background: var(--color-primary);
color: #fff;
}
.adult-verification table {
width: 100%;
border-collapse: collapse;
}
.adult-verification th,
.adult-verification td {
padding: 12px 10px;
border-bottom: 1px solid rgba(93, 64, 55, 0.08);
text-align: left;
}
.adult-verification__badge {
display: inline-flex;
padding: 4px 10px;
border-radius: var(--radius-pill);
font-weight: 700;
font-size: 0.82rem;
}
.adult-verification__badge--pending {
background: rgba(216, 167, 65, 0.16);
}
.adult-verification__badge--approved {
background: rgba(92, 156, 106, 0.18);
}
.adult-verification__badge--rejected {
background: rgba(176, 88, 88, 0.16);
}
.adult-verification__actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.adult-verification__request {
display: grid;
gap: 4px;
}
.adult-verification__actions .secondary {
background: transparent;
}
@media (max-width: 760px) {
.adult-verification__hero,
.adult-verification__filters,
.adult-verification__list {
padding: 18px;
}
.adult-verification table,
.adult-verification thead,
.adult-verification tbody,
.adult-verification tr,
.adult-verification td {
display: block;
}
.adult-verification thead {
display: none;
}
.adult-verification td {
padding: 8px 0;
}
}
</style>

View File

@@ -9,6 +9,7 @@
<th>{{ $t('admin.chatrooms.roomName') }}</th>
<th>{{ $t('admin.chatrooms.type') }}</th>
<th>{{ $t('admin.chatrooms.isPublic') }}</th>
<th>{{ $t('admin.chatrooms.isAdultOnly') }}</th>
<th>{{ $t('admin.chatrooms.actions') }}</th>
</tr>
</thead>
@@ -17,6 +18,7 @@
<td>{{ room.title }}</td>
<td>{{ getRoomTypeLabel(room) }}</td>
<td>{{ room.isPublic ? $t('common.yes') : $t('common.no') }}</td>
<td>{{ room.isAdultOnly ? $t('common.yes') : $t('common.no') }}</td>
<td>
<button @click="editRoom(room)">{{ $t('common.edit') }}</button>
<button @click="deleteRoom(room)">{{ $t('common.delete') }}</button>

View File

@@ -0,0 +1,244 @@
<template>
<div class="adult-verification">
<section class="adult-verification__hero surface-card">
<span class="adult-verification__eyebrow">Administration</span>
<h1>{{ $t('admin.eroticModeration.title') }}</h1>
<p>{{ $t('admin.eroticModeration.intro') }}</p>
</section>
<section class="adult-verification__filters surface-card">
<button
v-for="option in filterOptions"
:key="option.value"
type="button"
:class="{ active: statusFilter === option.value }"
@click="changeFilter(option.value)"
>
{{ option.label }}
</button>
</section>
<section class="adult-verification__list surface-card">
<div v-if="loading" class="adult-verification__state">{{ $t('general.loading') }}</div>
<div v-else-if="rows.length === 0" class="adult-verification__state">{{ $t('admin.eroticModeration.empty') }}</div>
<table v-else>
<thead>
<tr>
<th>{{ $t('admin.eroticModeration.target') }}</th>
<th>{{ $t('admin.eroticModeration.owner') }}</th>
<th>{{ $t('admin.eroticModeration.reporter') }}</th>
<th>{{ $t('admin.eroticModeration.reason') }}</th>
<th>{{ $t('admin.eroticModeration.statusLabel') }}</th>
<th>{{ $t('admin.eroticModeration.meta') }}</th>
<th>{{ $t('admin.eroticModeration.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td class="adult-verification__request">
<strong>{{ row.targetType === 'image' ? $t('admin.eroticModeration.image') : $t('admin.eroticModeration.video') }}</strong>
<span>{{ row.target?.title || '—' }}</span>
<span v-if="row.target?.isModeratedHidden" class="adult-verification__badge adult-verification__badge--rejected">
{{ $t('admin.eroticModeration.hidden') }}
</span>
<button v-if="row.target" type="button" class="secondary" @click="previewTarget(row)">
{{ $t('admin.eroticModeration.preview') }}
</button>
</td>
<td>{{ row.owner?.username || '—' }}</td>
<td>{{ row.reporter?.username || '—' }}</td>
<td class="adult-verification__request">
<strong>{{ $t(`socialnetwork.erotic.reportReasons.${row.reason}`) }}</strong>
<span v-if="row.note">{{ row.note }}</span>
</td>
<td>
<span class="adult-verification__badge" :class="`adult-verification__badge--${row.status}`">
{{ $t(`admin.eroticModeration.status.${row.status}`) }}
</span>
</td>
<td class="adult-verification__request">
<span>{{ formatDate(row.createdAt) }}</span>
<span v-if="row.actionTaken">{{ $t(`admin.eroticModeration.actionLabels.${row.actionTaken}`) }}</span>
<span v-if="row.handledAt">{{ formatDate(row.handledAt) }}</span>
</td>
<td class="adult-verification__actions">
<button type="button" @click="applyAction(row, 'dismiss')">{{ $t('admin.eroticModeration.dismiss') }}</button>
<button type="button" class="secondary" @click="applyAction(row, 'hide_content')">{{ $t('admin.eroticModeration.hide') }}</button>
<button type="button" class="secondary" @click="applyAction(row, 'restore_content')">{{ $t('admin.eroticModeration.restore') }}</button>
<button type="button" class="secondary" @click="applyAction(row, 'delete_content')">{{ $t('admin.eroticModeration.delete') }}</button>
<button type="button" class="secondary" @click="applyAction(row, 'block_uploads')">{{ $t('admin.eroticModeration.blockUploads') }}</button>
<button type="button" class="secondary" @click="applyAction(row, 'revoke_access')">{{ $t('admin.eroticModeration.revokeAccess') }}</button>
</td>
</tr>
</tbody>
</table>
</section>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import { showApiError, showSuccess } from '@/utils/feedback.js';
export default {
name: 'EroticModerationView',
data() {
return {
loading: false,
statusFilter: 'open',
rows: []
};
},
computed: {
filterOptions() {
return [
{ value: 'open', label: this.$t('admin.eroticModeration.filters.open') },
{ value: 'actioned', label: this.$t('admin.eroticModeration.filters.actioned') },
{ value: 'dismissed', label: this.$t('admin.eroticModeration.filters.dismissed') },
{ value: 'all', label: this.$t('admin.eroticModeration.filters.all') }
];
}
},
methods: {
async load() {
this.loading = true;
try {
const response = await apiClient.get('/api/admin/users/erotic-moderation', {
params: { status: this.statusFilter }
});
this.rows = response.data || [];
} catch (error) {
this.rows = [];
showApiError(this, error, this.$t('admin.eroticModeration.loadError'));
} finally {
this.loading = false;
}
},
async changeFilter(filter) {
this.statusFilter = filter;
await this.load();
},
async applyAction(row, action) {
const note = window.prompt(this.$t('admin.eroticModeration.notePrompt'), row.note || '') || '';
try {
await apiClient.put(`/api/admin/users/erotic-moderation/${row.id}`, { action, note });
showSuccess(this, this.$t('admin.eroticModeration.actionSuccess'));
await this.load();
} catch (error) {
showApiError(this, error, this.$t('admin.eroticModeration.actionError'));
}
},
async previewTarget(row) {
try {
const response = await apiClient.get(`/api/admin/users/erotic-moderation/preview/${row.targetType}/${row.targetId}`, {
responseType: 'blob'
});
const url = window.URL.createObjectURL(response.data);
window.open(url, '_blank', 'noopener');
window.setTimeout(() => window.URL.revokeObjectURL(url), 10000);
} catch (error) {
showApiError(this, error, this.$t('admin.eroticModeration.previewError'));
}
},
formatDate(value) {
if (!value) return '—';
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
}
},
async mounted() {
await this.load();
}
};
</script>
<style scoped>
.adult-verification {
display: grid;
gap: 18px;
}
.adult-verification__hero,
.adult-verification__filters,
.adult-verification__list {
background: linear-gradient(180deg, rgba(255, 252, 247, 0.97), rgba(250, 244, 235, 0.95));
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-soft);
padding: 22px 24px;
}
.adult-verification__eyebrow {
display: inline-flex;
margin-bottom: 8px;
padding: 4px 10px;
border-radius: var(--radius-pill);
background: var(--color-secondary-soft);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.adult-verification__hero p,
.adult-verification__state {
margin: 0;
color: var(--color-text-secondary);
}
.adult-verification__filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.adult-verification__filters button.active {
background: var(--color-primary);
color: #fff;
}
.adult-verification table {
width: 100%;
border-collapse: collapse;
}
.adult-verification th,
.adult-verification td {
padding: 12px 10px;
border-bottom: 1px solid rgba(93, 64, 55, 0.08);
text-align: left;
}
.adult-verification__badge {
display: inline-flex;
padding: 4px 10px;
border-radius: var(--radius-pill);
font-weight: 700;
font-size: 0.82rem;
}
.adult-verification__badge--open {
background: rgba(216, 167, 65, 0.16);
}
.adult-verification__badge--actioned {
background: rgba(92, 156, 106, 0.18);
}
.adult-verification__badge--dismissed {
background: rgba(176, 88, 88, 0.16);
}
.adult-verification__actions,
.adult-verification__request {
display: grid;
gap: 6px;
}
.adult-verification__actions .secondary {
background: transparent;
}
</style>