Merge branch 'dynamic_schedule' of https://gitea.zuev.company/Zuev/magistr into dynamic_schedule
This commit is contained in:
@@ -1,38 +1,43 @@
|
||||
const token = localStorage.getItem('token');
|
||||
let refreshPromise = null;
|
||||
|
||||
const AUTH_KEYS = ['token', 'role', 'departmentId', 'userId'];
|
||||
|
||||
export function getToken() {
|
||||
return token;
|
||||
return localStorage.getItem('token');
|
||||
}
|
||||
|
||||
export function isAuthenticatedAsAdmin() {
|
||||
const role = localStorage.getItem('role');
|
||||
return token && role === 'ADMIN';
|
||||
return getToken() && role === 'ADMIN';
|
||||
}
|
||||
|
||||
export function isAuthenticatedAsRole(expectedRole) {
|
||||
const role = localStorage.getItem('role');
|
||||
return token && role === expectedRole;
|
||||
return getToken() && role === expectedRole;
|
||||
}
|
||||
|
||||
export function isAuthenticatedAsAny(roles) {
|
||||
const role = localStorage.getItem('role');
|
||||
return token && roles.includes(role);
|
||||
return getToken() && roles.includes(role);
|
||||
}
|
||||
|
||||
function getHeaders(contentType = 'application/json') {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
const headers = {};
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (contentType) {
|
||||
headers['Content-Type'] = contentType;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function apiFetch(endpoint, method = 'GET', body = null) {
|
||||
export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUnauthorized = true) {
|
||||
const options = {
|
||||
method,
|
||||
headers: getHeaders(body ? 'application/json' : null)
|
||||
headers: getHeaders(body ? 'application/json' : null),
|
||||
credentials: 'same-origin'
|
||||
};
|
||||
|
||||
if (body) {
|
||||
@@ -48,6 +53,15 @@ export async function apiFetch(endpoint, method = 'GET', body = null) {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (response.status === 401 && retryOnUnauthorized) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
return apiFetch(endpoint, method, body, false);
|
||||
}
|
||||
clearAuthState();
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
||||
}
|
||||
@@ -55,6 +69,57 @@ export async function apiFetch(endpoint, method = 'GET', body = null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function refreshAccessToken() {
|
||||
if (refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
refreshPromise = (async () => {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok || !data?.token) {
|
||||
return false;
|
||||
}
|
||||
storeAuthState(data);
|
||||
return true;
|
||||
})().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Не удалось завершить серверную сессию:', e.message);
|
||||
} finally {
|
||||
clearAuthState();
|
||||
}
|
||||
}
|
||||
|
||||
export function storeAuthState(data) {
|
||||
if (data.token) localStorage.setItem('token', data.token);
|
||||
if (data.role) localStorage.setItem('role', data.role);
|
||||
if (data.departmentId !== undefined && data.departmentId !== null) {
|
||||
localStorage.setItem('departmentId', data.departmentId);
|
||||
}
|
||||
if (data.userId !== undefined && data.userId !== null) {
|
||||
localStorage.setItem('userId', data.userId);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuthState() {
|
||||
AUTH_KEYS.forEach(key => localStorage.removeItem(key));
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (url) => apiFetch(url, 'GET'),
|
||||
post: (url, body) => apiFetch(url, 'POST', body),
|
||||
|
||||
@@ -3,7 +3,7 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
|
||||
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
|
||||
}
|
||||
|
||||
import { isAuthenticatedAsAny } from './api.js';
|
||||
import { isAuthenticatedAsAny, logout } from './api.js';
|
||||
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
|
||||
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
|
||||
|
||||
@@ -143,9 +143,8 @@ document.addEventListener('click', (e) => {
|
||||
});
|
||||
|
||||
// Logout
|
||||
btnLogout.addEventListener('click', () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('role');
|
||||
btnLogout.addEventListener('click', async () => {
|
||||
await logout();
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function initGroups() {
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||
subgroupsTbody.innerHTML = `<tr><td colspan="4" class="loading-row">Ошибка загрузки данных: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
@@ -103,13 +103,13 @@ export async function initGroups() {
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
allGroups = await api.get('/api/groups');
|
||||
allGroups = await api.get('/api/groups?includeArchived=true');
|
||||
applyGroupFilter();
|
||||
populateGroupSelect();
|
||||
populateSubgroupGroupSelect();
|
||||
await loadAssignments();
|
||||
} catch (error) {
|
||||
groupsTbody.innerHTML = `<tr><td colspan="9" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
groupsTbody.innerHTML = `<tr><td colspan="10" class="loading-row">Ошибка загрузки: ${escapeHtml(error.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ export async function initGroups() {
|
||||
|
||||
function renderGroups(groups) {
|
||||
if (!groups || !groups.length) {
|
||||
groupsTbody.innerHTML = '<tr><td colspan="9" class="loading-row">Нет групп</td></tr>';
|
||||
groupsTbody.innerHTML = '<tr><td colspan="10" class="loading-row">Нет групп</td></tr>';
|
||||
return;
|
||||
}
|
||||
groupsTbody.innerHTML = groups.map(group => `
|
||||
@@ -145,9 +145,12 @@ export async function initGroups() {
|
||||
<td>${group.course || '-'}</td>
|
||||
<td>${escapeHtml(specialityLabel(group.specialtyId || group.specialityCode))}</td>
|
||||
<td>${escapeHtml(group.specialtyProfileName || '-')}</td>
|
||||
<td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
|
||||
<td style="text-align: right;">
|
||||
<button class="btn-edit-classroom btn-edit-group" data-id="${group.id}">Изменить</button>
|
||||
<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Удалить</button>
|
||||
${group.status === 'ARCHIVED'
|
||||
? `<button class="btn-restore-group" data-id="${group.id}" style="margin-left: 0.5rem;">Восстановить</button>`
|
||||
: `<button class="btn-delete" data-id="${group.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
@@ -232,24 +235,26 @@ export async function initGroups() {
|
||||
|
||||
function populateGroupSelect() {
|
||||
const current = calendarGroupSelect.value;
|
||||
const availableGroups = allGroups.filter(group => group.active);
|
||||
calendarGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
||||
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
||||
if (current && allGroups.some(group => String(group.id) === String(current))) {
|
||||
availableGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
||||
if (current && availableGroups.some(group => String(group.id) === String(current))) {
|
||||
calendarGroupSelect.value = current;
|
||||
} else if (allGroups.length) {
|
||||
calendarGroupSelect.value = String(allGroups[0].id);
|
||||
} else if (availableGroups.length) {
|
||||
calendarGroupSelect.value = String(availableGroups[0].id);
|
||||
}
|
||||
syncSelects(calendarGroupSelect);
|
||||
}
|
||||
|
||||
function populateSubgroupGroupSelect() {
|
||||
const current = subgroupGroupSelect.value;
|
||||
const availableGroups = allGroups.filter(group => group.active);
|
||||
subgroupGroupSelect.innerHTML = '<option value="">Выберите группу</option>' +
|
||||
allGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
||||
if (current && allGroups.some(group => String(group.id) === String(current))) {
|
||||
availableGroups.map(group => `<option value="${group.id}">${escapeHtml(group.name)}</option>`).join('');
|
||||
if (current && availableGroups.some(group => String(group.id) === String(current))) {
|
||||
subgroupGroupSelect.value = current;
|
||||
} else if (allGroups.length) {
|
||||
subgroupGroupSelect.value = String(allGroups[0].id);
|
||||
} else if (availableGroups.length) {
|
||||
subgroupGroupSelect.value = String(availableGroups[0].id);
|
||||
}
|
||||
syncSelects(subgroupGroupSelect);
|
||||
}
|
||||
@@ -342,15 +347,26 @@ export async function initGroups() {
|
||||
async function handleGroupTableClick(event) {
|
||||
const btnDelete = event.target.closest('.btn-delete');
|
||||
const btnEdit = event.target.closest('.btn-edit-group');
|
||||
const btnRestore = event.target.closest('.btn-restore-group');
|
||||
|
||||
if (btnDelete) {
|
||||
if (!confirm('Удалить группу?')) return;
|
||||
if (!confirm('Архивировать группу? История расписания сохранится.')) return;
|
||||
try {
|
||||
await api.delete('/api/groups/' + btnDelete.dataset.id);
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка удаления');
|
||||
alert(error.message || 'Ошибка архивирования');
|
||||
}
|
||||
}
|
||||
|
||||
if (btnRestore) {
|
||||
try {
|
||||
await api.post('/api/groups/' + btnRestore.dataset.id + '/restore', {});
|
||||
await loadGroups();
|
||||
await loadSubgroups();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Ошибка восстановления');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,6 +582,21 @@ export async function initGroups() {
|
||||
return name ? `${code} — ${name}` : code;
|
||||
}
|
||||
|
||||
function statusLabel(group) {
|
||||
if (group.status === 'ARCHIVED') return 'Архив';
|
||||
return group.studyStateName || (group.active ? 'Активна' : 'Неактивна');
|
||||
}
|
||||
|
||||
function statusBadgeClass(group) {
|
||||
if (group.status === 'ARCHIVED' || group.studyState === 'GRADUATED' || group.studyState === 'INACTIVE') {
|
||||
return 'badge-unavailable';
|
||||
}
|
||||
if (group.studyState === 'NOT_STARTED') {
|
||||
return 'badge-ef';
|
||||
}
|
||||
return 'badge-available';
|
||||
}
|
||||
|
||||
function syncSelects(...selects) {
|
||||
selects.filter(Boolean).forEach(select => {
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// Основной модуль страницы настроек
|
||||
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
|
||||
|
||||
import { getToken } from '../../js/api.js';
|
||||
|
||||
// Проверка авторизации
|
||||
const token = localStorage.getItem('token');
|
||||
const role = localStorage.getItem('role');
|
||||
if (!token || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) {
|
||||
if (!getToken() || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
|
||||
@@ -67,12 +67,13 @@
|
||||
<th>Курс</th>
|
||||
<th>Специальность</th>
|
||||
<th>Профиль</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="groups-tbody">
|
||||
<tr>
|
||||
<td colspan="9" class="loading-row">Загрузка...</td>
|
||||
<td colspan="10" class="loading-row">Загрузка...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -127,23 +127,28 @@
|
||||
hideAlert();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: usernameInput.value.trim(),
|
||||
password: passwordInput.value,
|
||||
}),
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
username: usernameInput.value.trim(),
|
||||
password: passwordInput.value,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showAlert('Вход выполнен успешно!', 'success');
|
||||
if (response.ok) {
|
||||
showAlert('Вход выполнен успешно!', 'success');
|
||||
|
||||
if (data.token) localStorage.setItem('token', data.token);
|
||||
if (data.role) localStorage.setItem('role', data.role);
|
||||
if (data.departmentId) localStorage.setItem('departmentId', data.departmentId);
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('role');
|
||||
localStorage.removeItem('departmentId');
|
||||
localStorage.removeItem('userId');
|
||||
if (data.token) localStorage.setItem('token', data.token);
|
||||
if (data.role) localStorage.setItem('role', data.role);
|
||||
if (data.departmentId) localStorage.setItem('departmentId', data.departmentId);
|
||||
if (data.userId) localStorage.setItem('userId', data.userId);
|
||||
|
||||
const redirect = data.redirect || '/';
|
||||
|
||||
@@ -292,19 +292,18 @@
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
const groupSelect = document.getElementById('group-select');
|
||||
const grid = document.getElementById('schedule-grid');
|
||||
const statusLine = document.getElementById('status-line');
|
||||
const weekLabel = document.getElementById('week-label');
|
||||
const datePicker = document.getElementById('date-picker');
|
||||
let weekStart = startOfWeek(new Date());
|
||||
let refreshPromise = null;
|
||||
|
||||
document.getElementById('logout-link').addEventListener('click', () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('role');
|
||||
localStorage.removeItem('departmentId');
|
||||
localStorage.removeItem('userId');
|
||||
document.getElementById('logout-link').addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
await logout();
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
document.getElementById('theme-toggle-local').addEventListener('click', () => {
|
||||
@@ -444,17 +443,78 @@
|
||||
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
async function fetchJson(url, retryOnUnauthorized = true) {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (response.status === 401 && retryOnUnauthorized) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
return fetchJson(url, false);
|
||||
}
|
||||
clearAuthState();
|
||||
window.location.href = '/';
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
refreshPromise = (async () => {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok || !data?.token) {
|
||||
return false;
|
||||
}
|
||||
storeAuthState(data);
|
||||
return true;
|
||||
})().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Не удалось завершить серверную сессию:', error.message);
|
||||
} finally {
|
||||
clearAuthState();
|
||||
}
|
||||
}
|
||||
|
||||
function storeAuthState(data) {
|
||||
if (data.token) localStorage.setItem('token', data.token);
|
||||
if (data.role) localStorage.setItem('role', data.role);
|
||||
if (data.departmentId !== undefined && data.departmentId !== null) {
|
||||
localStorage.setItem('departmentId', data.departmentId);
|
||||
}
|
||||
if (data.userId !== undefined && data.userId !== null) {
|
||||
localStorage.setItem('userId', data.userId);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAuthState() {
|
||||
['token', 'role', 'departmentId', 'userId'].forEach(key => localStorage.removeItem(key));
|
||||
}
|
||||
|
||||
function showStatus(message, isError) {
|
||||
statusLine.textContent = message;
|
||||
statusLine.classList.toggle('error', isError);
|
||||
|
||||
@@ -295,19 +295,18 @@
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
const teacherId = localStorage.getItem('userId');
|
||||
const grid = document.getElementById('schedule-grid');
|
||||
const statusLine = document.getElementById('status-line');
|
||||
const weekLabel = document.getElementById('week-label');
|
||||
const datePicker = document.getElementById('date-picker');
|
||||
let weekStart = startOfWeek(new Date());
|
||||
let refreshPromise = null;
|
||||
|
||||
document.getElementById('logout-link').addEventListener('click', () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('role');
|
||||
localStorage.removeItem('departmentId');
|
||||
localStorage.removeItem('userId');
|
||||
document.getElementById('logout-link').addEventListener('click', async (event) => {
|
||||
event.preventDefault();
|
||||
await logout();
|
||||
window.location.href = '/';
|
||||
});
|
||||
|
||||
document.getElementById('theme-toggle-local').addEventListener('click', () => {
|
||||
@@ -471,17 +470,78 @@
|
||||
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
async function fetchJson(url, retryOnUnauthorized = true) {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(url, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (response.status === 401 && retryOnUnauthorized) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
return fetchJson(url, false);
|
||||
}
|
||||
clearAuthState();
|
||||
window.location.href = '/';
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
refreshPromise = (async () => {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok || !data?.token) {
|
||||
return false;
|
||||
}
|
||||
storeAuthState(data);
|
||||
return true;
|
||||
})().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Не удалось завершить серверную сессию:', error.message);
|
||||
} finally {
|
||||
clearAuthState();
|
||||
}
|
||||
}
|
||||
|
||||
function storeAuthState(data) {
|
||||
if (data.token) localStorage.setItem('token', data.token);
|
||||
if (data.role) localStorage.setItem('role', data.role);
|
||||
if (data.departmentId !== undefined && data.departmentId !== null) {
|
||||
localStorage.setItem('departmentId', data.departmentId);
|
||||
}
|
||||
if (data.userId !== undefined && data.userId !== null) {
|
||||
localStorage.setItem('userId', data.userId);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAuthState() {
|
||||
['token', 'role', 'departmentId', 'userId'].forEach(key => localStorage.removeItem(key));
|
||||
}
|
||||
|
||||
function showStatus(message, isError) {
|
||||
statusLine.textContent = message;
|
||||
statusLine.classList.toggle('error', isError);
|
||||
|
||||
Reference in New Issue
Block a user