баг-фикс 30/34

This commit is contained in:
Zuev
2026-07-19 14:40:43 +03:00
parent 3d798c13e3
commit bc0e1ab1b4
172 changed files with 13431 additions and 2910 deletions

View File

@@ -1,2 +1,4 @@
Dockerfile
.dockerignore
node_modules
dist

View File

@@ -1,5 +1,27 @@
FROM httpd:alpine
COPY . /usr/local/apache2/htdocs/
FROM node:22-alpine3.23@sha256:8516dce0483394d5708d4b2ee6cacb79fb1d617ea4e2787c2120bcca92ce372e AS frontend-assets
# Set appropriate permissions for the web server to serve static files
RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY telemetry/ ./telemetry/
COPY scripts/build-vendor.mjs ./scripts/build-vendor.mjs
RUN npm run build:vendor
FROM httpd:alpine3.23@sha256:4a15e9c73f25334bc03cfb3c692c9adfc103bb46ca89cee1f0b9a5fcbc7b21f6
COPY admin/ /usr/local/apache2/htdocs/admin/
COPY department/ /usr/local/apache2/htdocs/department/
COPY edu-office/ /usr/local/apache2/htdocs/edu-office/
COPY student/ /usr/local/apache2/htdocs/student/
COPY teacher/ /usr/local/apache2/htdocs/teacher/
COPY index.html script.js style.css theme-toggle.js auth-session.js telemetry.js /usr/local/apache2/htdocs/
COPY --from=frontend-assets /build/dist/vendor/ /usr/local/apache2/htdocs/vendor/
COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
# Сервер раздаёт same-origin ресурсы и проксирует API во внутреннюю сеть Compose.
RUN sed -i 's/^#LoadModule headers_module/LoadModule headers_module/' /usr/local/apache2/conf/httpd.conf \
&& sed -i 's/^#LoadModule proxy_module/LoadModule proxy_module/' /usr/local/apache2/conf/httpd.conf \
&& sed -i 's/^#LoadModule proxy_http_module/LoadModule proxy_http_module/' /usr/local/apache2/conf/httpd.conf \
&& printf '\nInclude conf/extra/magistr-security.conf\nInclude conf/extra/magistr-proxy.conf\n' >> /usr/local/apache2/conf/httpd.conf \
&& chown -R www-data:www-data /usr/local/apache2/htdocs/

View File

@@ -317,6 +317,48 @@
line-height: 1.4;
}
.dashboard-conflict-card {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
border: 1px solid rgba(229, 231, 235, 0.1);
border-left: 4px solid var(--warning);
border-radius: var(--radius-sm);
background: rgba(245, 158, 11, 0.05);
}
.dashboard-conflict-card--error {
border-left-color: var(--error);
background: rgba(239, 68, 68, 0.05);
}
.dashboard-conflict-card__icon {
margin-top: 0.1rem;
}
.dashboard-conflict-card__body {
flex: 1;
}
.dashboard-conflict-card__body h4 {
margin: 0 0 0.25rem;
color: #d97706;
font-size: 0.95rem;
font-weight: 600;
}
.dashboard-conflict-card--error h4 {
color: var(--error);
}
.dashboard-conflict-card__body p {
margin: 0;
color: var(--text-main);
font-size: 0.85rem;
line-height: 1.4;
}
@media (max-width: 1200px) {
.dashboard-metrics-grid {
grid-template-columns: repeat(2, minmax(180px, 1fr));
@@ -927,6 +969,9 @@
border-radius: var(--radius-sm);
background: rgba(10, 10, 15, 0.97);
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.35);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.schedule-visual-context-menu[hidden] {
@@ -2014,7 +2059,7 @@
}
.calendar-week-col {
width: calc((100% - var(--calendar-day-head-width)) / var(--calendar-week-columns, 1));
width: 22px;
}
.calendar-semester-table thead th {
@@ -2122,6 +2167,8 @@
visibility: hidden;
transform: translateY(4px) scale(0.98);
transition: opacity 0.14s ease, transform 0.14s ease, visibility 0.14s ease;
right: 12px;
bottom: 12px;
}
.calendar-day-tooltip.visible {
@@ -2219,6 +2266,7 @@
}
.calendar-total-chip {
--chip-color: var(--accent);
display: inline-flex;
align-items: center;
gap: 0.35rem;
@@ -3626,6 +3674,15 @@ tbody tr:hover {
transition: transform 0.4s ease;
}
.theme-toggle-icon-enter {
animation: theme-toggle-icon-enter 0.4s ease;
}
@keyframes theme-toggle-icon-enter {
from { opacity: 0; transform: rotate(-90deg) scale(0.5); }
to { opacity: 1; transform: rotate(0deg) scale(1); }
}
.theme-toggle:hover {
background: var(--button-secondary-bg-hover);
border-color: var(--button-secondary-border);

View File

@@ -482,3 +482,16 @@
pointer-events: auto;
}
}
.sidebar.collapsed .sidebar-section-title {
display: none !important;
}
.sidebar-section-divider {
height: 1px;
background: rgba(229, 231, 235, 0.1);
margin: 0.5rem 1.5rem;
}
.sidebar.collapsed .sidebar-section-divider {
margin: 0.5rem 0.5rem;
}

View File

@@ -107,4 +107,14 @@ body {
animation: admin-ripple 0.6s linear;
background-color: rgba(255, 255, 255, 0.3);
pointer-events: none;
left: 50%;
top: 50%;
width: 24px;
height: 24px;
margin: -12px 0 0 -12px;
}
.ripple-host {
position: relative;
overflow: hidden;
}

View File

@@ -5,10 +5,6 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Админ-панель</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- CSS Modules -->
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/layout.css">
@@ -16,19 +12,6 @@
<link rel="stylesheet" href="css/modals.css">
<link rel="stylesheet" href="css/departments-data.css">
<link rel="stylesheet" href="css/auditorium-workload.css">
<style>
.sidebar.collapsed .sidebar-section-title {
display: none !important;
}
.sidebar-section-divider {
height: 1px;
background: rgba(229, 231, 235, 0.1);
margin: 0.5rem 1.5rem;
}
.sidebar.collapsed .sidebar-section-divider {
margin: 0.5rem 0.5rem;
}
</style>
</head>
<body>

View File

@@ -1,23 +1,38 @@
let refreshPromise = null;
const AUTH_KEYS = ['token', 'role', 'departmentId', 'userId'];
import {
clearAuthState,
fetchWithAuth,
getAccessToken,
getAuthState,
logoutSession,
refreshAccessToken,
restoreSession,
storeAuthState
} from '../../auth-session.js';
export function getToken() {
return localStorage.getItem('token');
return getAccessToken();
}
export function getSession() {
return getAuthState();
}
export async function initializeSession() {
return restoreSession();
}
export function isAuthenticatedAsAdmin() {
const role = localStorage.getItem('role');
const role = getAuthState()?.role;
return getToken() && role === 'ADMIN';
}
export function isAuthenticatedAsRole(expectedRole) {
const role = localStorage.getItem('role');
const role = getAuthState()?.role;
return getToken() && role === expectedRole;
}
export function isAuthenticatedAsAny(roles) {
const role = localStorage.getItem('role');
const role = getAuthState()?.role;
return getToken() && roles.includes(role);
}
@@ -44,7 +59,7 @@ export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUna
options.body = JSON.stringify(body);
}
const response = await fetch(endpoint, options);
const response = await fetchWithAuth(endpoint, options);
let data;
try {
@@ -54,12 +69,8 @@ export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUna
}
if (response.status === 401 && retryOnUnauthorized) {
const refreshed = await refreshAccessToken();
if (refreshed) {
return apiFetch(endpoint, method, body, false);
}
clearAuthState();
window.location.href = '/';
window.location.replace('/');
}
if (!response.ok) {
@@ -72,28 +83,7 @@ export async function apiFetch(endpoint, method = 'GET', body = null, retryOnUna
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 { clearAuthState, refreshAccessToken, storeAuthState };
const cache = new Map();
@@ -139,32 +129,8 @@ export function clearCache() {
}
export async function logout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin'
});
} catch (e) {
console.warn('Не удалось завершить серверную сессию:', e.message);
} finally {
clearAuthState();
clearCache();
}
}
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));
await logoutSession();
clearCache();
}
export const api = {

View File

@@ -15,7 +15,7 @@ export class CustomSelect {
this.layerParent = this.originalSelect.closest('.settings-card, .card');
// Hide original but keep it accessible for form submissions and JS
this.originalSelect.style.display = 'none';
this.originalSelect.hidden = true;
// Bind methods
this.handleTriggerClick = this.handleTriggerClick.bind(this);
@@ -114,11 +114,7 @@ export class CustomSelect {
items.forEach(item => {
if (item.classList.contains('placeholder-item')) return;
const text = item.textContent.toLowerCase();
if (text.includes(query)) {
item.style.display = '';
} else {
item.style.display = 'none';
}
item.hidden = !text.includes(query);
});
});
searchLi.appendChild(searchInput);
@@ -208,7 +204,7 @@ export class CustomSelect {
const items = this.menu.querySelectorAll('.custom-select-item');
items.forEach(item => {
if (!item.classList.contains('placeholder-item')) {
item.style.display = '';
item.hidden = false;
}
});
setTimeout(() => {

View File

@@ -1,18 +1,17 @@
// OTel: загружаем только на продакшене (не на localhost)
if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
}
import { api, isAuthenticatedAsAny, logout } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { api, initializeSession, logout } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick, renderStatusMessage } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
import { ADMIN_APP_ROLES, SETTINGS_ROLES, capabilitiesForRole } from './role-capabilities.js';
import { initializeTelemetry } from '../../telemetry.js';
const AUTHORIZED_ROLES = ['ADMIN', 'EDUCATION_OFFICE', 'DEPARTMENT', 'SCHEDULE_VIEWER'];
const currentRole = localStorage.getItem('role');
if (!isAuthenticatedAsAny(AUTHORIZED_ROLES)) {
window.location.href = '/';
async function bootstrap() {
const session = await initializeSession();
if (!session || !ADMIN_APP_ROLES.includes(session.role)) {
window.location.replace('/');
return;
}
const currentRole = session.role;
void initializeTelemetry();
// Global initialization for Custom Selects
document.addEventListener('DOMContentLoaded', () => {
@@ -120,25 +119,6 @@ const ROUTES = {
},
};
const ROLE_NAVIGATION = {
ADMIN: {
defaultTab: 'dashboard',
tabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users']
},
EDUCATION_OFFICE: {
defaultTab: 'dashboard',
tabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms']
},
DEPARTMENT: {
defaultTab: 'department-workspace',
tabs: ['department-workspace', 'schedule-view']
},
SCHEDULE_VIEWER: {
defaultTab: 'schedule-view',
tabs: ['schedule-view']
}
};
let currentTab = null;
// DOM Elements
@@ -153,8 +133,8 @@ const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main');
const teacherRequestsNavBadge = document.getElementById('teacher-requests-nav-badge');
const teacherRequestsTitleBadge = document.getElementById('teacher-requests-title-badge');
const roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER;
const allowedTabs = new Set(roleNavigation.tabs);
const roleCapabilities = capabilitiesForRole(currentRole);
const allowedTabs = new Set(roleCapabilities.adminTabs);
// Setup Global Effects
applyRippleEffect();
@@ -258,8 +238,8 @@ async function switchTab(tab) {
window.location.hash = tab;
}
} catch (e) {
appContent.innerHTML = `<div class="form-alert error">Ошибка загрузки вкладки: ${e.message}</div>`;
console.error(e);
renderStatusMessage(appContent, 'Не удалось загрузить выбранную вкладку');
console.error('Не удалось загрузить вкладку');
}
// Close mobile menu if open
@@ -274,7 +254,7 @@ function configureRoleInterface() {
const settingsLink = document.querySelector('.settings-menu a[href="/admin/settings/"]');
if (settingsLink) {
settingsLink.hidden = !['ADMIN', 'EDUCATION_OFFICE'].includes(currentRole);
settingsLink.hidden = !SETTINGS_ROLES.includes(currentRole);
}
const firstAllowedTitle = {
@@ -297,7 +277,7 @@ async function refreshTeacherRequestBadge(countOverride = null) {
} catch (error) {
if (teacherRequestsNavBadge) teacherRequestsNavBadge.hidden = true;
if (teacherRequestsTitleBadge) teacherRequestsTitleBadge.hidden = true;
console.warn('Не удалось загрузить счётчик заявок:', error.message);
console.warn('Не удалось загрузить счётчик заявок');
}
}
@@ -323,7 +303,7 @@ document.addEventListener('teacherRequestsChanged', (event) => {
});
const hashTab = window.location.hash.replace('#', '');
switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab);
switchTab(allowedTabs.has(hashTab) ? hashTab : roleCapabilities.defaultAdminTab);
window.addEventListener('hashchange', () => {
const hashTab = window.location.hash.replace('#', '');
@@ -331,3 +311,6 @@ window.addEventListener('hashchange', () => {
switchTab(hashTab);
}
});
}
await bootstrap();

View File

@@ -1,47 +0,0 @@
import { WebTracerProvider } from 'https://esm.sh/@opentelemetry/sdk-trace-web@1.22.0';
import { getWebAutoInstrumentations } from 'https://esm.sh/@opentelemetry/auto-instrumentations-web@0.37.0';
import { OTLPTraceExporter } from 'https://esm.sh/@opentelemetry/exporter-trace-otlp-http@0.49.1';
import { BatchSpanProcessor } from 'https://esm.sh/@opentelemetry/sdk-trace-base@1.22.0';
import { registerInstrumentations } from 'https://esm.sh/@opentelemetry/instrumentation@0.49.1';
import { ZoneContextManager } from 'https://esm.sh/@opentelemetry/context-zone@1.22.0';
import { Resource } from 'https://esm.sh/@opentelemetry/resources@1.22.0';
import { SemanticResourceAttributes } from 'https://esm.sh/@opentelemetry/semantic-conventions@1.22.0';
// Инициализация провайдера метрик и трейсов с именем сервиса
const provider = new WebTracerProvider({
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: 'magistr-frontend-admin',
}),
});
// Экспортер отправляет данные на относительный путь /otel/v1/traces.
// На проде Caddy перехватит этот запрос и проксирует в SigNoz OTLP Collector (порт 4318).
const traceExporter = new OTLPTraceExporter({
url: window.location.origin + '/otel/v1/traces',
});
// Использование BatchSpanProcessor для буферизации трейсов перед отправкой
provider.addSpanProcessor(new BatchSpanProcessor(traceExporter));
// Использование ZoneContextManager для поддержки асинхронных операций (Promise, setTimeout, etc)
provider.register({
contextManager: new ZoneContextManager(),
});
// Регистрация авто-инструментаций для бразуера (document-load, xml-http-request, fetch, history, etc)
registerInstrumentations({
instrumentations: [
getWebAutoInstrumentations({
'@opentelemetry/instrumentation-fetch': {
propagateTraceHeaderCorsUrls: /.*/,
clearTimingResources: true,
},
'@opentelemetry/instrumentation-xml-http-request': {
propagateTraceHeaderCorsUrls: /.*/,
clearTimingResources: true,
},
}),
],
});
console.log('OpenTelemetry Web SDK initialized successfully.');

View File

@@ -0,0 +1,46 @@
const capabilities = {
ADMIN: {
defaultAdminTab: 'dashboard',
adminTabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users'],
defaultSettingsTab: 'general',
settingsTabs: ['general', 'time-slots', 'database', 'edu-forms']
},
EDUCATION_OFFICE: {
defaultAdminTab: 'dashboard',
adminTabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms'],
defaultSettingsTab: 'time-slots',
settingsTabs: ['time-slots', 'edu-forms']
},
DEPARTMENT: {
defaultAdminTab: 'department-workspace',
adminTabs: ['department-workspace', 'schedule-view'],
defaultSettingsTab: null,
settingsTabs: []
},
SCHEDULE_VIEWER: {
defaultAdminTab: 'schedule-view',
adminTabs: ['schedule-view'],
defaultSettingsTab: null,
settingsTabs: []
}
};
export const ROLE_CAPABILITIES = Object.freeze(
Object.fromEntries(Object.entries(capabilities).map(([role, value]) => [
role,
Object.freeze({
...value,
adminTabs: Object.freeze([...value.adminTabs]),
settingsTabs: Object.freeze([...value.settingsTabs])
})
]))
);
export const ADMIN_APP_ROLES = Object.freeze(Object.keys(ROLE_CAPABILITIES));
export const SETTINGS_ROLES = Object.freeze(
ADMIN_APP_ROLES.filter(role => ROLE_CAPABILITIES[role].settingsTabs.length > 0)
);
export function capabilitiesForRole(role) {
return ROLE_CAPABILITIES[role] || null;
}

View File

@@ -25,24 +25,44 @@ export function hideAlert(elementId) {
el.textContent = '';
}
/** Форматирует календарную дату по локальным компонентам без UTC-сдвига. */
export function formatLocalDate(date) {
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
throw new TypeError('Ожидалась корректная дата');
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function renderStatusMessage(container, message, className = 'form-alert error') {
if (!container) return;
const element = document.createElement('div');
element.className = className;
element.textContent = message;
container.replaceChildren(element);
}
export function renderTableMessage(tbody, columnCount, message) {
if (!tbody) return;
const row = document.createElement('tr');
const cell = document.createElement('td');
cell.colSpan = columnCount;
cell.className = 'loading-row';
cell.textContent = message;
row.appendChild(cell);
tbody.replaceChildren(row);
}
export function applyRippleEffect() {
document.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-primary, .btn-danger, .btn-danger-subtle, .btn-logout');
if (!btn) return;
const rect = btn.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const ripple = document.createElement('span');
ripple.classList.add('ripple');
ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;
if (getComputedStyle(btn).position === 'static') {
btn.style.position = 'relative';
}
btn.style.overflow = 'hidden';
btn.classList.add('ripple-host');
btn.appendChild(ripple);

View File

@@ -801,7 +801,7 @@ export async function initAcademicCalendar() {
const cellByWeekAndDay = new Map(rows.map(row => [`${row.weekNumber}:${row.dayOfWeek}`, row]));
return `
<div class="calendar-semester-table-wrap">
<table class="calendar-semester-table" style="--calendar-week-columns:${weekSlots.length}; --calendar-min-table-width:${32 + weekSlots.length * 22}px">
<table class="calendar-semester-table">
<colgroup>
<col class="calendar-day-head-col">
${weekSlots.map(() => '<col class="calendar-week-col">').join('')}
@@ -842,7 +842,6 @@ export async function initAcademicCalendar() {
const dayName = shortDayLabel(row.dayOfWeek);
const activity = activityById(selected) || activityTypes[0] || {};
const code = activity.code || row.activityCode || 'Т';
const color = activity.colorCode || '#64748b';
return `
<td class="calendar-day"
data-course="${row.courseNumber}"
@@ -853,8 +852,7 @@ export async function initAcademicCalendar() {
data-time-scope-id="${timeAssignmentForDate(row.date)?.scopeId || ''}"
role="button"
tabindex="0"
aria-label="${escapeHtml(`${formatShortDate(row.date)} ${dayName}, ${row.weekNumber} неделя, код ${code}`)}"
style="--activity-color:${escapeHtml(color)}">
aria-label="${escapeHtml(`${formatShortDate(row.date)} ${dayName}, ${row.weekNumber} неделя, код ${code}`)}">
<strong class="calendar-day-code">${escapeHtml(code)}</strong>
${timeAssignmentForDate(row.date) ? '<span class="calendar-day-time-marker">вр.</span>' : ''}
<select class="calendar-day-activity custom-select-initialized" aria-hidden="true" tabindex="-1">${options(activityTypes.map(toActivityOption), selected)}</select>
@@ -921,7 +919,7 @@ export async function initAcademicCalendar() {
return;
}
calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code" style="--activity-color:${escapeHtml(activity.colorCode || '#64748b')}">${escapeHtml(activity.code)}</span>
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
<div>
<strong>${escapeHtml(activity.name)}</strong>
<p>${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}</p>
@@ -938,7 +936,7 @@ export async function initAcademicCalendar() {
const activity = activityById(commonCalendarDayActivityValue(days));
if (!activity) {
calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code" style="--activity-color:#64748b">...</span>
<span class="calendar-day-dialog-code">...</span>
<div>
<strong>Выбрано ячеек: ${days.length}</strong>
<p>Выберите код активности, чтобы применить его ко всему выделению</p>
@@ -948,7 +946,7 @@ export async function initAcademicCalendar() {
}
calendarDayDialogActivitySummary.innerHTML = `
<span class="calendar-day-dialog-code" style="--activity-color:${escapeHtml(activity.colorCode || '#64748b')}">${escapeHtml(activity.code)}</span>
<span class="calendar-day-dialog-code">${escapeHtml(activity.code)}</span>
<div>
<strong>${escapeHtml(activity.name)} · ${days.length} ячеек</strong>
<p>${escapeHtml(activity.description || (activity.allowSchedule ? 'Обычные пары разрешены' : 'Обычные пары не генерируются'))}</p>
@@ -1041,8 +1039,6 @@ export async function initAcademicCalendar() {
const select = day.querySelector('.calendar-day-activity');
const activity = activityById(select.value);
const code = activity?.code || '-';
const color = activity?.colorCode || '#64748b';
day.style.setProperty('--activity-color', color);
day.querySelector('.calendar-day-code').textContent = code;
day.setAttribute('aria-label', `${formatShortDate(day.dataset.date)} ${shortDayLabel(day.dataset.day)}, ${day.dataset.week} неделя, код ${code}`);
}
@@ -1053,11 +1049,8 @@ export async function initAcademicCalendar() {
return;
}
const tooltip = ensureCalendarDayTooltip();
const activity = activityById(day.querySelector('.calendar-day-activity')?.value);
tooltip.style.setProperty('--activity-color', activity?.colorCode || '#64748b');
tooltip.innerHTML = renderCalendarDayTooltip(day);
tooltip.classList.add('visible');
positionCalendarDayTooltip(tooltip, day, event);
}
function hideCalendarDayTooltip() {
@@ -1076,14 +1069,13 @@ export async function initAcademicCalendar() {
function renderCalendarDayTooltip(day) {
const activity = activityById(day.querySelector('.calendar-day-activity')?.value);
const code = activity?.code || day.querySelector('.calendar-day-code')?.textContent || '-';
const color = activity?.colorCode || '#64748b';
const timeAssignment = timeAssignmentForDate(day.dataset.date);
const timeLabel = timeAssignment
? (timeSlotScopes.find(scope => String(scope.id) === String(timeAssignment.scopeId))?.name || 'ручная сетка')
: automaticTimeScopeLabel(day.dataset.date).replace('По правилу даты ', '');
return `
<div class="calendar-day-tooltip-head">
<span class="calendar-day-tooltip-code" style="--activity-color:${escapeHtml(color)}"><span>${escapeHtml(code)}</span></span>
<span class="calendar-day-tooltip-code"><span>${escapeHtml(code)}</span></span>
<div>
<strong>${escapeHtml(formatLongDate(day.dataset.date))}</strong>
<span>${escapeHtml(shortDayLabel(day.dataset.day))}, ${escapeHtml(day.dataset.week)} неделя</span>
@@ -1106,24 +1098,6 @@ export async function initAcademicCalendar() {
`;
}
function positionCalendarDayTooltip(tooltip, day, event) {
const offset = 14;
const rect = day.getBoundingClientRect();
const x = event?.clientX ?? rect.left + rect.width / 2;
const y = event?.clientY ?? rect.top;
const tooltipRect = tooltip.getBoundingClientRect();
let left = x + offset;
let top = y + offset;
if (left + tooltipRect.width > window.innerWidth - 12) {
left = x - tooltipRect.width - offset;
}
if (top + tooltipRect.height > window.innerHeight - 12) {
top = y - tooltipRect.height - offset;
}
tooltip.style.left = `${Math.max(12, left)}px`;
tooltip.style.top = `${Math.max(12, top)}px`;
}
function startCalendarDayDragSelection(event, day) {
if (event.button !== 0) return;
hideCalendarDayTooltip();
@@ -1246,10 +1220,9 @@ export async function initAcademicCalendar() {
calendarTotals.innerHTML = Array.from(counts.entries())
.sort(([a], [b]) => a.localeCompare(b, 'ru'))
.map(([code, count]) => {
const activity = activityTypes.find(item => item.code === code);
const selectedClass = selectedCode === code ? ' is-selected' : '';
const suffix = selectedCode === code ? '<span class="calendar-total-selected">выбрано</span>' : '';
return `<span class="calendar-total-chip${selectedClass}" style="--chip-color:${escapeHtml(activity?.colorCode || '#64748b')}">${escapeHtml(code)}: ${count}${suffix}</span>`;
return `<span class="calendar-total-chip${selectedClass}">${escapeHtml(code)}: ${count}${suffix}</span>`;
}).join('');
}

View File

@@ -5,7 +5,7 @@ async function fetchEquipments() {
try {
return await api.get('/api/equipments');
} catch (e) {
console.error("Failed to fetch equipments", e);
console.error('Не удалось загрузить список оборудования');
return [];
}
}

View File

@@ -325,18 +325,16 @@ export async function initDashboard() {
const conflictCards = conflicts.map(c => {
const isError = c.severity === 'error';
const borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b';
const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)';
const badgeIcon = isError
? `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>`
: `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`;
return `
<div style="background: ${bgCol}; border: 1px solid rgba(229, 231, 235, 0.1); border-left: 4px solid ${borderCol}; border-radius: 8px; padding: 1rem; display: flex; gap: 0.75rem; align-items: start;">
<div style="margin-top: 0.1rem;">${badgeIcon}</div>
<div style="flex: 1;">
<h4 style="margin: 0 0 0.25rem 0; font-size: 0.95rem; font-weight: 600; color: ${isError ? 'var(--error, #ef4444)' : '#d97706'};">${escapeHtml(c.title)}</h4>
<p style="margin: 0; font-size: 0.85rem; line-height: 1.4; color: var(--text-main);">${c.description}</p>
<div class="dashboard-conflict-card dashboard-conflict-card--${isError ? 'error' : 'warning'}">
<div class="dashboard-conflict-card__icon">${badgeIcon}</div>
<div class="dashboard-conflict-card__body">
<h4>${escapeHtml(c.title)}</h4>
<p>${escapeHtml(c.description)}</p>
</div>
</div>
`;

View File

@@ -1,11 +1,12 @@
import { api } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
import { api, getSession } from '../api.js';
import { escapeHtml, formatLocalDate, showAlert, hideAlert } from '../utils.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
export async function initDepartmentWorkspace() {
const role = localStorage.getItem('role');
const userDepartmentId = localStorage.getItem('departmentId');
const session = getSession();
const role = session?.role;
const userDepartmentId = session?.departmentId;
const departmentField = document.getElementById('department-workspace-department-field');
const departmentSelect = document.getElementById('department-workspace-department');
const startInput = document.getElementById('department-workspace-start');
@@ -493,14 +494,10 @@ function fillSelect(id, items, valueFn, labelFn, emptyLabel) {
function setDefaultDates(startInput, endInput) {
const today = new Date();
const nextWeek = new Date();
const nextWeek = new Date(today);
nextWeek.setDate(today.getDate() + 7);
startInput.value = toIsoDate(today);
endInput.value = toIsoDate(nextWeek);
}
function toIsoDate(date) {
return date.toISOString().slice(0, 10);
startInput.value = formatLocalDate(today);
endInput.value = formatLocalDate(nextWeek);
}
function formatDateTime(value) {

View File

@@ -5,7 +5,7 @@ async function fetchEducationForms() {
try {
return await api.get('/api/education-forms');
} catch (e) {
console.error("Failed to fetch education forms", e);
console.error('Не удалось загрузить формы обучения');
return [];
}
}
@@ -182,7 +182,7 @@ export async function initGroups() {
try {
subgroups = await api.get('/api/subgroups');
} catch (error) {
console.error("Failed to load subgroups", error);
console.error('Не удалось загрузить подгруппы');
}
}
@@ -560,7 +560,7 @@ export async function initGroups() {
renderSubgroupCapacityFields(count, current.map(item => item.studentCapacity));
subgroupFormTitle.textContent = 'Настройка подгрупп';
btnSaveSubgroup.textContent = 'Применить';
btnCancelSubgroupEdit.style.display = 'none';
btnCancelSubgroupEdit.hidden = true;
hideAlert('manage-subgroup-alert');
}

View File

@@ -1,4 +1,4 @@
import { api } from '../api.js';
import { api, getSession } from '../api.js';
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
const ROLE_DEPARTMENT = 'DEPARTMENT';
@@ -38,8 +38,9 @@ const MOBILE_QUERY = '(max-width: 760px)';
const DEFAULT_SEMESTER_WEEKS = 22;
export async function initScheduleView() {
const role = localStorage.getItem('role');
const userDepartmentId = normalizeId(localStorage.getItem('departmentId'));
const session = getSession();
const role = session?.role;
const userDepartmentId = normalizeId(session?.departmentId);
const dateInput = document.getElementById('schedule-view-date');
const targetSelect = document.getElementById('schedule-view-target');
const loadButton = document.getElementById('schedule-view-load');

View File

@@ -658,13 +658,7 @@ export async function initSchedule() {
slotIndex: button.dataset.slotIndex || ''
};
if (!visualContextMenu) return;
const rect = button.getBoundingClientRect();
visualContextMenu.hidden = false;
const menuRect = visualContextMenu.getBoundingClientRect();
const top = Math.min(rect.bottom + 6, window.innerHeight - menuRect.height - 12);
const left = Math.min(rect.left, window.innerWidth - menuRect.width - 12);
visualContextMenu.style.top = `${Math.max(12, top)}px`;
visualContextMenu.style.left = `${Math.max(12, left)}px`;
visualContextMenu.querySelector('button')?.focus();
}
@@ -672,8 +666,6 @@ export async function initSchedule() {
visualContextTarget = null;
if (!visualContextMenu) return;
visualContextMenu.hidden = true;
visualContextMenu.style.top = '';
visualContextMenu.style.left = '';
}
async function handleVisualContextMenuClick(event) {

View File

@@ -1,5 +1,5 @@
import { api } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
import { escapeHtml, hideAlert, renderTableMessage, showAlert } from '../utils.js';
export async function initTeacherRequests() {
const requestsTbody = document.getElementById('teacher-requests-tbody');
@@ -23,7 +23,7 @@ export async function initTeacherRequests() {
detail: { count: normalizedRequests.length }
}));
} catch (error) {
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей');
updateCount(0);
}
}
@@ -44,10 +44,10 @@ export async function initTeacherRequests() {
`).join('')}
</select>
</td>
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50"></td>
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50" autocomplete="username"></td>
<td><input type="text" class="teacher-request-fullname" value="${escapeHtml(request.fullName || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-jobtitle" value="${escapeHtml(request.jobTitle || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-password" placeholder="Минимум 8 символов"></td>
<td><input type="password" class="teacher-request-password" placeholder="Минимум 8 символов" autocomplete="new-password"></td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>
<div class="teacher-request-actions">
@@ -131,6 +131,6 @@ export async function initTeacherRequests() {
await loadDepartments();
await loadRequests();
} catch (error) {
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки')}</td></tr>`;
renderTableMessage(requestsTbody, 7, 'Не удалось загрузить заявки преподавателей');
}
}

View File

@@ -335,7 +335,7 @@ export async function initUniversityStructure() {
profileIdInput.value = '';
profileFormTitle.textContent = 'Создание профиля';
btnSaveProfile.textContent = 'Создать';
btnCancelProfileEdit.style.display = 'none';
btnCancelProfileEdit.hidden = true;
hideAlert('manage-profile-alert');
}
@@ -384,7 +384,7 @@ export async function initUniversityStructure() {
profileDescInput.value = profile.description || '';
profileFormTitle.textContent = 'Редактирование профиля';
btnSaveProfile.textContent = 'Сохранить';
btnCancelProfileEdit.style.display = 'inline-block';
btnCancelProfileEdit.hidden = false;
hideAlert('manage-profile-alert');
return;
}
@@ -451,7 +451,7 @@ export async function initUniversityStructure() {
tabProfileSpecSelect.disabled = false;
mainProfileFormTitle.textContent = 'Создание профиля';
btnTabSaveProfile.textContent = 'Создать';
btnTabCancelProfileEdit.style.display = 'none';
btnTabCancelProfileEdit.hidden = true;
hideAlert('tab-profile-alert');
tabProfileSpecSelect.dispatchEvent(new Event('change'));
}
@@ -507,7 +507,7 @@ export async function initUniversityStructure() {
mainProfileFormTitle.textContent = 'Редактирование профиля';
btnTabSaveProfile.textContent = 'Сохранить';
btnTabCancelProfileEdit.style.display = 'inline-block';
btnTabCancelProfileEdit.hidden = false;
hideAlert('tab-profile-alert');
createProfileTabForm.scrollIntoView({ behavior: 'smooth', block: 'nearest' });

View File

@@ -5,10 +5,6 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Настройки — Magistr</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../css/components.css">
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/layout.css">

View File

@@ -1,13 +1,17 @@
// Основной модуль страницы настроек
import { startDropdownAutoObserver, initAllCustomDropdowns } from '../../js/dropdown.js';
import { renderStatusMessage } from '../../js/utils.js';
import { SETTINGS_ROLES, capabilitiesForRole } from '../../js/role-capabilities.js';
import { getToken } from '../../js/api.js';
import { initializeSession } from '../../js/api.js';
// Проверка авторизации
const role = localStorage.getItem('role');
if (!getToken() || !['ADMIN', 'EDUCATION_OFFICE'].includes(role)) {
window.location.href = '/';
async function bootstrap() {
const session = await initializeSession();
if (!session || !SETTINGS_ROLES.includes(session.role)) {
window.location.replace('/');
return;
}
const role = session.role;
// Глобальная инициализация кастомных списков
document.addEventListener('DOMContentLoaded', () => {
@@ -44,12 +48,9 @@ const ROUTES = {
},
};
const ROLE_TABS = {
ADMIN: ['general', 'time-slots', 'database', 'edu-forms'],
EDUCATION_OFFICE: ['time-slots', 'edu-forms']
};
const allowedTabs = new Set(ROLE_TABS[role] || []);
const defaultTab = role === 'EDUCATION_OFFICE' ? 'time-slots' : 'general';
const roleCapabilities = capabilitiesForRole(role);
const allowedTabs = new Set(roleCapabilities.settingsTabs);
const defaultTab = roleCapabilities.defaultSettingsTab;
let currentTab = null;
@@ -129,8 +130,8 @@ async function switchTab(tab) {
currentTab = tab;
} catch (e) {
appContent.innerHTML = `<div style="padding:1rem;color:var(--error);">Ошибка загрузки: ${e.message}</div>`;
console.error(e);
renderStatusMessage(appContent, 'Не удалось загрузить раздел настроек');
console.error('Не удалось загрузить раздел настроек');
}
// Закрываем мобильное меню после перехода
@@ -140,3 +141,6 @@ async function switchTab(tab) {
// Вкладка по умолчанию
switchTab(defaultTab);
}
await bootstrap();

View File

@@ -1,5 +1,5 @@
import { api } from '../../../js/api.js';
import { escapeHtml, showAlert, hideAlert } from '../../../js/utils.js';
import { escapeHtml, showAlert, hideAlert, renderStatusMessage, renderTableMessage } from '../../../js/utils.js';
export async function initDatabase() {
const tenantsTbody = document.getElementById('tenants-tbody');
@@ -12,8 +12,8 @@ export async function initDatabase() {
try {
const data = await api.get('/api/database/status');
const statusBadge = data.connected
? '<span class="badge badge-available">Online</span>'
: '<span class="badge badge-unavailable">Offline</span>';
? '<span class="badge badge-available">Доступно</span>'
: '<span class="badge badge-unavailable">Недоступно</span>';
statusInfo.innerHTML = `
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;">
@@ -36,7 +36,7 @@ export async function initDatabase() {
</div>
`;
} catch (e) {
statusInfo.innerHTML = `<div class="form-alert error" style="display:block">Ошибка загрузки статуса: ${e.message}</div>`;
renderStatusMessage(statusInfo, 'Не удалось загрузить статус подключения');
}
}
@@ -46,7 +46,7 @@ export async function initDatabase() {
const tenants = await api.get('/api/database/tenants');
renderTenantsTable(tenants);
} catch (e) {
tenantsTbody.innerHTML = `<tr><td colspan="6" class="loading-row">Ошибка загрузки: ${e.message}</td></tr>`;
renderTableMessage(tenantsTbody, 6, 'Не удалось загрузить список тенантов');
}
}
@@ -58,8 +58,8 @@ export async function initDatabase() {
tenantsTbody.innerHTML = tenants.map(t => {
const statusBadge = t.connected
? '<span class="badge badge-available">Online</span>'
: '<span class="badge badge-unavailable">Offline</span>';
? '<span class="badge badge-available">Доступно</span>'
: '<span class="badge badge-unavailable">Недоступно</span>';
return `
<tr>
@@ -96,7 +96,7 @@ export async function initDatabase() {
showAlert('add-tenant-alert', `${result.message}`, 'error');
}
} catch (e) {
showAlert('add-tenant-alert', `Ошибка: ${e.message}`, 'error');
showAlert('add-tenant-alert', 'Не удалось проверить подключение', 'error');
} finally {
btnTest.textContent = 'Тест';
btnTest.disabled = false;
@@ -122,7 +122,7 @@ export async function initDatabase() {
try {
const result = await api.post('/api/database/tenants', { name, domain, url, username, password });
if (result.success) {
showAlert('add-tenant-alert', `Тенант "${escapeHtml(domain)}" добавлен!`, 'success');
showAlert('add-tenant-alert', `Тенант «${domain}» добавлен`, 'success');
addTenantForm.reset();
loadTenants();
loadStatus();
@@ -130,7 +130,7 @@ export async function initDatabase() {
showAlert('add-tenant-alert', result.message, 'error');
}
} catch (e) {
showAlert('add-tenant-alert', `Ошибка: ${e.message}`, 'error');
showAlert('add-tenant-alert', 'Не удалось добавить тенант', 'error');
}
});
@@ -147,7 +147,7 @@ export async function initDatabase() {
loadTenants();
loadStatus();
} catch (e) {
alert(`Ошибка: ${e.message}`);
alert('Не удалось удалить тенант');
}
});

View File

@@ -8,7 +8,7 @@ export async function fetchEducationForms() {
allEducationForms = await api.get('/api/education-forms');
return allEducationForms;
} catch (e) {
console.error("Failed to fetch education forms", e);
console.error('Не удалось загрузить формы обучения');
return [];
}
}

View File

@@ -62,8 +62,8 @@ export async function initTimeSlotsSettings() {
form?.addEventListener('submit', saveSlot);
resetButton?.addEventListener('click', resetForm);
refreshButton?.addEventListener('click', loadAll);
startInput?.addEventListener('change', fillDurationIfEmpty);
endInput?.addEventListener('change', fillDurationIfEmpty);
startInput?.addEventListener('change', calculateDuration);
endInput?.addEventListener('change', calculateDuration);
tbody?.addEventListener('click', handleSlotTableClick);
await loadAll();
@@ -206,8 +206,7 @@ export async function initTimeSlotsSettings() {
orderNumber: Number(orderInput.value),
scopeId: activeScopeId,
startTime: startInput.value,
endTime: endInput.value,
durationMinutes: durationInput.value ? Number(durationInput.value) : null
endTime: endInput.value
};
try {
@@ -272,8 +271,9 @@ export async function initTimeSlotsSettings() {
}
}
function fillDurationIfEmpty() {
if (durationInput.value || !startInput.value || !endInput.value || startInput.value >= endInput.value) return;
function calculateDuration() {
durationInput.value = '';
if (!startInput.value || !endInput.value || startInput.value >= endInput.value) return;
const [startHour, startMinute] = startInput.value.split(':').map(Number);
const [endHour, endMinute] = endInput.value.split(':').map(Number);
durationInput.value = String((endHour * 60 + endMinute) - (startHour * 60 + startMinute));

View File

@@ -55,11 +55,11 @@
<div class="form-row" style="margin-top: 0.75rem;">
<div class="form-group">
<label for="tenant-username">Пользователь</label>
<input type="text" id="tenant-username" placeholder="postgres" required>
<input type="text" id="tenant-username" placeholder="postgres" autocomplete="username" required>
</div>
<div class="form-group">
<label for="tenant-password">Пароль</label>
<input type="password" id="tenant-password" placeholder="••••••••" required>
<input type="password" id="tenant-password" placeholder="••••••••" autocomplete="new-password" required>
</div>
<button type="button" class="btn btn-md btn-secondary" id="btn-test-connection" style="height: fit-content;">
Тест

View File

@@ -70,7 +70,9 @@
<label class="form-field" for="time-slot-duration">
<span>Минут</span>
<input type="number" id="time-slot-duration" min="1" step="1" placeholder="90">
<input type="number" id="time-slot-duration" min="1" step="1" readonly
aria-describedby="time-slot-duration-hint">
<small id="time-slot-duration-hint">Рассчитывается по времени начала и окончания</small>
</label>
<button type="submit" class="btn btn-md btn-primary">Сохранить слот</button>

View File

@@ -5,11 +5,11 @@
<div class="form-row">
<div class="form-group">
<label for="new-username">Имя пользователя</label>
<input type="text" id="new-username" placeholder="username" required>
<input type="text" id="new-username" placeholder="Имя пользователя" autocomplete="username" required>
</div>
<div class="form-group">
<label for="new-password">Пароль</label>
<input type="text" id="new-password" placeholder="Минимум 8 символов" required>
<input type="password" id="new-password" placeholder="Минимум 8 символов" autocomplete="new-password" required>
</div>
<div class="form-group">
<label for="new-role">Роль</label>

119
frontend/auth-session.js Normal file
View File

@@ -0,0 +1,119 @@
const LEGACY_AUTH_KEYS = ['token', 'role', 'departmentId', 'userId'];
let accessToken = null;
let authState = null;
let refreshPromise = null;
purgeLegacyAuthStorage();
export function getAccessToken() {
return accessToken;
}
export function getAuthState() {
return authState ? { ...authState } : null;
}
export function storeAuthState(data) {
if (!data?.token || !data?.role) {
clearAuthState();
return null;
}
accessToken = data.token;
authState = {
role: data.role,
departmentId: normalizeOptionalId(data.departmentId),
userId: normalizeOptionalId(data.userId),
redirect: typeof data.redirect === 'string' ? data.redirect : '/'
};
return getAuthState();
}
export function clearAuthState() {
accessToken = null;
authState = null;
}
export async function restoreSession() {
if (accessToken && authState) {
return getAuthState();
}
const refreshed = await refreshAccessToken();
return refreshed ? getAuthState() : null;
}
export async function refreshAccessToken() {
if (refreshPromise) {
return refreshPromise;
}
refreshPromise = (async () => {
let response;
try {
response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'same-origin'
});
} catch (error) {
clearAuthState();
return false;
}
const data = await response.json().catch(() => null);
if (!response.ok || !storeAuthState(data)) {
clearAuthState();
return false;
}
return true;
})().finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
export async function fetchWithAuth(endpoint, options = {}, retryOnUnauthorized = true) {
const headers = new Headers(options.headers || {});
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
const response = await fetch(endpoint, {
...options,
headers,
credentials: 'same-origin'
});
if (response.status === 401 && retryOnUnauthorized && await refreshAccessToken()) {
return fetchWithAuth(endpoint, options, false);
}
return response;
}
export async function logoutSession() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'same-origin'
});
} catch (error) {
console.warn('Не удалось завершить серверную сессию');
} finally {
clearAuthState();
}
}
function purgeLegacyAuthStorage() {
try {
LEGACY_AUTH_KEYS.forEach(key => localStorage.removeItem(key));
LEGACY_AUTH_KEYS.forEach(key => sessionStorage.removeItem(key));
} catch (error) {
console.warn('Не удалось очистить устаревшие данные авторизации в браузере');
}
}
function normalizeOptionalId(value) {
return value === undefined || value === null || value === '' ? null : String(value);
}

View File

@@ -7,8 +7,6 @@
<meta http-equiv="refresh" content="0; url=/admin/#department-workspace">
</head>
<body>
<script>
window.location.replace('/admin/#department-workspace');
</script>
<p><a href="/admin/#department-workspace">Перейти в кабинет кафедры</a></p>
</body>
</html>

View File

@@ -7,8 +7,6 @@
<meta http-equiv="refresh" content="0; url=/admin/#schedule-view">
</head>
<body>
<script>
window.location.replace('/admin/#schedule-view');
</script>
<p><a href="/admin/#schedule-view">Перейти в кабинет учебного отдела</a></p>
</body>
</html>

View File

@@ -5,9 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Страница авторизации">
<title>Авторизация</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
@@ -82,6 +79,6 @@
</main>
<script src="theme-toggle.js"></script>
<script src="script.js"></script>
<script type="module" src="script.js"></script>
</body>
</html>

949
frontend/package-lock.json generated Normal file
View File

@@ -0,0 +1,949 @@
{
"name": "magistr-frontend-checks",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "magistr-frontend-checks",
"devDependencies": {
"@opentelemetry/api": "1.9.0",
"@opentelemetry/auto-instrumentations-web": "0.65.0",
"@opentelemetry/context-zone": "2.9.0",
"@opentelemetry/exporter-trace-otlp-http": "0.220.0",
"@opentelemetry/instrumentation": "0.220.0",
"@opentelemetry/resources": "2.9.0",
"@opentelemetry/sdk-trace-base": "2.9.0",
"@opentelemetry/sdk-trace-web": "2.9.0",
"@opentelemetry/semantic-conventions": "1.43.0",
"esbuild": "0.25.12",
"zone.js": "0.16.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/api-logs": {
"version": "0.220.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz",
"integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.3.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/auto-instrumentations-web": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-web/-/auto-instrumentations-web-0.65.0.tgz",
"integrity": "sha512-POJmrp9P4xSw0w5HOZLxRQe/bS3TkuEUSJnPo/+S75FF9yrEoUhCkUahWYwqcjsMIZmer1tsbW1gjvkx2mZsgQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/instrumentation": "^0.220.0",
"@opentelemetry/instrumentation-document-load": "^0.65.0",
"@opentelemetry/instrumentation-fetch": "^0.220.0",
"@opentelemetry/instrumentation-user-interaction": "^0.64.0",
"@opentelemetry/instrumentation-xml-http-request": "^0.220.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.9.0",
"zone.js": "^0.11.4 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0"
}
},
"node_modules/@opentelemetry/context-zone": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/context-zone/-/context-zone-2.9.0.tgz",
"integrity": "sha512-dUH2j6/jh5hgsAFvEqx+9MIjdi8OzBK1u8K6qsb2HE2R1AiR+u00GWCxbDts30JggTX4rwA8YNLO2xF5LC6EFg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/context-zone-peer-dep": "2.9.0",
"zone.js": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
}
},
"node_modules/@opentelemetry/context-zone-peer-dep": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/context-zone-peer-dep/-/context-zone-peer-dep-2.9.0.tgz",
"integrity": "sha512-xECINyl1UHLNbaF1my3K5AqJGyox1ysLidplInAb/XTfBxc2CLDRuS4rUxiJs8irW/PkEDujdc/IPUqkJFnrzg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0",
"zone.js": "^0.10.2 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0"
}
},
"node_modules/@opentelemetry/core": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz",
"integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/exporter-trace-otlp-http": {
"version": "0.220.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.220.0.tgz",
"integrity": "sha512-/+ExB3lRkf+erv4PnoywyL7RHKITidxtUpUTS55k7OQ0dB42S7gEF1gry7swb9MSm1hYLUhJg4QQh9W8SpwwqA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.9.0",
"@opentelemetry/otlp-exporter-base": "0.220.0",
"@opentelemetry/otlp-transformer": "0.220.0",
"@opentelemetry/resources": "2.9.0",
"@opentelemetry/sdk-trace": "2.9.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/instrumentation": {
"version": "0.220.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz",
"integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.220.0",
"import-in-the-middle": "^3.0.0",
"require-in-the-middle": "^8.0.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/instrumentation-document-load": {
"version": "0.65.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-document-load/-/instrumentation-document-load-0.65.0.tgz",
"integrity": "sha512-xAhNXUIywjqmaSkpimsuUpRr0QYebMyTn0IJS9wFWYVLyH+2Q5IYWYys4IrT60foeyf2gyFmRBQyHupblNdaMA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "^2.0.0",
"@opentelemetry/instrumentation": "^0.220.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@opentelemetry/semantic-conventions": "^1.23.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/instrumentation-fetch": {
"version": "0.220.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fetch/-/instrumentation-fetch-0.220.0.tgz",
"integrity": "sha512-9LW+zgvKK6kqojB0GVqbOCRGBUWiatHPmRTusjSVdwYCImIgJRrJTKu6WhjHdejJSjRS/mGsw/w4ETZBqav3kQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.9.0",
"@opentelemetry/instrumentation": "0.220.0",
"@opentelemetry/sdk-trace-web": "2.9.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/instrumentation-user-interaction": {
"version": "0.64.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-user-interaction/-/instrumentation-user-interaction-0.64.0.tgz",
"integrity": "sha512-y9P9CP8WzuJBZL/h4rSa0jhpDlHTFWiuGhe/xxfyvHyA1UuI4QW2ZcsdFdQDak29J20tGZzkAoi6EqZDRU7xVg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "^2.0.0",
"@opentelemetry/instrumentation": "^0.220.0",
"@opentelemetry/sdk-trace-web": "^2.0.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0",
"zone.js": "^0.11.4 || ^0.13.0 || ^0.14.0 || ^0.15.0 || ^0.16.0"
}
},
"node_modules/@opentelemetry/instrumentation-xml-http-request": {
"version": "0.220.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-xml-http-request/-/instrumentation-xml-http-request-0.220.0.tgz",
"integrity": "sha512-9FTY+kT3z5D8f7dC+Tubd4oJTu6tOGlQmws5J8e7DLMeIUwpWmLquRFV2BuUShiAPxTMGqqsZnuT3CVVbTu0JA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.9.0",
"@opentelemetry/instrumentation": "0.220.0",
"@opentelemetry/sdk-trace-web": "2.9.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-exporter-base": {
"version": "0.220.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.220.0.tgz",
"integrity": "sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.9.0",
"@opentelemetry/otlp-transformer": "0.220.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-transformer": {
"version": "0.220.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.220.0.tgz",
"integrity": "sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.220.0",
"@opentelemetry/core": "2.9.0",
"@opentelemetry/resources": "2.9.0",
"@opentelemetry/sdk-logs": "0.220.0",
"@opentelemetry/sdk-metrics": "2.9.0",
"@opentelemetry/sdk-trace": "2.9.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/resources": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz",
"integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.9.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-logs": {
"version": "0.220.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.220.0.tgz",
"integrity": "sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.220.0",
"@opentelemetry/core": "2.9.0",
"@opentelemetry/resources": "2.9.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.4.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-metrics": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.9.0.tgz",
"integrity": "sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.9.0",
"@opentelemetry/resources": "2.9.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.9.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.9.0.tgz",
"integrity": "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.9.0",
"@opentelemetry/resources": "2.9.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz",
"integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.9.0",
"@opentelemetry/resources": "2.9.0",
"@opentelemetry/sdk-trace": "2.9.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"node_modules/@opentelemetry/sdk-trace-web": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.9.0.tgz",
"integrity": "sha512-LS4XlzOK3e6YYdt84m15AmRR04121rKipmifi4XFZToH1h75f7F2bzHLbB1NMgIEYJ0jSKYm4VsK5mws/5kfTQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.9.0",
"@opentelemetry/sdk-trace-base": "2.9.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"node_modules/@opentelemetry/semantic-conventions": {
"version": "1.43.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz",
"integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/cjs-module-lexer": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz",
"integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/es-module-lexer": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.12",
"@esbuild/android-arm": "0.25.12",
"@esbuild/android-arm64": "0.25.12",
"@esbuild/android-x64": "0.25.12",
"@esbuild/darwin-arm64": "0.25.12",
"@esbuild/darwin-x64": "0.25.12",
"@esbuild/freebsd-arm64": "0.25.12",
"@esbuild/freebsd-x64": "0.25.12",
"@esbuild/linux-arm": "0.25.12",
"@esbuild/linux-arm64": "0.25.12",
"@esbuild/linux-ia32": "0.25.12",
"@esbuild/linux-loong64": "0.25.12",
"@esbuild/linux-mips64el": "0.25.12",
"@esbuild/linux-ppc64": "0.25.12",
"@esbuild/linux-riscv64": "0.25.12",
"@esbuild/linux-s390x": "0.25.12",
"@esbuild/linux-x64": "0.25.12",
"@esbuild/netbsd-arm64": "0.25.12",
"@esbuild/netbsd-x64": "0.25.12",
"@esbuild/openbsd-arm64": "0.25.12",
"@esbuild/openbsd-x64": "0.25.12",
"@esbuild/openharmony-arm64": "0.25.12",
"@esbuild/sunos-x64": "0.25.12",
"@esbuild/win32-arm64": "0.25.12",
"@esbuild/win32-ia32": "0.25.12",
"@esbuild/win32-x64": "0.25.12"
}
},
"node_modules/import-in-the-middle": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.1.tgz",
"integrity": "sha512-0rymlHSFLwZ0ixx8DaQkoIyZojJPY2a0K2nEYslhKJ6jIYO/m0IcCb7iQsFPmS7WmKwISZiIrv5Icstrw/CmqA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"cjs-module-lexer": "^2.2.0",
"es-module-lexer": "^2.2.0",
"module-details-from-path": "^1.0.4"
},
"engines": {
"node": ">=18"
}
},
"node_modules/module-details-from-path": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz",
"integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==",
"dev": true,
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/require-in-the-middle": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz",
"integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
"module-details-from-path": "^1.0.3"
},
"engines": {
"node": ">=9.3.0 || >=8.10.0 <9.0.0"
}
},
"node_modules/zone.js": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.0.tgz",
"integrity": "sha512-LqLPpIQANebrlxY6jKcYKdgN5DTXyyHAKnnWWjE5pPfEQ4n7j5zn7mOEEpwNZVKGqx3kKKmvplEmoBrvpgROTA==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -6,7 +6,22 @@
"node": ">=18"
},
"scripts": {
"build": "npm run build:vendor",
"build:vendor": "node scripts/build-vendor.mjs",
"test": "node --test tests/*.test.mjs",
"check": "node --check admin/js/dashboard-conflicts.js && node --check admin/js/views/dashboard.js && node --test tests/*.test.mjs"
"check": "node --check auth-session.js && node --check telemetry.js && node --check script.js && node --check admin/js/api.js && node --check admin/js/main.js && node --check teacher/app.js && node --check student/app.js && node --test tests/*.test.mjs"
},
"devDependencies": {
"@opentelemetry/api": "1.9.0",
"@opentelemetry/auto-instrumentations-web": "0.65.0",
"@opentelemetry/context-zone": "2.9.0",
"@opentelemetry/exporter-trace-otlp-http": "0.220.0",
"@opentelemetry/instrumentation": "0.220.0",
"@opentelemetry/resources": "2.9.0",
"@opentelemetry/sdk-trace-base": "2.9.0",
"@opentelemetry/sdk-trace-web": "2.9.0",
"@opentelemetry/semantic-conventions": "1.43.0",
"esbuild": "0.25.12",
"zone.js": "0.16.0"
}
}

10
frontend/proxy.conf Normal file
View File

@@ -0,0 +1,10 @@
ProxyRequests Off
ProxyPreserveHost On
# Same-origin API для локального Docker Compose.
ProxyPass "/api" "http://backend:8080/api" connectiontimeout=5 timeout=60 retry=0
ProxyPassReverse "/api" "http://backend:8080/api"
# Локальная диагностика без публикации backend-порта на хосте.
ProxyPass "/actuator/health" "http://backend:8080/actuator/health" connectiontimeout=5 timeout=10 retry=0
ProxyPassReverse "/actuator/health" "http://backend:8080/actuator/health"

View File

@@ -1,34 +1,11 @@
import { clearAuthState, storeAuthState } from './auth-session.js';
import { initializeTelemetry } from './telemetry.js';
void initializeTelemetry();
(() => {
'use strict';
// --- OpenTelemetry Frontend Instrumentation ---
// Загружаем OTel только на продакшене (не на localhost)
if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('https://esm.sh/@opentelemetry/sdk-trace-web').then(async ({ WebTracerProvider, BatchSpanProcessor }) => {
const { OTLPTraceExporter } = await import('https://esm.sh/@opentelemetry/exporter-trace-otlp-http');
const { getWebAutoInstrumentations } = await import('https://esm.sh/@opentelemetry/auto-instrumentations-web');
const { registerInstrumentations } = await import('https://esm.sh/@opentelemetry/instrumentation');
const { Resource } = await import('https://esm.sh/@opentelemetry/resources');
const exporter = new OTLPTraceExporter({
url: window.location.origin + '/otel/v1/traces'
});
const provider = new WebTracerProvider({
resource: new Resource({ 'service.name': 'magistr-frontend' }),
});
provider.addSpanProcessor(new BatchSpanProcessor(exporter));
provider.register();
registerInstrumentations({
instrumentations: [getWebAutoInstrumentations()]
});
console.log("SigNoz (OpenTelemetry) инициализирован во фронтенде.");
}).catch(e => console.error("Ошибка загрузки OTel:", e));
}
// ----------------------------------------------
const form = document.getElementById('login-form');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
@@ -42,14 +19,8 @@
// Ripple effect
btnSubmit.addEventListener('click', function(e) {
const rect = this.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const ripple = document.createElement('span');
ripple.classList.add('ripple');
ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;
this.appendChild(ripple);
@@ -141,15 +112,8 @@
if (response.ok) {
showAlert('Вход выполнен успешно!', 'success');
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);
clearAuthState();
storeAuthState(data);
const redirect = data.redirect || '/';
setTimeout(() => { window.location.href = redirect; }, 400);

View File

@@ -0,0 +1,10 @@
import { build } from 'esbuild';
await build({
entryPoints: ['telemetry/otel-entry.js'],
bundle: true,
format: 'esm',
platform: 'browser',
target: ['es2022'],
outfile: 'dist/vendor/otel.js'
});

5
frontend/security.conf Normal file
View File

@@ -0,0 +1,5 @@
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; script-src-attr 'none'; style-src 'self'; style-src-attr 'unsafe-hashes' 'sha256-Ap89pw6Ru+bI6/fvSZrLRErTB0Vlrkjhr+GBUApiIWk=' 'sha256-SIXM20kwgT/LFCXoUieNCZ2zVEtDEJLBMFkzuqTDiEs=' 'sha256-Gg5P79IEFo2naj0u+NnV/XRNhPpmG+qszzSTXOPUj1A=' 'sha256-TnG5YZ63fruUpLo7bQSZmwEy7iaTHxpbBhwz5vu9uSg=' 'sha256-XzNZtBQ5RVU4zPLtfvXv2K6GCBeoCM2HUIIQSlEaULA=' 'sha256-nULJId45lkSYkp9G7rs5Ucrzkl/RvOYcmolE2LAWGy0=' 'sha256-zxhlsg7XWHprQXI4sW8mCF1bkpJgB7ybVJwWOPVLGRE=' 'sha256-K0Xrmwt6pAGUrGVS8MwRZJExIhykcVQ8SI1byCheVwc=' 'sha256-4YmfPYnVNL8IzZiGb/0iwhc6yUgCF19Y33KOfCPVmCw=' 'sha256-iNZSE5e3vOhMyMQkK5rpl8610z7E4ZtyqgP2Svv49PU=' 'sha256-MhqAPfMbUlw1OEgW5/QfCxo+BaJmIHx/8235W2X3bVI=' 'sha256-foNl8DYrtM07r6llOCuEQkuDC3P+Z9AhhM6r+IJDuvE=' 'sha256-TZ/59DFnpMwCdrSuMVhcjfKS2+Uktb1ikcGdbop+LrY=' 'sha256-bouNTXMWL7OeoflvcP2VmNuyQ00CJIpU091rlJcNh0o=' 'sha256-SWPOf/xgJrjLW+pINXE1lz0iy53MqwWqXeVJ6eC/feI=' 'sha256-1N/gWxc/W4gFlj8zf/p3KcADNes7ROePFaTOWiqwF4E=' 'sha256-biLFinpqYMtWHmXfkA1BPeCY0/fNt46SAZ+BBk5YUog=' 'sha256-/dAGmjflKS5r+hQF1hmagGz5QABPHf+YFadZo1ne2rc=' 'sha256-Xrwk3bvnw8h9hvxxkkKe/tGHcDa4FiyIkZgQh7zZ5OM=' 'sha256-yg9xEhg50yeViXuIpHVfY4ORsctQahuBNAF5elcPo2o=' 'sha256-wJR14aiwE2jYijrg7Fjqg/N0F1aAZ5CIcUtwN05LcGg=' 'sha256-aYnI2FBeHDuscjjfM9Mw136ryNwuYO8SA40Zevg1KoY=' 'sha256-y6K4epLEZv6QKwcoJSp/0xGIN8fOl7DSHSPNd5QT6Xo=' 'sha256-tdUysXRHnX2Z4LXERihNGxflbZuvaer3/ZEbjN2vQYE=' 'sha256-scdHk/Xhx12Q7ITYKMEP661eR9qxmkAEEIEGSoSvhnk=' 'sha256-SQjLChNmhyZMwxbpXjOF6LYFFUEHnV6PvzvTEmWb7eA=' 'sha256-50MALp7OIkg8n1p4VILXcnKO8ypKpLNL/QYLOgWPOCk=' 'sha256-clWb9y4Y/RHEr3K6egWYXbH1r5Hi2fgpzDHlBdskEeo=' 'sha256-chNIGV8ioVlRP9Ypn/XhEA2EhWFAeWCuUGJL5S+vzz0=' 'sha256-W998Pje85WadRdREdYtdQtKylzNM5QOtfq5HJWoaeTc=' 'sha256-s6IpehL1Dcfvbn/QY1MA4otfjj+O7fzMbbfi7Qc6pmA=' 'sha256-AC3CbEeLPJf2W6daVbkfUpWh5e6gLviBovIHzqhb7I4=' 'sha256-+j7xWNheSULp9DWeiEHZrhTxGekhyQ9qIjUgAiPhJ50=' 'sha256-moRhvd2U0JZLbFHJNyPJuevxlkfGXw5XWMByVNv9Vig=' 'sha256-203TZuzrYa786NHRsoV1tXZ8L/9lEl+/UL46E0VAiGI=' 'sha256-032ZxPG0oQ3+/U6NXKrgm5LnBreb9401xJrjrsb+p+U=' 'sha256-JamKOmpIVQSPv+gDrYtzbxnVclnrvhrOMLmA+WaGPIE=' 'sha256-mnRyqew64Lcv5/Qgb/4HP8LfJi5MjjQCGLPt+/cJZis=' 'sha256-Fx6gwPzP46ZjBgLjBZ6QLksJxr+0rXElPelXtSQMnxE=' 'sha256-fug/5+Ut6nV0WD+m4YV23l7GiD/oMKUnUvZdMiDCZmw=' 'sha256-AkGc/9SiOd74zk72UnCdLs+k10sM4iy2uKmgoXkaHe0=' 'sha256-Ar6JjQB5XpqI8eiPMCdTp/UuHaqgHiv6I+MV5V0AqD0=' 'sha256-q3nqK4VzeI/SowYCYQ/tCfo056B/JMutuSpzbJbHczk=' 'sha256-FHr5kWe1pqEckj58767LzvY6a0Jefvryb6NDWnX+9iA=' 'sha256-3bpbcQ42x+1MqmADGJ3zikAZX1tMXcOSJO5HaQgBLd0=' 'sha256-JZXmJpz7JH1a0DyiOr/syUiirSXF8caWHSEs55ZeuvM=' 'sha256-5z4wfnbsEag4kdsuEHpgdTrXYzlAY7n+RR18FQrPaEk=' 'sha256-97KgMmG+SNbCjjLJMC4FFguhozddOvNKLVC6hCmCMsg=' 'sha256-K1k356n2KePxr1iToCyZJNdGCm9eq1GSLPiovYYcGJw=' 'sha256-Wbi+cQ7sBtwXXZqWfFgLJRH4d6W8PogiptWNj9KIw8c=' 'sha256-ZGygktZXqOYjv8SF3rIcpWd19YFA1qMbC3LhD9PPSIk=' 'sha256-C1diVwUHC99dr4LcsUyLWfgQa8cu0K/7SBK+bd24TWg=' 'sha256-gKWvnkygeYkz1lyPSLpgVWSsmFaaDT2lY+hqijL4Gd8=' 'sha256-XhqU3am1TiOnFUKLIcPS5JCePjRUcUoGqQvjnfL92ik=' 'sha256-COR0sjBXRz2BkYmsZyQELjCUGLEbVii9YMmpBGwfFhM=' 'sha256-N90MKmRow2DpYEVeqcc3uc8pOUsS4Rg4sNmkau1k0xQ=' 'sha256-qcFcRsF+ljnThVJj80IfggPdbDjDPk/ZEeeiFCw/f4s=' 'sha256-niVdTxYNBhpZEFDz7d87VgN56BaVHxkE1mKzrSiTEa8=' 'sha256-N6tSydZ64AHCaOWfwKbUhxXx2fRFDxHOaL3e3CO7GPI=' 'sha256-V3Yf08rYNHRd1lYyQK1mcEb3duonX88OA6nfz8qDvuY=' 'sha256-7VXlcg/uSZugHSa6UtIG2/44ju460LiO4M0CyQfraX8='; upgrade-insecure-requests"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set Permissions-Policy "camera=(), geolocation=(), microphone=()"

222
frontend/student/app.js Normal file
View File

@@ -0,0 +1,222 @@
import {
clearAuthState,
fetchWithAuth,
logoutSession,
restoreSession
} from '../auth-session.js';
(async () => {
const session = await restoreSession();
if (!session || session.role !== 'STUDENT') {
window.location.replace('/');
return;
}
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());
document.getElementById('logout-link').addEventListener('click', async (event) => {
event.preventDefault();
await logoutSession();
window.location.replace('/');
});
document.getElementById('theme-toggle-local').addEventListener('click', () => {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
});
document.documentElement.dataset.theme = localStorage.getItem('theme') || 'light';
document.getElementById('prev-week').addEventListener('click', () => moveWeek(-1));
document.getElementById('today-week').addEventListener('click', () => {
weekStart = startOfWeek(new Date());
loadSchedule();
});
document.getElementById('next-week').addEventListener('click', () => moveWeek(1));
datePicker.addEventListener('change', () => {
if (datePicker.value) {
weekStart = startOfWeek(new Date(datePicker.value));
loadSchedule();
}
});
groupSelect.addEventListener('change', () => {
localStorage.setItem('studentGroupId', groupSelect.value);
loadSchedule();
});
init();
async function init() {
await loadGroups();
await loadSchedule();
}
async function loadGroups() {
try {
const groups = await fetchJson('/api/groups');
if (!groups.length) {
groupSelect.innerHTML = '<option value="">Группы не найдены</option>';
return;
}
const savedGroupId = localStorage.getItem('studentGroupId');
groupSelect.innerHTML = groups.map(group => (
`<option value="${escapeHtml(group.id)}">${escapeHtml(group.name)}</option>`
)).join('');
groupSelect.value = savedGroupId && groups.some(group => String(group.id) === savedGroupId)
? savedGroupId
: String(groups[0].id);
} catch (error) {
groupSelect.innerHTML = '<option value="">Ошибка загрузки групп</option>';
showStatus(error.message, true);
}
}
async function loadSchedule() {
const groupId = groupSelect.value;
const startDate = toIsoDate(weekStart);
const endDate = toIsoDate(addDays(weekStart, 6));
datePicker.value = startDate;
weekLabel.textContent = `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`;
renderEmptyWeek('Загрузка...');
if (!groupId) {
showStatus('Выберите группу', false);
renderEmptyWeek('Нет выбранной группы');
return;
}
try {
const lessons = await fetchJson(`/api/schedule?groupId=${encodeURIComponent(groupId)}&startDate=${startDate}&endDate=${endDate}`);
renderWeek(lessons);
showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false);
} catch (error) {
renderEmptyWeek('Ошибка загрузки');
showStatus(error.message, true);
}
}
function renderWeek(lessons) {
const lessonsByDate = new Map();
lessons.forEach(lesson => {
if (!lessonsByDate.has(lesson.date)) lessonsByDate.set(lesson.date, []);
lessonsByDate.get(lesson.date).push(lesson);
});
grid.innerHTML = daysOfWeek().map(date => {
const key = toIsoDate(date);
const dayLessons = lessonsByDate.get(key) || [];
return renderDay(date, dayLessons);
}).join('');
}
function renderEmptyWeek(message) {
grid.innerHTML = daysOfWeek().map(date => renderDay(date, [], message)).join('');
}
function renderDay(date, lessons, emptyMessage = 'Занятий нет') {
return `
<article class="day">
<div class="day-head">
<div class="day-name">${escapeHtml(dayName(date))}</div>
<div class="day-date">${escapeHtml(formatDate(date))}</div>
</div>
<div class="lesson-list">
${lessons.length ? lessons.map(renderLesson).join('') : `<div class="empty">${escapeHtml(emptyMessage)}</div>`}
</div>
</article>
`;
}
function renderLesson(lesson) {
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
? lesson.subgroupNames.join(', ')
: lesson.subgroupName;
return `
<div class="lesson">
<div class="lesson-time">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</div>
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
<div class="lesson-meta">
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
${subgroups ? `<span class="chip">${escapeHtml(subgroups)}</span>` : ''}
<span class="chip">${escapeHtml(lesson.lessonFormat)}</span>
<span class="chip">${escapeHtml(lesson.teacherName)}</span>
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
</div>
</div>
`;
}
function moveWeek(delta) {
weekStart = addDays(weekStart, delta * 7);
loadSchedule();
}
function daysOfWeek() {
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
}
async function fetchJson(url) {
const response = await fetchWithAuth(url);
const data = await response.json().catch(() => null);
if (response.status === 401) {
clearAuthState();
window.location.replace('/');
throw new Error('Требуется повторный вход в систему');
}
if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
}
return data;
}
function showStatus(message, isError) {
statusLine.textContent = message;
statusLine.classList.toggle('error', isError);
}
function startOfWeek(date) {
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const day = copy.getDay() || 7;
copy.setDate(copy.getDate() - day + 1);
return copy;
}
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
function toIsoDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatDate(date) {
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function dayName(date) {
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
return value.charAt(0).toUpperCase() + value.slice(1);
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
})();

View File

@@ -4,304 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Расписание студента</title>
<style>
* {
box-sizing: border-box;
}
:root {
--bg: #f4f7fb;
--panel: #ffffff;
--panel-border: #dbe3ef;
--text: #162033;
--muted: #66758f;
--accent: #4F46E5;
--accent-strong: #4338CA;
--blue: #2563eb;
--warning: #b45309;
--shadow: 0 12px 34px rgba(22, 32, 51, 0.08);
}
[data-theme="dark"] {
--bg: #0e131d;
--panel: #151d2b;
--panel-border: #263449;
--text: #edf3fb;
--muted: #9cadc5;
--accent: #8B5CF6;
--accent-strong: #7C3AED;
--blue: #60a5fa;
--warning: #f59e0b;
--shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
}
.shell {
max-width: 1240px;
margin: 0 auto;
padding: 24px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.title h1 {
margin: 0 0 4px;
font-size: 28px;
font-weight: 700;
letter-spacing: 0;
}
.title span {
color: var(--muted);
font-size: 14px;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
}
.btn,
.select,
.date-input {
min-height: 40px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
color: var(--text);
font: inherit;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
padding: 0 14px;
cursor: pointer;
text-decoration: none;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.btn:hover {
border-color: var(--accent);
color: var(--accent-strong);
}
.btn-primary {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: var(--accent-strong);
color: #fff;
}
.btn-secondary {
background: var(--panel);
border-color: var(--panel-border);
color: var(--text);
}
.btn-secondary:hover {
background: color-mix(in srgb, var(--accent) 7%, var(--panel));
}
.btn-danger-subtle {
background: #FEF2F2;
border-color: #FECACA;
color: #B91C1C;
}
[data-theme="dark"] .btn-danger-subtle {
background: rgba(239, 68, 68, 0.13);
border-color: rgba(239, 68, 68, 0.25);
color: #FECACA;
}
.btn-icon-md {
width: 40px;
height: 40px;
padding: 0;
border-radius: 8px;
}
.btn-icon-md svg {
width: 20px;
height: 20px;
}
.btn-md {
min-height: 40px;
padding: 0 16px;
font-size: 14px;
font-weight: 600;
}
.toolbar {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto auto;
gap: 12px;
margin-bottom: 16px;
padding: 14px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}
.select,
.date-input {
width: 100%;
padding: 0 12px;
}
.week-nav {
display: flex;
gap: 8px;
}
.status {
min-height: 22px;
margin-bottom: 12px;
color: var(--muted);
font-size: 14px;
}
.schedule {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 10px;
}
.day {
min-height: 260px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
overflow: hidden;
}
.day-head {
padding: 12px;
border-bottom: 1px solid var(--panel-border);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.day-name {
font-weight: 700;
}
.day-date {
margin-top: 2px;
color: var(--muted);
font-size: 13px;
}
.lesson-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
}
.lesson {
border-left: 4px solid var(--blue);
border-radius: 7px;
background: color-mix(in srgb, var(--blue) 9%, transparent);
padding: 10px;
}
.lesson-time {
color: var(--muted);
font-size: 13px;
font-weight: 600;
}
.lesson-title {
margin-top: 5px;
font-weight: 700;
line-height: 1.25;
}
.lesson-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.chip {
border: 1px solid var(--panel-border);
border-radius: 999px;
padding: 3px 8px;
color: var(--muted);
font-size: 12px;
background: var(--panel);
}
.empty {
padding: 16px 10px;
color: var(--muted);
font-size: 13px;
}
.error {
color: var(--warning);
}
@media (max-width: 980px) {
.schedule {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.shell {
padding: 16px;
}
.topbar,
.actions,
.toolbar {
grid-template-columns: 1fr;
align-items: stretch;
}
.topbar,
.actions {
display: grid;
}
.week-nav {
justify-content: stretch;
}
.week-nav .btn {
flex: 1;
}
.schedule {
grid-template-columns: 1fr;
}
}
</style>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="shell">
@@ -344,275 +47,6 @@
<section class="schedule" id="schedule-grid" aria-live="polite"></section>
</main>
<script>
(() => {
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', async (event) => {
event.preventDefault();
await logout();
window.location.href = '/';
});
document.getElementById('theme-toggle-local').addEventListener('click', () => {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
});
document.documentElement.dataset.theme = localStorage.getItem('theme') || 'light';
document.getElementById('prev-week').addEventListener('click', () => moveWeek(-1));
document.getElementById('today-week').addEventListener('click', () => {
weekStart = startOfWeek(new Date());
loadSchedule();
});
document.getElementById('next-week').addEventListener('click', () => moveWeek(1));
datePicker.addEventListener('change', () => {
if (datePicker.value) {
weekStart = startOfWeek(new Date(datePicker.value));
loadSchedule();
}
});
groupSelect.addEventListener('change', () => {
localStorage.setItem('studentGroupId', groupSelect.value);
loadSchedule();
});
init();
async function init() {
await loadGroups();
await loadSchedule();
}
async function loadGroups() {
try {
const groups = await fetchJson('/api/groups');
if (!groups.length) {
groupSelect.innerHTML = '<option value="">Группы не найдены</option>';
return;
}
const savedGroupId = localStorage.getItem('studentGroupId');
groupSelect.innerHTML = groups.map(group => (
`<option value="${escapeHtml(group.id)}">${escapeHtml(group.name)}</option>`
)).join('');
groupSelect.value = savedGroupId && groups.some(group => String(group.id) === savedGroupId)
? savedGroupId
: String(groups[0].id);
} catch (error) {
groupSelect.innerHTML = '<option value="">Ошибка загрузки групп</option>';
showStatus(error.message, true);
}
}
async function loadSchedule() {
const groupId = groupSelect.value;
const startDate = toIsoDate(weekStart);
const endDate = toIsoDate(addDays(weekStart, 6));
datePicker.value = startDate;
weekLabel.textContent = `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`;
renderEmptyWeek('Загрузка...');
if (!groupId) {
showStatus('Выберите группу', false);
renderEmptyWeek('Нет выбранной группы');
return;
}
try {
const lessons = await fetchJson(`/api/schedule?groupId=${encodeURIComponent(groupId)}&startDate=${startDate}&endDate=${endDate}`);
renderWeek(lessons);
showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false);
} catch (error) {
renderEmptyWeek('Ошибка загрузки');
showStatus(error.message, true);
}
}
function renderWeek(lessons) {
const lessonsByDate = new Map();
lessons.forEach(lesson => {
if (!lessonsByDate.has(lesson.date)) lessonsByDate.set(lesson.date, []);
lessonsByDate.get(lesson.date).push(lesson);
});
grid.innerHTML = daysOfWeek().map(date => {
const key = toIsoDate(date);
const dayLessons = lessonsByDate.get(key) || [];
return renderDay(date, dayLessons);
}).join('');
}
function renderEmptyWeek(message) {
grid.innerHTML = daysOfWeek().map(date => renderDay(date, [], message)).join('');
}
function renderDay(date, lessons, emptyMessage = 'Занятий нет') {
return `
<article class="day">
<div class="day-head">
<div class="day-name">${escapeHtml(dayName(date))}</div>
<div class="day-date">${escapeHtml(formatDate(date))}</div>
</div>
<div class="lesson-list">
${lessons.length ? lessons.map(renderLesson).join('') : `<div class="empty">${escapeHtml(emptyMessage)}</div>`}
</div>
</article>
`;
}
function renderLesson(lesson) {
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
? lesson.subgroupNames.join(', ')
: lesson.subgroupName;
return `
<div class="lesson">
<div class="lesson-time">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</div>
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
<div class="lesson-meta">
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
${subgroups ? `<span class="chip">${escapeHtml(subgroups)}</span>` : ''}
<span class="chip">${escapeHtml(lesson.lessonFormat)}</span>
<span class="chip">${escapeHtml(lesson.teacherName)}</span>
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
</div>
</div>
`;
}
function moveWeek(delta) {
weekStart = addDays(weekStart, delta * 7);
loadSchedule();
}
function daysOfWeek() {
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
}
async function fetchJson(url, retryOnUnauthorized = true) {
const token = localStorage.getItem('token');
const response = await fetch(url, {
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);
}
function startOfWeek(date) {
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const day = copy.getDay() || 7;
copy.setDate(copy.getDate() - day + 1);
return copy;
}
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
function toIsoDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatDate(date) {
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function dayName(date) {
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
return value.charAt(0).toUpperCase() + value.slice(1);
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
})();
</script>
<script type="module" src="app.js"></script>
</body>
</html>

297
frontend/student/style.css Normal file
View File

@@ -0,0 +1,297 @@
* {
box-sizing: border-box;
}
:root {
--bg: #f4f7fb;
--panel: #ffffff;
--panel-border: #dbe3ef;
--text: #162033;
--muted: #66758f;
--accent: #4F46E5;
--accent-strong: #4338CA;
--blue: #2563eb;
--warning: #b45309;
--shadow: 0 12px 34px rgba(22, 32, 51, 0.08);
}
[data-theme="dark"] {
--bg: #0e131d;
--panel: #151d2b;
--panel-border: #263449;
--text: #edf3fb;
--muted: #9cadc5;
--accent: #8B5CF6;
--accent-strong: #7C3AED;
--blue: #60a5fa;
--warning: #f59e0b;
--shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
}
.shell {
max-width: 1240px;
margin: 0 auto;
padding: 24px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.title h1 {
margin: 0 0 4px;
font-size: 28px;
font-weight: 700;
letter-spacing: 0;
}
.title span {
color: var(--muted);
font-size: 14px;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
}
.btn,
.select,
.date-input {
min-height: 40px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
color: var(--text);
font: inherit;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
padding: 0 14px;
cursor: pointer;
text-decoration: none;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.btn:hover {
border-color: var(--accent);
color: var(--accent-strong);
}
.btn-primary {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: var(--accent-strong);
color: #fff;
}
.btn-secondary {
background: var(--panel);
border-color: var(--panel-border);
color: var(--text);
}
.btn-secondary:hover {
background: color-mix(in srgb, var(--accent) 7%, var(--panel));
}
.btn-danger-subtle {
background: #FEF2F2;
border-color: #FECACA;
color: #B91C1C;
}
[data-theme="dark"] .btn-danger-subtle {
background: rgba(239, 68, 68, 0.13);
border-color: rgba(239, 68, 68, 0.25);
color: #FECACA;
}
.btn-icon-md {
width: 40px;
height: 40px;
padding: 0;
border-radius: 8px;
}
.btn-icon-md svg {
width: 20px;
height: 20px;
}
.btn-md {
min-height: 40px;
padding: 0 16px;
font-size: 14px;
font-weight: 600;
}
.toolbar {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto auto;
gap: 12px;
margin-bottom: 16px;
padding: 14px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}
.select,
.date-input {
width: 100%;
padding: 0 12px;
}
.week-nav {
display: flex;
gap: 8px;
}
.status {
min-height: 22px;
margin-bottom: 12px;
color: var(--muted);
font-size: 14px;
}
.schedule {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 10px;
}
.day {
min-height: 260px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
overflow: hidden;
}
.day-head {
padding: 12px;
border-bottom: 1px solid var(--panel-border);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.day-name {
font-weight: 700;
}
.day-date {
margin-top: 2px;
color: var(--muted);
font-size: 13px;
}
.lesson-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
}
.lesson {
border-left: 4px solid var(--blue);
border-radius: 7px;
background: color-mix(in srgb, var(--blue) 9%, transparent);
padding: 10px;
}
.lesson-time {
color: var(--muted);
font-size: 13px;
font-weight: 600;
}
.lesson-title {
margin-top: 5px;
font-weight: 700;
line-height: 1.25;
}
.lesson-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.chip {
border: 1px solid var(--panel-border);
border-radius: 999px;
padding: 3px 8px;
color: var(--muted);
font-size: 12px;
background: var(--panel);
}
.empty {
padding: 16px 10px;
color: var(--muted);
font-size: 13px;
}
.error {
color: var(--warning);
}
@media (max-width: 980px) {
.schedule {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.shell {
padding: 16px;
}
.topbar,
.actions,
.toolbar {
grid-template-columns: 1fr;
align-items: stretch;
}
.topbar,
.actions {
display: grid;
}
.week-nav {
justify-content: stretch;
}
.week-nav .btn {
flex: 1;
}
.schedule {
grid-template-columns: 1fr;
}
}

View File

@@ -447,6 +447,11 @@ body {
animation: ripple 0.6s linear;
background-color: rgba(255, 255, 255, 0.3);
pointer-events: none;
left: 50%;
top: 50%;
width: 24px;
height: 24px;
margin: -12px 0 0 -12px;
}
@keyframes ripple {
@@ -498,6 +503,15 @@ body {
transition: transform 0.4s ease;
}
.theme-toggle-icon-enter {
animation: theme-toggle-icon-enter 0.4s ease;
}
@keyframes theme-toggle-icon-enter {
from { opacity: 0; transform: rotate(-90deg) scale(0.5); }
to { opacity: 1; transform: rotate(0deg) scale(1); }
}
.theme-toggle:hover {
background: var(--button-secondary-bg-hover);
transform: translateY(-1px);

246
frontend/teacher/app.js Normal file
View File

@@ -0,0 +1,246 @@
import {
clearAuthState,
fetchWithAuth,
logoutSession,
restoreSession
} from '../auth-session.js';
(async () => {
const session = await restoreSession();
if (!session || session.role !== 'TEACHER') {
window.location.replace('/');
return;
}
const teacherId = session.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());
document.getElementById('logout-link').addEventListener('click', async (event) => {
event.preventDefault();
await logoutSession();
window.location.replace('/');
});
document.getElementById('theme-toggle-local').addEventListener('click', () => {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
});
document.documentElement.dataset.theme = localStorage.getItem('theme') || 'light';
document.getElementById('prev-week').addEventListener('click', () => moveWeek(-1));
document.getElementById('today-week').addEventListener('click', () => {
weekStart = startOfWeek(new Date());
loadSchedule();
});
document.getElementById('next-week').addEventListener('click', () => moveWeek(1));
datePicker.addEventListener('change', () => {
if (datePicker.value) {
weekStart = startOfWeek(new Date(datePicker.value));
loadSchedule();
}
});
loadSchedule();
async function loadSchedule() {
const startDate = toIsoDate(weekStart);
const endDate = toIsoDate(addDays(weekStart, 6));
datePicker.value = startDate;
weekLabel.textContent = `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`;
if (!teacherId) {
renderEmptyWeek('Не удалось определить преподавателя');
showStatus('Выполните вход заново, чтобы получить идентификатор пользователя', true);
return;
}
renderEmptyWeek('Загрузка...');
try {
const lessons = await fetchJson(`/api/schedule?teacherId=${encodeURIComponent(teacherId)}&startDate=${startDate}&endDate=${endDate}`);
renderWeek(lessons);
showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false);
} catch (error) {
renderEmptyWeek('Ошибка загрузки');
showStatus(error.message, true);
}
}
function renderWeek(lessons) {
if (window.matchMedia('(max-width: 980px)').matches) {
renderMobileWeek(lessons);
return;
}
const slots = collectSlots(lessons);
const lessonsByKey = new Map();
lessons.forEach(lesson => {
lessonsByKey.set(`${lesson.date}:${lesson.timeSlotOrder}`, lesson);
});
grid.innerHTML = [
'<div class="corner"></div>',
...daysOfWeek().map(date => renderHead(date)),
...slots.flatMap(slot => [
`<div class="time-cell">${escapeHtml(slot.label)}</div>`,
...daysOfWeek().map(date => renderCell(lessonsByKey.get(`${toIsoDate(date)}:${slot.order}`)))
])
].join('');
}
function renderMobileWeek(lessons) {
const lessonsByDate = new Map();
lessons.forEach(lesson => {
if (!lessonsByDate.has(lesson.date)) lessonsByDate.set(lesson.date, []);
lessonsByDate.get(lesson.date).push(lesson);
});
grid.innerHTML = daysOfWeek().map(date => {
const key = toIsoDate(date);
const dayLessons = lessonsByDate.get(key) || [];
return `
<article class="mobile-day">
${renderHead(date)}
<div class="mobile-list">
${dayLessons.length ? dayLessons.map(renderLesson).join('') : '<div class="empty">Занятий нет</div>'}
</div>
</article>
`;
}).join('');
}
function renderEmptyWeek(message) {
if (window.matchMedia('(max-width: 980px)').matches) {
grid.innerHTML = daysOfWeek().map(date => `
<article class="mobile-day">
${renderHead(date)}
<div class="mobile-list"><div class="empty">${escapeHtml(message)}</div></div>
</article>
`).join('');
return;
}
grid.innerHTML = [
'<div class="corner"></div>',
...daysOfWeek().map(date => renderHead(date)),
`<div class="time-cell">Неделя</div>`,
...daysOfWeek().map(() => `<div class="day-cell"><div class="empty">${escapeHtml(message)}</div></div>`)
].join('');
}
function renderHead(date) {
return `
<div class="day-head">
<strong>${escapeHtml(dayName(date))}</strong>
<span>${escapeHtml(formatDate(date))}</span>
</div>
`;
}
function renderCell(lesson) {
return `<div class="day-cell">${lesson ? renderLesson(lesson) : '<div class="empty">Нет занятия</div>'}</div>`;
}
function renderLesson(lesson) {
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
? lesson.subgroupNames.join(', ')
: lesson.subgroupName;
return `
<div class="lesson">
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
<div class="lesson-meta">
<span class="chip">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</span>
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
${subgroups ? `<span class="chip">${escapeHtml(subgroups)}</span>` : ''}
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
</div>
</div>
`;
}
function collectSlots(lessons) {
const slots = new Map();
lessons.forEach(lesson => {
slots.set(lesson.timeSlotOrder, {
order: lesson.timeSlotOrder,
label: `${trimTime(lesson.startTime)} - ${trimTime(lesson.endTime)}`
});
});
if (!slots.size) {
slots.set(1, { order: 1, label: 'Расписание' });
}
return [...slots.values()].sort((a, b) => a.order - b.order);
}
function moveWeek(delta) {
weekStart = addDays(weekStart, delta * 7);
loadSchedule();
}
function daysOfWeek() {
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
}
async function fetchJson(url) {
const response = await fetchWithAuth(url);
const data = await response.json().catch(() => null);
if (response.status === 401) {
clearAuthState();
window.location.replace('/');
throw new Error('Требуется повторный вход в систему');
}
if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
}
return data;
}
function showStatus(message, isError) {
statusLine.textContent = message;
statusLine.classList.toggle('error', isError);
}
function startOfWeek(date) {
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const day = copy.getDay() || 7;
copy.setDate(copy.getDate() - day + 1);
return copy;
}
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
function toIsoDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatDate(date) {
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function dayName(date) {
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
return value.charAt(0).toUpperCase() + value.slice(1);
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
})();

View File

@@ -4,310 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Расписание преподавателя</title>
<style>
* {
box-sizing: border-box;
}
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--panel-border: #d8dee8;
--text: #172033;
--muted: #67758d;
--accent: #4F46E5;
--accent-strong: #4338CA;
--green: #047857;
--orange: #b45309;
--shadow: 0 12px 30px rgba(23, 32, 51, 0.08);
}
[data-theme="dark"] {
--bg: #10151f;
--panel: #171f2e;
--panel-border: #2a374b;
--text: #eef4fb;
--muted: #a2b0c5;
--accent: #8B5CF6;
--accent-strong: #7C3AED;
--green: #34d399;
--orange: #f59e0b;
--shadow: 0 18px 42px rgba(0, 0, 0, 0.28);
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
}
.shell {
max-width: 1240px;
margin: 0 auto;
padding: 24px;
}
.topbar,
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.topbar {
margin-bottom: 18px;
}
.title h1 {
margin: 0 0 4px;
font-size: 28px;
font-weight: 700;
letter-spacing: 0;
}
.title span {
color: var(--muted);
font-size: 14px;
}
.toolbar {
margin-bottom: 16px;
padding: 14px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}
.actions,
.week-nav {
display: flex;
align-items: center;
gap: 8px;
}
.btn,
.date-input {
min-height: 40px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
color: var(--text);
font: inherit;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
padding: 0 14px;
cursor: pointer;
text-decoration: none;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.btn:hover {
border-color: var(--accent);
color: var(--accent-strong);
}
.btn-primary {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: var(--accent-strong);
color: #fff;
}
.btn-secondary {
background: var(--panel);
border-color: var(--panel-border);
color: var(--text);
}
.btn-secondary:hover {
background: color-mix(in srgb, var(--accent) 7%, var(--panel));
}
.btn-danger-subtle {
background: #FEF2F2;
border-color: #FECACA;
color: #B91C1C;
}
[data-theme="dark"] .btn-danger-subtle {
background: rgba(239, 68, 68, 0.13);
border-color: rgba(239, 68, 68, 0.25);
color: #FECACA;
}
.btn-icon-md {
width: 40px;
height: 40px;
padding: 0;
border-radius: 8px;
}
.btn-icon-md svg {
width: 20px;
height: 20px;
}
.btn-md {
min-height: 40px;
padding: 0 16px;
font-size: 14px;
font-weight: 600;
}
.date-input {
padding: 0 12px;
}
.status {
min-height: 22px;
margin-bottom: 12px;
color: var(--muted);
font-size: 14px;
}
.schedule {
display: grid;
grid-template-columns: 96px repeat(7, minmax(0, 1fr));
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
overflow: hidden;
}
.corner,
.day-head,
.time-cell,
.day-cell {
border-right: 1px solid var(--panel-border);
border-bottom: 1px solid var(--panel-border);
}
.corner,
.day-head {
min-height: 58px;
padding: 10px;
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.day-head strong {
display: block;
}
.day-head span {
color: var(--muted);
font-size: 13px;
}
.time-cell {
padding: 10px;
color: var(--muted);
font-size: 13px;
background: color-mix(in srgb, var(--panel-border) 26%, transparent);
}
.day-cell {
min-height: 118px;
padding: 8px;
}
.lesson {
height: 100%;
min-height: 96px;
border-left: 4px solid var(--green);
border-radius: 7px;
background: color-mix(in srgb, var(--green) 10%, transparent);
padding: 9px;
}
.lesson-title {
font-weight: 700;
line-height: 1.25;
}
.lesson-meta {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
}
.chip {
border: 1px solid var(--panel-border);
border-radius: 999px;
padding: 3px 8px;
color: var(--muted);
font-size: 12px;
background: var(--panel);
}
.empty {
color: var(--muted);
font-size: 13px;
}
.error {
color: var(--orange);
}
@media (max-width: 980px) {
.schedule {
display: block;
border: 0;
background: transparent;
box-shadow: none;
}
.mobile-day {
margin-bottom: 10px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
overflow: hidden;
}
.mobile-day .day-head {
border-right: 0;
}
.mobile-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
}
}
@media (max-width: 640px) {
.shell {
padding: 16px;
}
.topbar,
.toolbar,
.actions {
display: grid;
align-items: stretch;
}
.week-nav .btn {
flex: 1;
}
}
</style>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="shell">
@@ -347,299 +44,6 @@
<section class="schedule" id="schedule-grid" aria-live="polite"></section>
</main>
<script>
(() => {
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', async (event) => {
event.preventDefault();
await logout();
window.location.href = '/';
});
document.getElementById('theme-toggle-local').addEventListener('click', () => {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
});
document.documentElement.dataset.theme = localStorage.getItem('theme') || 'light';
document.getElementById('prev-week').addEventListener('click', () => moveWeek(-1));
document.getElementById('today-week').addEventListener('click', () => {
weekStart = startOfWeek(new Date());
loadSchedule();
});
document.getElementById('next-week').addEventListener('click', () => moveWeek(1));
datePicker.addEventListener('change', () => {
if (datePicker.value) {
weekStart = startOfWeek(new Date(datePicker.value));
loadSchedule();
}
});
loadSchedule();
async function loadSchedule() {
const startDate = toIsoDate(weekStart);
const endDate = toIsoDate(addDays(weekStart, 6));
datePicker.value = startDate;
weekLabel.textContent = `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`;
if (!teacherId) {
renderEmptyWeek('Не удалось определить преподавателя');
showStatus('Выполните вход заново, чтобы получить идентификатор пользователя', true);
return;
}
renderEmptyWeek('Загрузка...');
try {
const lessons = await fetchJson(`/api/schedule?teacherId=${encodeURIComponent(teacherId)}&startDate=${startDate}&endDate=${endDate}`);
renderWeek(lessons);
showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false);
} catch (error) {
renderEmptyWeek('Ошибка загрузки');
showStatus(error.message, true);
}
}
function renderWeek(lessons) {
if (window.matchMedia('(max-width: 980px)').matches) {
renderMobileWeek(lessons);
return;
}
const slots = collectSlots(lessons);
const lessonsByKey = new Map();
lessons.forEach(lesson => {
lessonsByKey.set(`${lesson.date}:${lesson.timeSlotOrder}`, lesson);
});
grid.innerHTML = [
'<div class="corner"></div>',
...daysOfWeek().map(date => renderHead(date)),
...slots.flatMap(slot => [
`<div class="time-cell">${escapeHtml(slot.label)}</div>`,
...daysOfWeek().map(date => renderCell(lessonsByKey.get(`${toIsoDate(date)}:${slot.order}`)))
])
].join('');
}
function renderMobileWeek(lessons) {
const lessonsByDate = new Map();
lessons.forEach(lesson => {
if (!lessonsByDate.has(lesson.date)) lessonsByDate.set(lesson.date, []);
lessonsByDate.get(lesson.date).push(lesson);
});
grid.innerHTML = daysOfWeek().map(date => {
const key = toIsoDate(date);
const dayLessons = lessonsByDate.get(key) || [];
return `
<article class="mobile-day">
${renderHead(date)}
<div class="mobile-list">
${dayLessons.length ? dayLessons.map(renderLesson).join('') : '<div class="empty">Занятий нет</div>'}
</div>
</article>
`;
}).join('');
}
function renderEmptyWeek(message) {
if (window.matchMedia('(max-width: 980px)').matches) {
grid.innerHTML = daysOfWeek().map(date => `
<article class="mobile-day">
${renderHead(date)}
<div class="mobile-list"><div class="empty">${escapeHtml(message)}</div></div>
</article>
`).join('');
return;
}
grid.innerHTML = [
'<div class="corner"></div>',
...daysOfWeek().map(date => renderHead(date)),
`<div class="time-cell">Неделя</div>`,
...daysOfWeek().map(() => `<div class="day-cell"><div class="empty">${escapeHtml(message)}</div></div>`)
].join('');
}
function renderHead(date) {
return `
<div class="day-head">
<strong>${escapeHtml(dayName(date))}</strong>
<span>${escapeHtml(formatDate(date))}</span>
</div>
`;
}
function renderCell(lesson) {
return `<div class="day-cell">${lesson ? renderLesson(lesson) : '<div class="empty">Нет занятия</div>'}</div>`;
}
function renderLesson(lesson) {
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
? lesson.subgroupNames.join(', ')
: lesson.subgroupName;
return `
<div class="lesson">
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
<div class="lesson-meta">
<span class="chip">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</span>
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
${subgroups ? `<span class="chip">${escapeHtml(subgroups)}</span>` : ''}
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
</div>
</div>
`;
}
function collectSlots(lessons) {
const slots = new Map();
lessons.forEach(lesson => {
slots.set(lesson.timeSlotOrder, {
order: lesson.timeSlotOrder,
label: `${trimTime(lesson.startTime)} - ${trimTime(lesson.endTime)}`
});
});
if (!slots.size) {
slots.set(1, { order: 1, label: 'Расписание' });
}
return [...slots.values()].sort((a, b) => a.order - b.order);
}
function moveWeek(delta) {
weekStart = addDays(weekStart, delta * 7);
loadSchedule();
}
function daysOfWeek() {
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
}
async function fetchJson(url, retryOnUnauthorized = true) {
const token = localStorage.getItem('token');
const response = await fetch(url, {
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);
}
function startOfWeek(date) {
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const day = copy.getDay() || 7;
copy.setDate(copy.getDate() - day + 1);
return copy;
}
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
function toIsoDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatDate(date) {
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function dayName(date) {
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
return value.charAt(0).toUpperCase() + value.slice(1);
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
})();
</script>
<script type="module" src="app.js"></script>
</body>
</html>

303
frontend/teacher/style.css Normal file
View File

@@ -0,0 +1,303 @@
* {
box-sizing: border-box;
}
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--panel-border: #d8dee8;
--text: #172033;
--muted: #67758d;
--accent: #4F46E5;
--accent-strong: #4338CA;
--green: #047857;
--orange: #b45309;
--shadow: 0 12px 30px rgba(23, 32, 51, 0.08);
}
[data-theme="dark"] {
--bg: #10151f;
--panel: #171f2e;
--panel-border: #2a374b;
--text: #eef4fb;
--muted: #a2b0c5;
--accent: #8B5CF6;
--accent-strong: #7C3AED;
--green: #34d399;
--orange: #f59e0b;
--shadow: 0 18px 42px rgba(0, 0, 0, 0.28);
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
}
.shell {
max-width: 1240px;
margin: 0 auto;
padding: 24px;
}
.topbar,
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.topbar {
margin-bottom: 18px;
}
.title h1 {
margin: 0 0 4px;
font-size: 28px;
font-weight: 700;
letter-spacing: 0;
}
.title span {
color: var(--muted);
font-size: 14px;
}
.toolbar {
margin-bottom: 16px;
padding: 14px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}
.actions,
.week-nav {
display: flex;
align-items: center;
gap: 8px;
}
.btn,
.date-input {
min-height: 40px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
color: var(--text);
font: inherit;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
padding: 0 14px;
cursor: pointer;
text-decoration: none;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.btn:hover {
border-color: var(--accent);
color: var(--accent-strong);
}
.btn-primary {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: var(--accent-strong);
color: #fff;
}
.btn-secondary {
background: var(--panel);
border-color: var(--panel-border);
color: var(--text);
}
.btn-secondary:hover {
background: color-mix(in srgb, var(--accent) 7%, var(--panel));
}
.btn-danger-subtle {
background: #FEF2F2;
border-color: #FECACA;
color: #B91C1C;
}
[data-theme="dark"] .btn-danger-subtle {
background: rgba(239, 68, 68, 0.13);
border-color: rgba(239, 68, 68, 0.25);
color: #FECACA;
}
.btn-icon-md {
width: 40px;
height: 40px;
padding: 0;
border-radius: 8px;
}
.btn-icon-md svg {
width: 20px;
height: 20px;
}
.btn-md {
min-height: 40px;
padding: 0 16px;
font-size: 14px;
font-weight: 600;
}
.date-input {
padding: 0 12px;
}
.status {
min-height: 22px;
margin-bottom: 12px;
color: var(--muted);
font-size: 14px;
}
.schedule {
display: grid;
grid-template-columns: 96px repeat(7, minmax(0, 1fr));
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
overflow: hidden;
}
.corner,
.day-head,
.time-cell,
.day-cell {
border-right: 1px solid var(--panel-border);
border-bottom: 1px solid var(--panel-border);
}
.corner,
.day-head {
min-height: 58px;
padding: 10px;
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.day-head strong {
display: block;
}
.day-head span {
color: var(--muted);
font-size: 13px;
}
.time-cell {
padding: 10px;
color: var(--muted);
font-size: 13px;
background: color-mix(in srgb, var(--panel-border) 26%, transparent);
}
.day-cell {
min-height: 118px;
padding: 8px;
}
.lesson {
height: 100%;
min-height: 96px;
border-left: 4px solid var(--green);
border-radius: 7px;
background: color-mix(in srgb, var(--green) 10%, transparent);
padding: 9px;
}
.lesson-title {
font-weight: 700;
line-height: 1.25;
}
.lesson-meta {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
}
.chip {
border: 1px solid var(--panel-border);
border-radius: 999px;
padding: 3px 8px;
color: var(--muted);
font-size: 12px;
background: var(--panel);
}
.empty {
color: var(--muted);
font-size: 13px;
}
.error {
color: var(--orange);
}
@media (max-width: 980px) {
.schedule {
display: block;
border: 0;
background: transparent;
box-shadow: none;
}
.mobile-day {
margin-bottom: 10px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
overflow: hidden;
}
.mobile-day .day-head {
border-right: 0;
}
.mobile-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
}
}
@media (max-width: 640px) {
.shell {
padding: 16px;
}
.topbar,
.toolbar,
.actions {
display: grid;
align-items: stretch;
}
.week-nav .btn {
flex: 1;
}
}

13
frontend/telemetry.js Normal file
View File

@@ -0,0 +1,13 @@
let telemetryPromise = null;
export function initializeTelemetry() {
if (['localhost', '127.0.0.1', '::1'].includes(window.location.hostname)) {
return Promise.resolve();
}
if (!telemetryPromise) {
telemetryPromise = import('/vendor/otel.js').catch(error => {
console.warn('Не удалось инициализировать браузерную телеметрию');
});
}
return telemetryPromise;
}

View File

@@ -0,0 +1,40 @@
import { getWebAutoInstrumentations } from '@opentelemetry/auto-instrumentations-web';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
const exporter = new OTLPTraceExporter({
url: `${window.location.origin}/otel/v1/traces`
});
const provider = new WebTracerProvider({
resource: resourceFromAttributes({
[ATTR_SERVICE_NAME]: 'magistr-frontend'
}),
spanProcessors: [new BatchSpanProcessor(exporter)]
});
provider.register({
contextManager: new ZoneContextManager()
});
registerInstrumentations({
instrumentations: [
getWebAutoInstrumentations({
'@opentelemetry/instrumentation-fetch': {
propagateTraceHeaderCorsUrls: [window.location.origin],
clearTimingResources: true
},
'@opentelemetry/instrumentation-xml-http-request': {
propagateTraceHeaderCorsUrls: [window.location.origin],
clearTimingResources: true
}
})
]
});
console.info('Браузерная телеметрия OpenTelemetry инициализирована');

View File

@@ -0,0 +1,174 @@
import assert from 'node:assert/strict';
import test from 'node:test';
let importSequence = 0;
class StorageMock {
constructor(initial = {}) {
this.values = new Map(Object.entries(initial));
this.removed = [];
}
getItem(key) {
return this.values.has(key) ? this.values.get(key) : null;
}
setItem(key, value) {
this.values.set(key, String(value));
}
removeItem(key) {
this.removed.push(key);
this.values.delete(key);
}
}
async function loadAuthModule() {
importSequence += 1;
return import(`../auth-session.js?test=${importSequence}`);
}
function installBrowserMocks() {
globalThis.localStorage = new StorageMock({
token: 'устаревший-access-token',
role: 'ADMIN',
departmentId: '7',
userId: '11',
theme: 'dark'
});
globalThis.sessionStorage = new StorageMock({ token: 'устаревший-session-token' });
}
function jsonResponse(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
});
}
test('login сохраняет access JWT только в памяти и очищает legacy Web Storage', async () => {
installBrowserMocks();
const calls = [];
globalThis.fetch = async (url, options) => {
calls.push({ url, options });
return jsonResponse({ ok: true });
};
const auth = await loadAuthModule();
const state = auth.storeAuthState({
token: 'access-login',
role: 'ADMIN',
departmentId: 7,
userId: 11,
redirect: '/admin/'
});
assert.equal(auth.getAccessToken(), 'access-login');
assert.deepEqual(state, {
role: 'ADMIN',
departmentId: '7',
userId: '11',
redirect: '/admin/'
});
assert.equal(localStorage.getItem('token'), null);
assert.equal(localStorage.getItem('role'), null);
assert.equal(localStorage.getItem('theme'), 'dark');
await auth.fetchWithAuth('/api/test');
assert.equal(new Headers(calls[0].options.headers).get('Authorization'), 'Bearer access-login');
});
test('reload восстанавливает память через HttpOnly refresh-cookie', async () => {
installBrowserMocks();
const calls = [];
globalThis.fetch = async (url, options) => {
calls.push({ url, options });
return jsonResponse({
token: 'access-after-reload',
role: 'DEPARTMENT',
departmentId: 4,
userId: 15,
redirect: '/admin/#department-workspace'
});
};
const auth = await loadAuthModule();
const state = await auth.restoreSession();
assert.equal(calls.length, 1);
assert.equal(calls[0].url, '/api/auth/refresh');
assert.equal(calls[0].options.method, 'POST');
assert.equal(calls[0].options.credentials, 'same-origin');
assert.equal(auth.getAccessToken(), 'access-after-reload');
assert.equal(state.role, 'DEPARTMENT');
});
test('401 запускает одну ротацию refresh и повторяет запрос с новым access JWT', async () => {
installBrowserMocks();
const calls = [];
globalThis.fetch = async (url, options) => {
calls.push({ url, options });
if (calls.length === 1) return jsonResponse({ message: 'Истёк токен' }, 401);
if (url === '/api/auth/refresh') {
return jsonResponse({ token: 'access-rotated', role: 'ADMIN' });
}
return jsonResponse({ success: true });
};
const auth = await loadAuthModule();
auth.storeAuthState({ token: 'access-old', role: 'ADMIN' });
const response = await auth.fetchWithAuth('/api/protected');
assert.equal(response.status, 200);
assert.deepEqual(calls.map(call => call.url), [
'/api/protected',
'/api/auth/refresh',
'/api/protected'
]);
assert.equal(new Headers(calls[0].options.headers).get('Authorization'), 'Bearer access-old');
assert.equal(new Headers(calls[2].options.headers).get('Authorization'), 'Bearer access-rotated');
});
test('параллельные запросы используют одну refresh-ротацию', async () => {
installBrowserMocks();
let refreshCalls = 0;
let releaseRefresh;
const refreshGate = new Promise(resolve => {
releaseRefresh = resolve;
});
globalThis.fetch = async url => {
if (url === '/api/auth/refresh') {
refreshCalls += 1;
await refreshGate;
return jsonResponse({ token: 'access-shared', role: 'ADMIN' });
}
return jsonResponse({ success: true });
};
const auth = await loadAuthModule();
const first = auth.refreshAccessToken();
const second = auth.refreshAccessToken();
releaseRefresh();
assert.equal(await first, true);
assert.equal(await second, true);
assert.equal(refreshCalls, 1);
});
test('logout отзывает cookie-сессию и очищает access JWT из памяти', async () => {
installBrowserMocks();
const calls = [];
globalThis.fetch = async (url, options) => {
calls.push({ url, options });
return jsonResponse({ success: true });
};
const auth = await loadAuthModule();
auth.storeAuthState({ token: 'access-before-logout', role: 'STUDENT', userId: 22 });
await auth.logoutSession();
assert.equal(auth.getAccessToken(), null);
assert.equal(auth.getAuthState(), null);
assert.equal(calls[0].url, '/api/auth/logout');
assert.equal(calls[0].options.credentials, 'same-origin');
});

View File

@@ -0,0 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
process.env.TZ = 'Europe/Moscow';
const { formatLocalDate } = await import('../admin/js/utils.js');
test('date-only форматируется по московским компонентам, а не через UTC', () => {
assert.equal(formatLocalDate(new Date('2026-01-01T21:15:00Z')), '2026-01-02');
});
test('date-only сохраняет локальный день до московской полуночи', () => {
assert.equal(formatLocalDate(new Date('2026-01-01T20:59:59Z')), '2026-01-01');
});
test('некорректная дата отклоняется явной русской ошибкой', () => {
assert.throws(() => formatLocalDate(new Date('invalid')), /Ожидалась корректная дата/);
});

View File

@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { readdir, readFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const frontendRoot = fileURLToPath(new URL('..', import.meta.url));
const sourceExtensions = new Set(['.html', '.js', '.mjs', '.css']);
async function sourceFiles(directory = frontendRoot) {
const result = [];
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'tests') continue;
const path = join(directory, entry.name);
if (entry.isDirectory()) {
result.push(...await sourceFiles(path));
} else if (sourceExtensions.has(extname(entry.name))) {
result.push(path);
}
}
return result;
}
async function sourceCorpus() {
const files = await sourceFiles();
return Promise.all(files.map(async path => ({ path, text: await readFile(path, 'utf8') })));
}
test('frontend не исполняет удалённые или inline-скрипты', async () => {
const corpus = await sourceCorpus();
for (const { path, text } of corpus) {
assert.doesNotMatch(text, /(?:from\s+|import\s*\()\s*['"]https?:\/\//, path);
if (extname(path) === '.html') {
assert.doesNotMatch(text, /<script\b(?![^>]*\bsrc=)[^>]*>/i, path);
assert.doesNotMatch(text, /<style\b/i, path);
assert.doesNotMatch(text, /\son[a-z]+\s*=/i, path);
}
}
});
test('access JWT и профиль сессии не читаются и не записываются в Web Storage', async () => {
const corpus = await sourceCorpus();
const authStorage = /(?:localStorage|sessionStorage)\.(?:getItem|setItem)\(\s*['"](?:token|role|departmentId|userId)['"]/;
for (const { path, text } of corpus) {
assert.doesNotMatch(text, authStorage, path);
}
});
test('CSP запрещает произвольные inline-ресурсы и покрывает style-атрибуты хэшами', async () => {
const corpus = await sourceCorpus();
const config = await readFile(join(frontendRoot, 'security.conf'), 'utf8');
assert.match(config, /script-src 'self'/);
assert.match(config, /script-src-attr 'none'/);
assert.match(config, /style-src 'self'/);
assert.match(config, /style-src-attr 'unsafe-hashes'/);
assert.match(config, /connect-src 'self'/);
assert.doesNotMatch(config, /'unsafe-inline'|'unsafe-eval'/);
const expectedHashes = new Set();
for (const { path, text } of corpus) {
assert.doesNotMatch(text, /\.style\.(?:cssText|[A-Za-z-]+)\s*=|\.style\.setProperty|setAttribute\(\s*['"]style/i, path);
for (const match of text.matchAll(/style="([^"]*)"/g)) {
assert.doesNotMatch(match[1], /\$\{/, `Динамический style-атрибут в ${path}`);
expectedHashes.add(`sha256-${createHash('sha256').update(match[1]).digest('base64')}`);
}
}
const configuredHashes = new Set(
Array.from(config.matchAll(/'((?:sha256)-[^']+)'/g), match => match[1])
);
assert.deepEqual(configuredHashes, expectedHashes);
});
test('Docker-образ включает локальный telemetry bundle и security headers', async () => {
const dockerfile = await readFile(join(frontendRoot, 'Dockerfile'), 'utf8');
const telemetry = await readFile(join(frontendRoot, 'telemetry.js'), 'utf8');
assert.match(dockerfile, /npm ci/);
assert.match(dockerfile, /npm run build:vendor/);
assert.match(dockerfile, /scripts\/build-vendor\.mjs/);
assert.match(dockerfile, /COPY --from=frontend-assets \/build\/dist\/vendor\//);
assert.match(dockerfile, /magistr-security\.conf/);
assert.match(telemetry, /import\('\/vendor\/otel\.js'\)/);
assert.doesNotMatch(telemetry, /https?:\/\//);
});
test('сообщения об ошибках не вставляют текст исключения через innerHTML', async () => {
const files = [
'admin/js/main.js',
'admin/settings/js/main.js',
'admin/settings/js/views/database.js',
'admin/js/views/teacher-requests.js'
];
for (const relativePath of files) {
const source = await readFile(join(frontendRoot, relativePath), 'utf8');
assert.doesNotMatch(source, /\$\{[^}\n]*(?:e|error|err)\.message[^}\n]*\}/, relativePath);
}
const utils = await readFile(join(frontendRoot, 'admin/js/utils.js'), 'utf8');
assert.match(utils, /element\.textContent = message/);
assert.match(utils, /container\.replaceChildren\(element\)/);
assert.match(utils, /cell\.textContent = message/);
assert.match(utils, /tbody\.replaceChildren\(row\)/);
});
test('пароли скрыты и помечены для менеджера паролей', async () => {
const users = await readFile(join(frontendRoot, 'admin/views/users.html'), 'utf8');
const database = await readFile(join(frontendRoot, 'admin/settings/views/database.html'), 'utf8');
const teacherRequests = await readFile(join(frontendRoot, 'admin/js/views/teacher-requests.js'), 'utf8');
assert.match(users, /id="new-password"[^>]*type="password"|type="password"[^>]*id="new-password"/);
assert.match(users, /id="new-password"[^>]*autocomplete="new-password"/);
assert.match(database, /id="tenant-password"[^>]*type="password"|type="password"[^>]*id="tenant-password"/);
assert.match(database, /id="tenant-password"[^>]*autocomplete="new-password"/);
assert.match(teacherRequests, /type="password"[^>]*class="teacher-request-password"[^>]*autocomplete="new-password"/);
});
test('tenant UI и production-логи соблюдают русский языковой регламент', async () => {
const database = await readFile(join(frontendRoot, 'admin/settings/js/views/database.js'), 'utf8');
assert.doesNotMatch(database, /\bOnline\b|\bOffline\b/);
assert.match(database, /Доступно/);
assert.match(database, /Недоступно/);
const backendRoot = join(frontendRoot, '..', 'backend', 'src', 'main', 'java', 'com', 'magistr', 'app', 'config', 'tenant');
const logSources = await Promise.all([
'TenantRoutingDataSource.java',
'TenantDataSourceConfig.java',
'TenantInterceptor.java'
].map(name => readFile(join(backendRoot, name), 'utf8')));
const tenantSources = logSources.flatMap(source =>
Array.from(source.matchAll(/log\.(?:debug|info|warn|error)\("([^"]*)"/g), match => match[1])
).join('\n');
assert.doesNotMatch(
tenantSources,
/Database API request|Unknown tenant|Resolved tenant|default DataSource|H2 in-memory|errorType|Tenant-БД|tenant-БД|startup lifecycle|startup Hikari pool/
);
const frontendSources = (await sourceCorpus())
.filter(({ path }) => extname(path) === '.js')
.map(({ text }) => text)
.join('\n');
assert.doesNotMatch(frontendSources, /console\.(?:log|info|warn|error)\([^\n]*(?:Failed|Error)/);
assert.doesNotMatch(frontendSources, /console\.(?:warn|error)\([^\n]*\.message/);
});
test('единая матрица ролей согласует admin и settings для учебного отдела', async () => {
const {
ADMIN_APP_ROLES,
SETTINGS_ROLES,
capabilitiesForRole
} = await import('../admin/js/role-capabilities.js');
const educationOffice = capabilitiesForRole('EDUCATION_OFFICE');
assert.ok(ADMIN_APP_ROLES.includes('EDUCATION_OFFICE'));
assert.ok(SETTINGS_ROLES.includes('EDUCATION_OFFICE'));
assert.ok(educationOffice.adminTabs.includes('academic-calendar'));
assert.deepEqual(educationOffice.settingsTabs, ['time-slots', 'edu-forms']);
assert.equal(educationOffice.defaultSettingsTab, 'time-slots');
assert.equal(capabilitiesForRole('DEPARTMENT').settingsTabs.length, 0);
const adminMain = await readFile(join(frontendRoot, 'admin/js/main.js'), 'utf8');
const settingsMain = await readFile(join(frontendRoot, 'admin/settings/js/main.js'), 'utf8');
assert.match(adminMain, /from '\.\/role-capabilities\.js'/);
assert.match(settingsMain, /from '\.\.\/\.\.\/js\/role-capabilities\.js'/);
assert.doesNotMatch(adminMain, /const ROLE_NAVIGATION/);
assert.doesNotMatch(settingsMain, /const ROLE_TABS/);
});

View File

@@ -35,14 +35,9 @@
/* Swap icon immediately and trigger a gentle rotate via CSS */
btn.innerHTML = goLight ? sunSVG : moonSVG;
const svg = btn.querySelector('svg');
svg.style.transition = 'none';
svg.style.transform = 'rotate(-90deg) scale(0.5)';
svg.style.opacity = '0';
requestAnimationFrame(() => {
svg.style.transition = 'transform 0.4s ease, opacity 0.3s ease';
svg.style.transform = 'rotate(0deg) scale(1)';
svg.style.opacity = '1';
});
svg.classList.remove('theme-toggle-icon-enter');
void svg.offsetWidth;
svg.classList.add('theme-toggle-icon-enter');
});
/* Where to place the button */