feat: добавлена функциональность политики конфиденциальности и согласия на обработку ПД
Основные изменения: Backend: - Добавлена миграция для поля privacy_consented_at в таблицу telecart_customers - Создан PrivacyPolicyHandler с методами: * checkIsUserPrivacyConsented - проверка наличия согласия пользователя * userPrivacyConsent - сохранение согласия пользователя - Обновлен TelegramService для извлечения userId из initData - Обновлен TelegramServiceProvider для внедрения зависимостей - Добавлены новые маршруты в routes.php - Обновлен SettingsHandler для возврата privacy_policy_link - Обновлен TelegramCustomersHandler для включения privacy_consented_at в ответы - Обновлены тесты TelegramServiceTest Frontend (SPA): - Создан компонент PrivacyPolicy.vue для отображения запроса согласия - Добавлена проверка согласия при инициализации приложения (main.js) - Обновлен App.vue для отображения компонента PrivacyPolicy - Добавлены функции checkIsUserPrivacyConsented и userPrivacyConsent в ftch.js - Обновлен SettingsStore для хранения privacy_policy_link и is_privacy_consented Frontend (Admin): - Добавлено поле privacy_policy_link в настройки (settings.js) - Добавлена настройка ссылки на политику конфиденциальности в GeneralView.vue - Обновлен CustomersView.vue: * Добавлена колонка privacy_consented_at с отображением даты согласия * Добавлена поддержка help-текста для колонок с иконкой вопроса и tooltip * Добавлены help-тексты для колонок last_seen_at, privacy_consented_at, created_at * Улучшено форматирование кода
This commit is contained in:
@@ -17,6 +17,7 @@ export const useSettingsStore = defineStore('settings', {
|
||||
theme_light: 'light',
|
||||
theme_dark: 'dark',
|
||||
app_debug: false,
|
||||
privacy_policy_link: null,
|
||||
},
|
||||
|
||||
telegram: {
|
||||
|
||||
@@ -67,13 +67,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</OverlayPanel>
|
||||
<Button icon="fa fa-refresh" @click="loadCustomers" v-tooltip.top="'Обновить таблицу'" size="small"/>
|
||||
<Button icon="fa fa-times-circle" label="Сбросить фильтры" @click="resetFilters" v-tooltip.top="'Сбросить все фильтры'" size="small"/>
|
||||
<Button icon="fa fa-refresh" @click="loadCustomers" v-tooltip.top="'Обновить таблицу'"
|
||||
size="small"/>
|
||||
<Button icon="fa fa-times-circle" label="Сбросить фильтры" @click="resetFilters"
|
||||
v-tooltip.top="'Сбросить все фильтры'" size="small"/>
|
||||
</div>
|
||||
|
||||
<IconField>
|
||||
<InputIcon class="fa fa-search" />
|
||||
<InputText v-model="globalSearchValue" placeholder="Поиск по таблице..." @input="onGlobalSearch" />
|
||||
<InputIcon class="fa fa-search"/>
|
||||
<InputText v-model="globalSearchValue" placeholder="Поиск по таблице..."
|
||||
@input="onGlobalSearch"/>
|
||||
</IconField>
|
||||
</div>
|
||||
</template>
|
||||
@@ -91,10 +94,25 @@
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column v-for="col in selectedColumns" :key="col.field" :field="col.field" :header="col.header" :sortable="col.sortable" :dataType="col.dataType" :showFilterMenu="col.filterable !== false">
|
||||
<Column v-for="col in selectedColumns" :key="col.field" :field="col.field"
|
||||
:sortable="col.sortable" :dataType="col.dataType"
|
||||
:showFilterMenu="col.filterable !== false">
|
||||
<template #header>
|
||||
<div class="tw:flex tw:items-center tw:gap-1">
|
||||
<span>{{ col.header }}</span>
|
||||
<i
|
||||
v-if="col.help"
|
||||
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help"
|
||||
v-tooltip.top="col.help"
|
||||
></i>
|
||||
</div>
|
||||
</template>
|
||||
<template #body="{ data }">
|
||||
<template v-if="col.field === 'id'">{{ data.id }}</template>
|
||||
<template v-else-if="col.field === 'telegram_user_id'">{{ data.telegram_user_id }}</template>
|
||||
<template v-else-if="col.field === 'telegram_user_id'">{{
|
||||
data.telegram_user_id
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="col.field === 'username'">
|
||||
<div class="tw:flex tw:items-center tw:gap-2">
|
||||
<div v-if="data.photo_url" class="tw:relative">
|
||||
@@ -132,25 +150,35 @@
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'privacy_consented_at'">
|
||||
<span v-if="data.privacy_consented_at">
|
||||
<i class="fa fa-clock-o"></i> {{ formatDate(data.privacy_consented_at) }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'created_at'">
|
||||
<i class="fa fa-calendar"></i> {{ formatDate(data.created_at) }}
|
||||
</template>
|
||||
</template>
|
||||
<template #filter="{ filterModel }">
|
||||
<template v-if="col.field === 'telegram_user_id'">
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по ID" class="p-column-filter" />
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по ID"
|
||||
class="p-column-filter"/>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'username'">
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по имени пользователя" class="p-column-filter" />
|
||||
<InputText v-model="filterModel.value" type="text"
|
||||
placeholder="Поиск по имени пользователя" class="p-column-filter"/>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'first_name'">
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по имени" class="p-column-filter" />
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по имени"
|
||||
class="p-column-filter"/>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'last_name'">
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по фамилии" class="p-column-filter" />
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по фамилии"
|
||||
class="p-column-filter"/>
|
||||
</template>
|
||||
<template v-else-if="['last_seen_at', 'created_at'].includes(col.field)">
|
||||
<DatePicker v-model="filterModel.value" dateFormat="dd.mm.yy" placeholder="dd.mm.yyyy" />
|
||||
<DatePicker v-model="filterModel.value" dateFormat="dd.mm.yy" placeholder="dd.mm.yyyy"/>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'is_premium'">
|
||||
<Dropdown
|
||||
@@ -204,7 +232,8 @@
|
||||
<div style="font-size: 0.9rem; color: #666;">
|
||||
ID: {{ selectedCustomer.telegram_user_id }}
|
||||
</div>
|
||||
<div v-if="!selectedCustomer.allows_write_to_pm" style="color: #f59e0b; margin-top: 0.5rem;">
|
||||
<div v-if="!selectedCustomer.allows_write_to_pm"
|
||||
style="color: #f59e0b; margin-top: 0.5rem;">
|
||||
<i class="fa fa-exclamation-triangle"></i> Пользователь не разрешил писать ему в PM
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,8 +274,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { FilterMatchMode, FilterOperator } from '@primevue/core/api';
|
||||
import {onMounted, ref} from 'vue';
|
||||
import {FilterMatchMode, FilterOperator} from '@primevue/core/api';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import DatePicker from 'primevue/datepicker';
|
||||
@@ -257,8 +286,8 @@ import Textarea from 'primevue/textarea';
|
||||
import OverlayPanel from 'primevue/overlaypanel';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import Button from 'primevue/button';
|
||||
import { apiPost } from '@/utils/http.js';
|
||||
import { useToast, IconField, InputIcon } from 'primevue';
|
||||
import {apiPost} from '@/utils/http.js';
|
||||
import {IconField, InputIcon, useToast} from 'primevue';
|
||||
|
||||
const toast = useToast();
|
||||
const customers = ref([]);
|
||||
@@ -270,16 +299,59 @@ const messageText = ref('');
|
||||
const sendingMessage = ref(false);
|
||||
|
||||
const columns = ref([
|
||||
{ field: 'id', header: '№', sortable: true, filterable: false, visible: true },
|
||||
{ field: 'telegram_user_id', header: 'ID в Telegram', sortable: true, filterable: true, visible: false },
|
||||
{ field: 'username', header: 'Имя пользователя', sortable: true, filterable: true, visible: true },
|
||||
{ field: 'first_name', header: 'Имя', sortable: true, filterable: true, visible: true },
|
||||
{ field: 'last_name', header: 'Фамилия', sortable: true, filterable: true, visible: true },
|
||||
{ field: 'language_code', header: 'Язык интерфейса', sortable: true, filterable: false, visible: false },
|
||||
{ field: 'is_premium', header: 'Премиум статус', sortable: true, filterable: true, visible: true },
|
||||
{ field: 'oc_customer_id', header: 'ID покупателя', sortable: true, filterable: false, visible: false },
|
||||
{ field: 'last_seen_at', header: 'Последний визит', sortable: true, dataType: 'date', filterable: true, visible: true },
|
||||
{ field: 'created_at', header: 'Дата регистрации', sortable: true, dataType: 'date', filterable: true, visible: true },
|
||||
{field: 'id', header: '№', sortable: true, filterable: false, visible: true},
|
||||
{
|
||||
field: 'telegram_user_id',
|
||||
header: 'ID в Telegram',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
visible: false
|
||||
},
|
||||
{field: 'username', header: 'Имя пользователя', sortable: true, filterable: true, visible: true},
|
||||
{field: 'first_name', header: 'Имя', sortable: true, filterable: true, visible: true},
|
||||
{field: 'last_name', header: 'Фамилия', sortable: true, filterable: true, visible: true},
|
||||
{
|
||||
field: 'language_code',
|
||||
header: 'Язык интерфейса',
|
||||
sortable: true,
|
||||
filterable: false,
|
||||
visible: false
|
||||
},
|
||||
{field: 'is_premium', header: 'Премиум статус', sortable: true, filterable: true, visible: true},
|
||||
{
|
||||
field: 'oc_customer_id',
|
||||
header: 'ID покупателя',
|
||||
sortable: true,
|
||||
filterable: false,
|
||||
visible: false
|
||||
},
|
||||
{
|
||||
field: 'last_seen_at',
|
||||
header: 'Последний визит',
|
||||
sortable: true,
|
||||
dataType: 'date',
|
||||
filterable: true,
|
||||
visible: true,
|
||||
help: 'Показывает, когда пользователь последний раз открывал Telegram магазин.',
|
||||
},
|
||||
{
|
||||
field: 'privacy_consented_at',
|
||||
header: 'Согласие ПД',
|
||||
sortable: true,
|
||||
dataType: 'date',
|
||||
filterable: true,
|
||||
visible: true,
|
||||
help: 'Показывает, когда пользователь дал согласие на обработку персональных данных.',
|
||||
},
|
||||
{
|
||||
field: 'created_at',
|
||||
header: 'Дата регистрации',
|
||||
sortable: true,
|
||||
dataType: 'date',
|
||||
filterable: true,
|
||||
visible: true,
|
||||
help: 'Показывает, когда пользователь впервые открыл Telegram магазин.',
|
||||
},
|
||||
]);
|
||||
|
||||
const selectedColumns = ref(columns.value.filter(col => col.visible !== false));
|
||||
@@ -314,9 +386,9 @@ function deselectAllColumns() {
|
||||
}
|
||||
|
||||
const premiumFilterOptions = [
|
||||
{ label: 'Любой', value: null },
|
||||
{ label: 'Нет', value: false },
|
||||
{ label: 'Да', value: true },
|
||||
{label: 'Любой', value: null},
|
||||
{label: 'Нет', value: false},
|
||||
{label: 'Да', value: true},
|
||||
];
|
||||
|
||||
const lazyParams = ref({
|
||||
@@ -329,14 +401,35 @@ const lazyParams = ref({
|
||||
});
|
||||
|
||||
const filters = ref({
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
telegram_user_id: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }] },
|
||||
username: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
||||
first_name: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
||||
last_name: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
||||
is_premium: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.EQUALS }] },
|
||||
created_at: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }] },
|
||||
last_seen_at: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }] },
|
||||
global: {value: null, matchMode: FilterMatchMode.CONTAINS},
|
||||
telegram_user_id: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.STARTS_WITH}]
|
||||
},
|
||||
username: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||
},
|
||||
first_name: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||
},
|
||||
last_name: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||
},
|
||||
is_premium: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}]
|
||||
},
|
||||
created_at: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
last_seen_at: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
});
|
||||
|
||||
function processFiltersForBackend(filtersObj) {
|
||||
@@ -447,14 +540,35 @@ function onGlobalSearch() {
|
||||
function resetFilters() {
|
||||
globalSearchValue.value = '';
|
||||
filters.value = {
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
telegram_user_id: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }] },
|
||||
username: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
||||
first_name: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
||||
last_name: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.CONTAINS }] },
|
||||
is_premium: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.EQUALS }] },
|
||||
created_at: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }] },
|
||||
last_seen_at: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }] },
|
||||
global: {value: null, matchMode: FilterMatchMode.CONTAINS},
|
||||
telegram_user_id: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.STARTS_WITH}]
|
||||
},
|
||||
username: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||
},
|
||||
first_name: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||
},
|
||||
last_name: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||
},
|
||||
is_premium: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}]
|
||||
},
|
||||
created_at: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
last_seen_at: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
};
|
||||
lazyParams.value.page = 1;
|
||||
lazyParams.value.first = 0;
|
||||
|
||||
@@ -18,6 +18,17 @@
|
||||
Изображение, которое будет отображаться в Telegram Mini App.
|
||||
</ItemImage>
|
||||
|
||||
<ItemInput label="Политика конфиденциальности"
|
||||
v-model="settings.items.app.privacy_policy_link"
|
||||
placeholder="https://your-site/privacy-polisy"
|
||||
>
|
||||
Укажите ссылку на страницу с вашей Политикой конфиденциальности.
|
||||
Она будет показываться пользователям для получения согласия на обработку персональных данных.
|
||||
Вы сможете видеть, кто согласился, на странице с
|
||||
<RouterLink to="/customers">Telegram покупателями</RouterLink>
|
||||
.
|
||||
</ItemInput>
|
||||
|
||||
<ItemSelect label="Светлая тема" v-model="settings.items.app.theme_light" :items="themes">
|
||||
Выберите стиль, который будет использоваться при отображении вашего магазина
|
||||
в Telegram для дневного режима.
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
</KeepAlive>
|
||||
</RouterView>
|
||||
|
||||
<PrivacyPolicy v-if="! settings.is_privacy_consented"/>
|
||||
|
||||
<CartButton v-if="settings.store_enabled"/>
|
||||
<Dock v-if="isAppDockShown"/>
|
||||
</section>
|
||||
@@ -42,8 +44,8 @@ import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
|
||||
import CartButton from "@/components/CartButton.vue";
|
||||
import Dock from "@/components/Dock.vue";
|
||||
import Navbar from "@/components/Navbar.vue";
|
||||
import AppDebugMessage from "@/components/AppDebugMessage.vue";
|
||||
import PrivacyPolicy from "@/components/PrivacyPolicy.vue";
|
||||
|
||||
const tg = useMiniApp();
|
||||
const platform = ref();
|
||||
|
||||
42
frontend/spa/src/components/PrivacyPolicy.vue
Normal file
42
frontend/spa/src/components/PrivacyPolicy.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div v-if="isShown" class="toast toast-center bottom-20 z-50">
|
||||
<div class="alert alert-info">
|
||||
<span>
|
||||
Используя магазин, вы соглашаетесь с
|
||||
<a v-if="settings.privacy_policy_link"
|
||||
href="#" class="underline"
|
||||
@click.prevent="showPrivacyPolicy"
|
||||
>обработкой персональных данных</a>
|
||||
<span v-else>обработкой персональных данных</span>.
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-outline"
|
||||
@click="privacyConsent"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {userPrivacyConsent} from "@/utils/ftch.js";
|
||||
import {ref} from "vue";
|
||||
import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
|
||||
const isShown = ref(true);
|
||||
const settings = useSettingsStore();
|
||||
|
||||
async function privacyConsent() {
|
||||
isShown.value = false;
|
||||
await userPrivacyConsent();
|
||||
}
|
||||
|
||||
function showPrivacyPolicy() {
|
||||
if (settings.privacy_policy_link) {
|
||||
window.Telegram.WebApp.openLink(settings.privacy_policy_link, {
|
||||
try_instant_view: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -8,7 +8,7 @@ import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import ApplicationError from "@/ApplicationError.vue";
|
||||
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
||||
import {injectYaMetrika} from "@/utils/yaMetrika.js";
|
||||
import {saveTelegramCustomer} from "@/utils/ftch.js";
|
||||
import {checkIsUserPrivacyConsented, saveTelegramCustomer} from "@/utils/ftch.js";
|
||||
|
||||
import {register} from 'swiper/element/bundle';
|
||||
import 'swiper/element/bundle';
|
||||
@@ -59,6 +59,22 @@ settings.load()
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const response = await checkIsUserPrivacyConsented();
|
||||
settings.is_privacy_consented = response?.data?.is_privacy_consented;
|
||||
if (settings.is_privacy_consented) {
|
||||
console.info('[Init] Privacy Policy consent given by user.');
|
||||
} else {
|
||||
console.info('[Init] Privacy Policy consent NOT given by user.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Init] Failed to check Telegram user consent.');
|
||||
settings.is_privacy_consented = false;
|
||||
}
|
||||
})();
|
||||
})
|
||||
.then(() => blocks.processBlocks(settings.mainpage_blocks))
|
||||
.then(async () => {
|
||||
console.debug('Load default filters for the main page');
|
||||
|
||||
@@ -29,6 +29,8 @@ export const useSettingsStore = defineStore('settings', {
|
||||
text_order_created_success: 'Заказ успешно оформлен.',
|
||||
},
|
||||
mainpage_blocks: [],
|
||||
is_privacy_consented: false,
|
||||
privacy_policy_link: false,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
@@ -53,6 +55,7 @@ export const useSettingsStore = defineStore('settings', {
|
||||
this.currency_code = settings.currency_code;
|
||||
this.texts = settings.texts;
|
||||
this.mainpage_blocks = settings.mainpage_blocks;
|
||||
this.privacy_policy_link = settings.privacy_policy_link;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -107,4 +107,12 @@ export async function saveTelegramCustomer(userData) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function checkIsUserPrivacyConsented() {
|
||||
return await ftch('checkIsUserPrivacyConsented');
|
||||
}
|
||||
|
||||
export async function userPrivacyConsent() {
|
||||
return await ftch('userPrivacyConsent');
|
||||
}
|
||||
|
||||
export default ftch;
|
||||
|
||||
Reference in New Issue
Block a user