Implement phone number normalization and access control for Telegram users

- Added functionality to normalize phone numbers for comparison, ensuring only digits are stored and checked.
- Updated configuration to include optional phone number allowlists for users and admins in the environment settings.
- Enhanced authentication logic to allow access based on normalized phone numbers, in addition to usernames.
- Introduced new helper functions for parsing and validating phone numbers, improving code organization and maintainability.
- Added unit tests to validate phone normalization and access control based on phone numbers.
This commit is contained in:
2026-02-18 16:11:44 +03:00
parent d0d22c150a
commit 59ba2a9ca4
10 changed files with 344 additions and 48 deletions

View File

@@ -295,13 +295,13 @@
let html = "<span class=\"num\">" + d.getDate() + "</span><div class=\"day-markers\">";
if (showMarkers) {
if (dutyList.length) {
html += "<span class=\"duty-marker\" data-names=\"" + namesAttr(dutyList) + "\" title=\"" + titleAttr(dutyList) + "\" aria-label=\"Дежурные\">Д</span>";
html += "<button type=\"button\" class=\"duty-marker\" data-event-type=\"duty\" data-names=\"" + namesAttr(dutyList) + "\" title=\"" + titleAttr(dutyList) + "\" aria-label=\"Дежурные\">Д</button>";
}
if (unavailableList.length) {
html += "<span class=\"unavailable-marker\" data-names=\"" + namesAttr(unavailableList) + "\" title=\"" + titleAttr(unavailableList) + "\" aria-label=\"Недоступен\">Н</span>";
html += "<button type=\"button\" class=\"unavailable-marker\" data-event-type=\"unavailable\" data-names=\"" + namesAttr(unavailableList) + "\" title=\"" + titleAttr(unavailableList) + "\" aria-label=\"Недоступен\">Н</button>";
}
if (vacationList.length) {
html += "<span class=\"vacation-marker\" data-names=\"" + namesAttr(vacationList) + "\" title=\"" + titleAttr(vacationList) + "\" aria-label=\"Отпуск\">О</span>";
html += "<button type=\"button\" class=\"vacation-marker\" data-event-type=\"vacation\" data-names=\"" + namesAttr(vacationList) + "\" title=\"" + titleAttr(vacationList) + "\" aria-label=\"Отпуск\">О</button>";
}
if (hasEvent) {
html += "<button type=\"button\" class=\"info-btn\" aria-label=\"Информация о дне\" data-summary=\"" + escapeHtml(eventSummaries.join("\n")) + "\">i</button>";
@@ -372,8 +372,9 @@
btn.addEventListener("click", function (e) {
e.stopPropagation();
var summary = btn.getAttribute("data-summary") || "";
if (hintEl.hidden || hintEl.textContent !== summary) {
hintEl.textContent = summary;
var content = "События:\n" + summary;
if (hintEl.hidden || hintEl.textContent !== content) {
hintEl.textContent = content;
var rect = btn.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.dataset.active = "1";
@@ -400,6 +401,20 @@
}
}
var EVENT_TYPE_LABELS = { duty: "Дежурство", unavailable: "Недоступен", vacation: "Отпуск" };
function getDutyMarkerHintContent(marker) {
var type = marker.getAttribute("data-event-type") || "duty";
var label = EVENT_TYPE_LABELS[type] || type;
var names = (marker.getAttribute("data-names") || "").replace(/\n/g, ", ");
return names ? label + ": " + names : label;
}
function clearActiveDutyMarker() {
calendarEl.querySelectorAll(".duty-marker.calendar-marker-active, .unavailable-marker.calendar-marker-active, .vacation-marker.calendar-marker-active")
.forEach(function (m) { m.classList.remove("calendar-marker-active"); });
}
function bindDutyMarkerTooltips() {
var hintEl = document.getElementById("dutyMarkerHint");
if (!hintEl) {
@@ -418,23 +433,54 @@
clearTimeout(hideTimeout);
hideTimeout = null;
}
var names = marker.getAttribute("data-names") || "";
hintEl.textContent = names;
hintEl.textContent = getDutyMarkerHintContent(marker);
var rect = marker.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.hidden = false;
});
marker.addEventListener("mouseleave", function () {
if (hintEl.dataset.active) { return; }
hideTimeout = setTimeout(function () {
hintEl.hidden = true;
hideTimeout = null;
}, 150);
});
marker.addEventListener("click", function (e) {
e.stopPropagation();
if (marker.classList.contains("calendar-marker-active")) {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
marker.classList.remove("calendar-marker-active");
return;
}
clearActiveDutyMarker();
hintEl.textContent = getDutyMarkerHintContent(marker);
var rect = marker.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.hidden = false;
hintEl.dataset.active = "1";
marker.classList.add("calendar-marker-active");
});
});
if (!document._dutyMarkerHintBound) {
document._dutyMarkerHintBound = true;
document.addEventListener("click", function () {
if (hintEl.dataset.active) {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
clearActiveDutyMarker();
}
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && hintEl.dataset.active) {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
clearActiveDutyMarker();
}
});
}
}
var EVENT_TYPE_LABELS = { duty: "Дежурство", unavailable: "Недоступен", vacation: "Отпуск" };
/** Format UTC date from ISO string as DD.MM for display. */
/** Format date as DD.MM in user's local timezone (for duty card labels). */
function formatDateKey(isoDateStr) {
@@ -540,7 +586,17 @@
dutyListEl.innerHTML = fullHtml;
var scrollTarget = dutyListEl.querySelector(".duty-timeline-day--today");
if (scrollTarget) {
scrollTarget.scrollIntoView({ behavior: "auto", block: "start" });
var calendarSticky = document.getElementById("calendarSticky");
if (calendarSticky) {
requestAnimationFrame(function () {
var calendarHeight = calendarSticky.offsetHeight;
var todayTop = scrollTarget.getBoundingClientRect().top + window.scrollY;
var scrollTop = Math.max(0, todayTop - calendarHeight);
window.scrollTo({ top: scrollTop, behavior: "auto" });
});
} else {
scrollTarget.scrollIntoView({ behavior: "auto", block: "start" });
}
}
}

View File

@@ -245,7 +245,7 @@ body {
transform: none;
}
/* Единый размер маркеров (11px), чтобы и один, и три в строке выглядели одинаково */
/* Маркеры: компактный размер 11px, кнопки для доступности и тапа */
.duty-marker,
.unavailable-marker,
.vacation-marker {
@@ -254,10 +254,13 @@ body {
justify-content: center;
width: 11px;
height: 11px;
padding: 0;
border: none;
font-size: 0.55rem;
font-weight: 700;
border-radius: 50%;
flex-shrink: 0;
cursor: pointer;
}
.duty-marker {
@@ -275,6 +278,18 @@ body {
background: color-mix(in srgb, var(--vacation) 25%, transparent);
}
.duty-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--duty);
}
.unavailable-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--unavailable);
}
.vacation-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--vacation);
}
.duty-list {
font-size: 0.9rem;
}