Compare commits

...
Author SHA1 Message Date
codex a153dc8765 docs: record admin navigation release changes
CI / tests-and-images (pull_request) Successful in 2m26s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-24 23:29:57 +03:00
codex 3620bb734c test: wait for localized user role
CI / tests-and-images (pull_request) Successful in 2m24s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 23:26:06 +03:00
codex 830e1d0f0d fix: localize account roles and align lab brand
CI / tests-and-images (pull_request) Successful in 2m26s
Runner smoke test / runner-smoke (pull_request) Successful in 2s
2026-10-24 23:17:43 +03:00
codex a1bb3eab98 fix: align admin navigation with backend capability
CI / tests-and-images (pull_request) Successful in 2m43s
Runner smoke test / runner-smoke (pull_request) Successful in 1s
2026-10-24 22:32:30 +03:00
admin 8bd2b53469 Merge pull request 'Согласовать поиск, кабинет и лабораторию с макетом' (#56) from fix/uiux-visual-feedback into main
CI / tests-and-images (push) Successful in 2m43s
Runner smoke test / runner-smoke (push) Successful in 1s
Reviewed-on: #56
Reviewed-by: agent <agent@vakanaut.ru>
2026-10-24 19:02:01 +00:00
10 changed files with 93 additions and 42 deletions

No files matched your search

+3
View File
@@ -2,6 +2,9 @@
## Не выпущено
- Frontend `0.3.3` / Backend `0.13.2`: управление пользователями отображается
согласно правам аккаунта, подписи ролей локализованы, повторяющиеся действия
кабинета убраны, а знак лаборатории приведён к основному бренду.
- Frontend `0.3.2`: устранены пересечения названия бренда с поиском и
мобильной навигации документа; навигационные ссылки получили общие quiet и
primary состояния.
+5 -4
View File
@@ -10,7 +10,8 @@ Telegram-бот — только часть рабочего окружения
## Текущее состояние
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2`, backend версии
`0.13.1` и frontend `0.3.2` с поисковым экраном и внутренней лабораторией.
`0.13.2` и frontend `0.3.3` с поисковым экраном, внутренней лабораторией и
согласованным отображением доступных действий учетной записи.
Авторизация хранится в
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
@@ -19,8 +20,8 @@ SQLite; первый пользователь `admin` создаётся инт
| Компонент | Версия | Состояние |
|---|---:|---|
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
| Backend | `0.13.1` | авторизация, поиск с фильтрами, ограниченный staging и статические ресурсы |
| Frontend | `0.3.2` | поиск по правовой базе, просмотр редакций, настраиваемая ширина панелей и внутренняя лаборатория |
| Backend | `0.13.2` | авторизация, серверная capability управления пользователями, поиск и статические ресурсы |
| Frontend | `0.3.3` | поиск по правовой базе, просмотр редакций, внутренняя лаборатория и контекстная навигация кабинета |
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
| RAG и база знаний | — | ещё не созданы |
@@ -63,4 +64,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
---
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.1 · Frontend v0.3.2
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.2 · Frontend v0.3.3
+7 -7
View File
@@ -13,7 +13,7 @@ import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from search.auth import AuthError, AuthStore
from search.auth import AuthError, AuthStore, can_manage_users
from search.minjust_opensearch import APP_VERSION, request_json
from search.catalog import CATALOGS, labels
from search.query import build_search_query
@@ -345,21 +345,21 @@ class Api:
return 200, {"account": account, "session_token": token}
except AuthError as error:
raise ApiError(400, str(error)) from error
if user and user["username"].casefold() == "admin" and method == "GET" and parts == ["admin", "users"]:
if user and can_manage_users(user["username"]) and method == "GET" and parts == ["admin", "users"]:
return 200, {"users": self.auth_store.list_users()}
if user and user["username"].casefold() == "admin" and method == "POST" and parts == ["admin", "users"]:
if user and can_manage_users(user["username"]) and method == "POST" and parts == ["admin", "users"]:
try:
self.auth_store.create_user(body.get("username"), body.get("password"))
return 201, {"ok": True}
except AuthError as error:
raise ApiError(400, str(error)) from error
if user and user["username"].casefold() == "admin" and method == "DELETE" and len(parts) == 3 and parts[:2] == ["admin", "users"]:
if user and can_manage_users(user["username"]) and method == "DELETE" and len(parts) == 3 and parts[:2] == ["admin", "users"]:
try:
self.auth_store.delete_user(parts[2])
return 200, {"ok": True}
except AuthError as error:
raise ApiError(400, str(error)) from error
if user and user["username"].casefold() == "admin" and method == "POST" and len(parts) == 4 and parts[:2] == ["admin", "users"] and parts[3] == "reset-password":
if user and can_manage_users(user["username"]) and method == "POST" and len(parts) == 4 and parts[:2] == ["admin", "users"] and parts[3] == "reset-password":
try:
self.auth_store.reset_password(parts[2], body.get("password"))
return 200, {"ok": True}
@@ -424,7 +424,7 @@ def handler(api: Api):
raise ApiError(401, "authentication required")
if user["must_change_password"] and not (method == "POST" and route == "/auth/change-password") and not (method == "GET" and route == "/auth/me"):
raise ApiError(403, "change password before continuing")
if route.startswith("/admin/users") and user["username"].casefold() != "admin":
if route.startswith("/admin/users") and not can_manage_users(user["username"]):
raise ApiError(403, "admin account required")
body = None
if method in {"POST", "DELETE"}:
@@ -503,7 +503,7 @@ def main() -> int:
api = Api(arguments.url, arguments.index, arguments.data, arguments.reviews_db, secret, arguments.users_db, arguments.cookie_secure)
if arguments.create_admin or arguments.reset_admin_password:
users = api.auth_store.list_users()
admin_exists = any(user["username"].casefold() == "admin" for user in users)
admin_exists = any(can_manage_users(user["username"]) for user in users)
if arguments.create_admin and users:
parser.error("accounts already exist")
if arguments.reset_admin_password and not admin_exists:
+7 -2
View File
@@ -22,6 +22,10 @@ class AuthError(ValueError):
pass
def can_manage_users(username: str) -> bool:
return isinstance(username, str) and username.casefold() == "admin"
class AuthStore:
def __init__(self, path: Path | str):
if path != ":memory:":
@@ -106,7 +110,8 @@ class AuthStore:
@staticmethod
def _public(user: sqlite3.Row) -> dict:
return {"username": user["username"], "role": "Администратор", "must_change_password": bool(user["must_change_password"]), "created_at": user["created_at"]}
can_manage = can_manage_users(user["username"])
return {"username": user["username"], "role": "Администратор" if can_manage else "Пользователь", "can_manage_users": can_manage, "must_change_password": bool(user["must_change_password"]), "created_at": user["created_at"]}
def user_for_token(self, token: str | None) -> dict | None:
if not token:
@@ -151,7 +156,7 @@ class AuthStore:
raise AuthError("login already exists") from error
def delete_user(self, username: str) -> None:
if username.casefold() == "admin":
if can_manage_users(username):
raise AuthError("admin account cannot be deleted")
with self._lock:
self.connection.execute("DELETE FROM users WHERE username=?", (username,))
+2 -2
View File
@@ -17,7 +17,7 @@ from typing import Iterator
from search.catalog import authority_codes, legal_force_code, source_code
APP_VERSION = "0.13.1"
APP_VERSION = "0.13.2"
LANGUAGES = {"ru", "ky"}
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
@@ -465,7 +465,7 @@ def main() -> int:
else:
documents, fragments = export_bulk(arguments.input, arguments.output, arguments.index, arguments.limit)
destination = arguments.output
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.3.2")
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.3.3")
return 0
+13 -12
View File
@@ -25,7 +25,7 @@
.review__header p { max-width: 65ch; margin: 0; color: var(--muted); }
.review__header-top { display: flex; justify-content: space-between; gap: 1rem; align-items: start; }
.review__eyebrow { display: inline-flex; align-items: center; gap: .5rem; color: var(--ink); font: 600 1.1rem/1.25 var(--serif); }
.review__eyebrow::before { content: ''; width: 1.5rem; height: 1.5rem; border: 2px solid var(--brand); border-radius: 50%; background: linear-gradient(90deg, transparent 45%, var(--brand) 45% 55%, transparent 55%), linear-gradient(0deg, transparent 45%, var(--brand) 45% 55%, transparent 55%); }
.review__eyebrow-mark { display: block; flex: 0 0 1.5rem; width: 1.5rem; height: 1.5rem; }
.review__main { padding-block-end: 2rem; }
.review__search { display: grid; grid-template-columns: minmax(18rem, 1fr) 9rem 9rem auto; align-items: end; gap: .75rem; padding: 1rem; background: var(--surface); border: 1px solid var(--line); border-radius: 6px; }
.review__field { display: grid; gap: .35rem; min-width: 0; }
@@ -621,7 +621,7 @@
<body>
<a class="review__skip" href="#results">Перейти к результатам</a>
<header class="review__header">
<div class="review__nav"><div class="review__eyebrow">Акылдаш</div><nav class="review__nav-links" aria-label="Основная навигация"><a id="review-search-nav" href="/">Поиск</a><a id="review-review-nav" href="/review">Лаборатория</a><button id="review-account-open" type="button">Личный кабинет</button><a id="review-users-open" href="/users" hidden>Пользователи</a></nav><div class="review__nav-account"><span id="review-signed-in-user"></span><button id="review-logout" type="button">Выйти</button></div></div>
<div class="review__nav"><div class="review__eyebrow"><svg class="review__eyebrow-mark" viewBox="0 0 48 48" aria-hidden="true"><circle cx="24" cy="24" r="20" fill="none" stroke="currentColor" stroke-width="3"/><path d="M24 5v38M5 24h38M11 11l26 26M37 11 11 37M17 7c2 8 12 8 14 0M17 41c2-8 12-8 14 0M7 17c8 2 8 12 0 14M41 17c-8 2-8 12 0 14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg>Акылдаш</div><nav class="review__nav-links" aria-label="Основная навигация"><a id="review-search-nav" href="/">Поиск</a><a id="review-review-nav" href="/review">Лаборатория</a><button id="review-account-open" type="button">Личный кабинет</button><a id="review-users-open" href="/users" hidden>Пользователи</a></nav><div class="review__nav-account"><span id="review-signed-in-user"></span><button id="review-logout" type="button">Выйти</button></div></div>
<div class="review__header-content"><div class="review__heading-copy"><h1 id="route-heading">Проверка качества поиска</h1><p id="route-description">Сопоставляйте запрос с текстом документов и сохраняйте оценку выдачи.</p></div><div class="review__header-top"><button id="tutorial-open" type="button">Как это работает</button></div></div>
</header>
<header class="search__chrome" aria-label="Акылдаш">
@@ -657,7 +657,7 @@
<section class="search__document" id="public-document-panel" aria-labelledby="public-document-heading"><div class="search__document-head"><div class="search__breadcrumbs" hidden><span>Документ</span><span aria-hidden="true">/</span><span id="document-breadcrumb">Выберите результат</span></div><div><button class="search__mobile-back" id="document-back" type="button">К результатам</button><button class="search__expand" id="document-expand" type="button" aria-label="Развернуть документ">Развернуть</button></div><h2 id="public-document-heading">Документ</h2></div><p id="public-document-meta" class="search__document-meta"><span id="document-facts">Выберите документ в выдаче, чтобы открыть текст.</span><label class="search__document-language" hidden>Язык<select id="document-language" disabled aria-label="Язык документа"></select></label></p><div class="search__document-toolbar"><details class="search__toc" id="document-toc"><summary>Содержание</summary><nav aria-label="Содержание документа"><ol id="document-toc-list"><li>Оглавление появится после загрузки документа</li></ol></nav></details><label class="search__edition-label"><select id="document-edition" disabled aria-label="Редакция документа"></select></label><button id="document-print" type="button">Печать</button><a id="document-source" class="search__source-link" href="#" target="_blank" rel="noreferrer" hidden>Официальный источник</a></div><div class="search__find"><label for="document-find">Найти в документе</label><input id="document-find" type="search" placeholder="Фраза в документе" disabled><span class="search__find-count" id="document-find-count" aria-live="polite"></span><button id="document-find-prev" type="button" disabled aria-label="Предыдущее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m12 4-6 6 6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button><button id="document-find-next" type="button" disabled aria-label="Следующее совпадение"><svg viewBox="0 0 20 20" width="18" height="18" aria-hidden="true"><path d="m8 4 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg></button></div><p class="search__document-note" id="document-note" hidden></p><article id="public-document-body" class="search__document-body"></article></section>
</div>
</main>
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Правовая база Кыргызстана</span><span>Frontend v0.3.2 <span aria-hidden="true">·</span> Backend v0.13.1</span></footer>
<footer class="search__footer"><span><strong>Акылдаш</strong> <span aria-hidden="true">·</span> Правовая база Кыргызстана</span><span>Frontend v0.3.3 <span aria-hidden="true">·</span> Backend v0.13.2</span></footer>
<div class="review__actions"><div class="review__actions-inner"><label>Проверяющий<input id="reviewer" readonly aria-label="Проверяющий"></label><label>Комментарий к выдаче <input id="overall-comment" maxlength="4000" placeholder="Что важно учесть? (необязательно)"></label><button class="review__button--primary" id="save" type="button" disabled>Сначала выполните поиск</button><p id="draft-status" class="review__status" role="status" aria-live="polite">Черновик появится после поиска.</p></div></div>
<dialog id="login-dialog" aria-labelledby="login-heading"><form class="review__page" id="login-form"><h2 id="login-heading">Вход в Акылдаш</h2><p>Введите учетные данные, выданные администратором.</p><label>Логин<input id="login-username" name="username" autocomplete="username" required minlength="3" maxlength="64"></label><label>Пароль<input id="login-password" name="password" type="password" autocomplete="current-password" required></label><p id="login-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Войти</button></form></dialog>
<dialog id="password-dialog" aria-labelledby="password-heading"><form class="review__page" id="password-form"><h2 id="password-heading">Создайте новый пароль</h2><p class="review__notice">Для продолжения замените временный пароль. Используйте не менее 12 символов.</p><label>Текущий пароль<input id="current-password" type="password" autocomplete="current-password" required></label><label>Новый пароль<input id="new-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><label>Повторите новый пароль<input id="confirm-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p id="password-error" class="review__status review__status--error" role="alert"></p><button class="review__button--primary" type="submit">Сохранить пароль</button></form></dialog>
@@ -675,7 +675,7 @@
<dialog id="document-dialog"><button class="review__dialog-close" id="dialog-close" type="button">Закрыть</button><h2 id="dialog-heading">Документ</h2><div class="review__document-body" id="dialog-body"></div></dialog>
<dialog class="search__about" id="sources-dialog" aria-labelledby="sources-heading"><button class="review__dialog-close" id="sources-close" type="button">Закрыть</button><h2 id="sources-heading">Источники документов</h2><p>Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.</p><p>Официальный источник: <a href="https://cbd.minjust.gov.kg/" target="_blank" rel="noreferrer">Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики</a>.</p></dialog>
<div class="review__tour-spotlight" id="tutorial-spotlight" hidden aria-hidden="true"></div><dialog class="review__tour" id="tutorial-dialog" aria-labelledby="tutorial-heading" aria-describedby="tutorial-text"><div class="review__tour-content"><p class="review__tour-kicker">Короткое знакомство</p><h2 id="tutorial-heading"></h2><p id="tutorial-text"></p><div class="review__tour-footer"><span class="review__tour-progress" id="tutorial-progress"></span><div class="review__tour-actions"><button id="tutorial-skip" type="button">Пропустить</button><button class="review__button--primary" id="tutorial-next" type="button">Далее</button></div></div></div></dialog>
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.3.2 · Backend v0.13.1 · Внутренняя лаборатория релевантности</div></footer>
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.3.3 · Backend v0.13.2 · Внутренняя лаборатория релевантности</div></footer>
<script>
document.body.classList.add('review__locked');
const DRAFT_KEY = 'akyldash-search-review-draft';
@@ -697,10 +697,11 @@
};
const copy = (key) => searchCopy[uiLanguage][key];
const modalCopy = {
ru: {account: 'Личный кабинет', lab: 'Лаборатория', users: 'Пользователи', logout: 'Выйти', role: 'Роль', admin: 'Администратор', created: 'Дата создания', changePassword: 'Изменить пароль', passwordRequirement: 'Новый пароль должен содержать не менее 12 символов.', currentPassword: 'Текущий пароль', newPassword: 'Новый пароль', confirmPassword: 'Повторите новый пароль', change: 'Изменить пароль', close: 'Закрыть', showPassword: 'Показать пароль', hidePassword: 'Скрыть пароль', usersHeading: 'Управление пользователями', temporaryPassword: 'Временный пароль', temporaryPasswordHelp: 'Пароль — от 12 символов. Новый пользователь сменит его при первом входе. Передайте временный пароль отдельно.', createUser: 'Создать пользователя', sourcesHeading: 'Источники документов', sourceIntro: 'Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.', officialSource: 'Официальный источник:', sourceName: 'Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики', loginHeading: 'Вход в Акылдаш', loginHelp: 'Введите учетные данные, выданные администратором.', username: 'Логин', password: 'Пароль', login: 'Войти', newPasswordHeading: 'Создайте новый пароль', newPasswordHelp: 'Для продолжения замените временный пароль. Используйте не менее 12 символов.', loginError: 'Не удалось войти. Проверьте логин и пароль.', passwordMismatch: 'Новые пароли не совпадают.', passwordChangeError: 'Не удалось изменить пароль.', usersLoadError: 'Не удалось загрузить список пользователей.', passwordResetError: 'Не удалось сбросить пароль.', userDeleteError: 'Не удалось удалить учетную запись.', userCreateError: 'Не удалось создать учетную запись.'},
ky: {account: 'Жеке кабинет', lab: 'Лаборатория', users: 'Колдонуучулар', logout: 'Чыгуу', role: 'Ролу', admin: 'Администратор', created: 'Түзүлгөн күнү', changePassword: 'Сырсөздү өзгөртүү', passwordRequirement: 'Жаңы сырсөз кеминде 12 белгиден турушу керек.', currentPassword: 'Учурдагы сырсөз', newPassword: 'Жаңы сырсөз', confirmPassword: 'Жаңы сырсөздү кайталаңыз', change: 'Сырсөздү өзгөртүү', close: 'Жабуу', showPassword: 'Сырсөздү көрсөтүү', hidePassword: 'Сырсөздү жашыруу', usersHeading: 'Колдонуучуларды башкаруу', temporaryPassword: 'Убактылуу сырсөз', temporaryPasswordHelp: 'Сырсөз кеминде 12 белгиден турушу керек. Жаңы колдонуучу биринчи киргенде аны өзгөртөт. Убактылуу сырсөздү өзүнчө жөнөтүңүз.', createUser: 'Колдонуучу түзүү', sourcesHeading: 'Документтердин булактары', sourceIntro: 'Издөө Кыргыз Республикасынын укуктук булактарынан даярдалган документтер боюнча жүргүзүлөт. Ар бир документ үчүн жеткиликтүү редакция жана расмий булактын шилтемеси көрсөтүлөт.', officialSource: 'Расмий булак:', sourceName: 'Кыргыз Республикасынын Юстиция министрлигинин укуктук маалыматтарынын борборлоштурулган банкы', loginHeading: 'Акылдашка кирүү', loginHelp: 'Администратор берген эсептик дайындарды киргизиңиз.', username: 'Колдонуучунун аты', password: 'Сырсөз', login: 'Кирүү', newPasswordHeading: 'Жаңы сырсөз түзүңүз', newPasswordHelp: 'Улантуу үчүн убактылуу сырсөздү алмаштырыңыз. Кеминде 12 белги колдонуңуз.', loginError: 'Кирүү мүмкүн болгон жок. Колдонуучунун атын жана сырсөздү текшериңиз.', passwordMismatch: 'Жаңы сырсөздөр дал келген жок.', passwordChangeError: 'Сырсөздү өзгөртүү мүмкүн болгон жок.', usersLoadError: 'Колдонуучулардын тизмесин жүктөө мүмкүн болгон жок.', passwordResetError: 'Сырсөздү баштапкы абалга келтирүү мүмкүн болгон жок.', userDeleteError: 'Колдонуучуну өчүрүү мүмкүн болгон жок.', userCreateError: 'Колдонуучуну түзүү мүмкүн болгон жок.'}
ru: {account: 'Личный кабинет', lab: 'Лаборатория', users: 'Пользователи', logout: 'Выйти', role: 'Роль', admin: 'Администратор', user: 'Пользователь', created: 'Дата создания', changePassword: 'Изменить пароль', passwordRequirement: 'Новый пароль должен содержать не менее 12 символов.', currentPassword: 'Текущий пароль', newPassword: 'Новый пароль', confirmPassword: 'Повторите новый пароль', change: 'Изменить пароль', close: 'Закрыть', showPassword: 'Показать пароль', hidePassword: 'Скрыть пароль', usersHeading: 'Управление пользователями', temporaryPassword: 'Временный пароль', temporaryPasswordHelp: 'Пароль — от 12 символов. Новый пользователь сменит его при первом входе. Передайте временный пароль отдельно.', createUser: 'Создать пользователя', sourcesHeading: 'Источники документов', sourceIntro: 'Поиск выполняется по документам, подготовленным из правовых источников Кыргызской Республики. Для каждого результата доступная редакция и ссылка на официальный источник показываются в панели документа.', officialSource: 'Официальный источник:', sourceName: 'Централизованный банк данных правовой информации Министерства юстиции Кыргызской Республики', loginHeading: 'Вход в Акылдаш', loginHelp: 'Введите учетные данные, выданные администратором.', username: 'Логин', password: 'Пароль', login: 'Войти', newPasswordHeading: 'Создайте новый пароль', newPasswordHelp: 'Для продолжения замените временный пароль. Используйте не менее 12 символов.', loginError: 'Не удалось войти. Проверьте логин и пароль.', passwordMismatch: 'Новые пароли не совпадают.', passwordChangeError: 'Не удалось изменить пароль.', usersLoadError: 'Не удалось загрузить список пользователей.', passwordResetError: 'Не удалось сбросить пароль.', userDeleteError: 'Не удалось удалить учетную запись.', userCreateError: 'Не удалось создать учетную запись.'},
ky: {account: 'Жеке кабинет', lab: 'Лаборатория', users: 'Колдонуучулар', logout: 'Чыгуу', role: 'Ролу', admin: 'Администратор', user: 'Колдонуучу', created: 'Түзүлгөн күнү', changePassword: 'Сырсөздү өзгөртүү', passwordRequirement: 'Жаңы сырсөз кеминде 12 белгиден турушу керек.', currentPassword: 'Учурдагы сырсөз', newPassword: 'Жаңы сырсөз', confirmPassword: 'Жаңы сырсөздү кайталаңыз', change: 'Сырсөздү өзгөртүү', close: 'Жабуу', showPassword: 'Сырсөздү көрсөтүү', hidePassword: 'Сырсөздү жашыруу', usersHeading: 'Колдонуучуларды башкаруу', temporaryPassword: 'Убактылуу сырсөз', temporaryPasswordHelp: 'Сырсөз кеминде 12 белгиден турушу керек. Жаңы колдонуучу биринчи киргенде аны өзгөртөт. Убактылуу сырсөздү өзүнчө жөнөтүңүз.', createUser: 'Колдонуучу түзүү', sourcesHeading: 'Документтердин булактары', sourceIntro: 'Издөө Кыргыз Республикасынын укуктук булактарынан даярдалган документтер боюнча жүргүзүлөт. Ар бир документ үчүн жеткиликтүү редакция жана расмий булактын шилтемеси көрсөтүлөт.', officialSource: 'Расмий булак:', sourceName: 'Кыргыз Республикасынын Юстиция министрлигинин укуктук маалыматтарынын борборлоштурулган банкы', loginHeading: 'Акылдашка кирүү', loginHelp: 'Администратор берген эсептик дайындарды киргизиңиз.', username: 'Колдонуучунун аты', password: 'Сырсөз', login: 'Кирүү', newPasswordHeading: 'Жаңы сырсөз түзүңүз', newPasswordHelp: 'Улантуу үчүн убактылуу сырсөздү алмаштырыңыз. Кеминде 12 белги колдонуңуз.', loginError: 'Кирүү мүмкүн болгон жок. Колдонуучунун атын жана сырсөздү текшериңиз.', passwordMismatch: 'Жаңы сырсөздөр дал келген жок.', passwordChangeError: 'Сырсөздү өзгөртүү мүмкүн болгон жок.', usersLoadError: 'Колдонуучулардын тизмесин жүктөө мүмкүн болгон жок.', passwordResetError: 'Сырсөздү баштапкы абалга келтирүү мүмкүн болгон жок.', userDeleteError: 'Колдонуучуну өчүрүү мүмкүн болгон жок.', userCreateError: 'Колдонуучуну түзүү мүмкүн болгон жок.'}
};
const modalMessage = (key) => modalCopy[uiLanguage][key];
const roleLabel = (user, language = uiLanguage) => user?.can_manage_users === true ? modalCopy[language].admin : modalCopy[language].user;
const reviewRoute = location.pathname === '/review';
const usersRoute = location.pathname === '/users';
const searchRoute = !reviewRoute && !usersRoute;
@@ -708,11 +709,11 @@
$('review-review-nav').classList.toggle('is-active', reviewRoute); $('review-search-nav').classList.toggle('is-active', searchRoute); $('review-users-open').classList.toggle('is-active', usersRoute); $('review-nav').classList.toggle('is-active', reviewRoute); $('search-nav').classList.toggle('is-active', searchRoute);
document.body.classList.toggle('is-review-route', reviewRoute);
document.body.classList.toggle('is-search-route', searchRoute); document.body.classList.toggle('is-users-route', usersRoute);
if (usersRoute) { document.title = 'Пользователи · Акылдаш'; $('route-heading').textContent = 'Пользователи'; $('route-description').textContent = 'Управляйте учётными записями. Доступ к действиям проверяется сервером.'; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#users-page'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.2 · Backend v0.13.1'; }
else if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.2 · Backend v0.13.1'; }
if (usersRoute) { document.title = 'Пользователи · Акылдаш'; $('route-heading').textContent = 'Пользователи'; $('route-description').textContent = 'Управляйте учётными записями. Доступ к действиям проверяется сервером.'; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#users-page'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.3 · Backend v0.13.2'; }
else if (!reviewRoute) { document.title = 'Поиск по правовой базе · Акылдаш'; document.querySelector('.review__header-content').hidden = true; $('tutorial-open').hidden = true; document.querySelector('.review__skip').href = '#public-results'; document.querySelector('footer .review__header-content').textContent = 'Акылдаш · Frontend v0.3.3 · Backend v0.13.2'; }
async function apiFetch(path, options = {}) { const response = await fetch(path, {credentials: 'same-origin', ...options}); if (response.status === 401 && path !== '/auth/login') { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); } return response; }
const showLogin = () => { document.body.classList.add('review__locked'); if (!$('login-dialog').open) $('login-dialog').showModal(); $('login-username').focus(); };
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = user.username; $('review-signed-in-user').textContent = user.username; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = user.role; $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); const canManageUsers = user.role === 'admin'; $('search-users-open').hidden = $('review-users-open').hidden = !canManageUsers; document.body.classList.remove('review__locked'); if (usersRoute) { if (!canManageUsers) { location.replace('/'); return; } document.body.classList.add('is-admin'); const savedLanguage = (() => { try { return localStorage.getItem('akyldash-ui-language') || 'ru'; } catch (_) { return 'ru'; } })(); updateUsersLanguage(savedLanguage); } else if (searchRoute) initSearchPage(); };
const enterWorkspace = (user) => { account = user; $('signed-in-user').textContent = user.username; $('review-signed-in-user').textContent = user.username; $('reviewer').value = user.username; $('profile-username').textContent = user.username; $('profile-role').textContent = roleLabel(user); $('profile-created').textContent = new Date(user.created_at * 1000).toLocaleDateString('ru-RU'); const canManageUsers = user.can_manage_users === true; $('review-nav').hidden = true; $('review-users-open').hidden = !canManageUsers || searchRoute; $('search-users-open').hidden = !canManageUsers || !searchRoute; document.body.classList.remove('review__locked'); if (usersRoute) { if (!canManageUsers) { location.replace('/'); return; } document.body.classList.add('is-admin'); const savedLanguage = (() => { try { return localStorage.getItem('akyldash-ui-language') || 'ru'; } catch (_) { return 'ru'; } })(); updateUsersLanguage(savedLanguage); } else if (searchRoute) initSearchPage(); };
const filterFields = [['statuses', {ru: 'Статус', ky: 'Статус'}, 'status'], ['document_types', {ru: 'Вид документа', ky: 'Документтин түрү'}, 'document_type'], ['authorities', {ru: 'Орган принятия', ky: 'Кабыл алган орган'}, 'authority']];
let filterCatalogs = {};
let filterCatalogsLoaded = false;
@@ -1210,7 +1211,7 @@
$('document-source').textContent = copy('source');
const modal = modalCopy[uiLanguage];
$('account-heading').textContent = modal.account; $('review-nav').textContent = modal.lab; $('search-users-open').textContent = modal.usersHeading; $('search-logout').textContent = modal.logout;
$('profile-role').textContent = account?.role === 'admin' ? modal.admin : account?.role || '';
$('profile-role').textContent = account ? roleLabel(account) : '';
document.querySelectorAll('.review__password-toggle').forEach((button) => { const visible = button.getAttribute('aria-pressed') === 'true'; const label = visible ? modal.hidePassword : modal.showPassword; button.textContent = label; button.setAttribute('aria-label', label); button.title = label; });
$('profile-tab').textContent = ky ? 'Профиль' : 'Профиль'; $('security-tab').textContent = ky ? 'Коопсуздук' : 'Безопасность';
document.querySelectorAll('[data-close]').forEach((button) => { button.setAttribute('aria-label', modal.close); button.title = modal.close; });
@@ -1342,7 +1343,7 @@
document.querySelectorAll('.review__user-table-head [role="columnheader"]').forEach((cell, index) => { cell.textContent = [words.login, words.role, words.state, words.lastLogin, words.actions][index]; });
$('create-user-heading').textContent = words.create; document.querySelectorAll('#create-user-form label').forEach((label, index) => { const input = label.querySelector('input'); label.firstChild.textContent = `${[words.username, words.tempPassword][index]} `; if (input) label.append(input); }); document.querySelector('#create-user-form p').textContent = words.tempHelp; document.querySelector('#create-user-form button[type="submit"]').textContent = words.create;
$('delete-user-heading').textContent = words.deleteHeading; $('delete-user-confirm').textContent = words.delete; document.querySelectorAll('#create-user-dialog [data-close], #delete-user-dialog [data-close]').forEach((button) => button.textContent = words.cancel);
document.querySelector('footer .review__header-content').textContent = `Акылдаш · Frontend v0.3.2 · Backend v0.13.1`;
document.querySelector('footer .review__header-content').textContent = `Акылдаш · Frontend v0.3.3 · Backend v0.13.2`;
if (usersRoute && account) loadUsers();
};
$('users-ui-ru').addEventListener('click', () => updateUsersLanguage('ru'));
@@ -1359,7 +1360,7 @@
const row = document.createElement('li'); row.className = 'review__user-row'; row.setAttribute('role', 'row');
const cell = (label, value, className = '') => { const item = document.createElement('span'); item.setAttribute('role', 'cell'); item.dataset.label = label; item.className = className; item.textContent = value; return item; };
const words = userCopy[usersLanguage];
row.append(cell(words.login, user.username, 'review__user-name'), cell(words.role, user.role || words.role), cell(words.state, user.must_change_password ? words.statusChange : words.active), cell(words.lastLogin, formatUserDate(user.last_login_at)));
row.append(cell(words.login, user.username, 'review__user-name'), cell(words.role, roleLabel(user, usersLanguage)), cell(words.state, user.must_change_password ? words.statusChange : words.active), cell(words.lastLogin, formatUserDate(user.last_login_at)));
const actions = document.createElement('div'); actions.className = 'review__user-actions'; actions.setAttribute('role', 'cell'); actions.dataset.label = 'Действия';
const history = document.createElement('details'); history.className = 'review__user-history';
const historySummary = document.createElement('summary'); historySummary.textContent = words.history;
+28
View File
@@ -59,6 +59,34 @@ class SearchApiTest(unittest.TestCase):
admin_api = get("/admin/users")
admin_api.send_response.assert_called_once_with(401)
def test_account_capability_matches_the_admin_users_api_guard(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
api.auth_store.create_admin("Fixture-only-admin-password-2026")
admin_token, _ = api.auth_store.login("admin", "Fixture-only-admin-password-2026")
api.auth_store.create_user("fixture-user", "Fixture-only-temporary-password-2026")
api.auth_store.change_password("fixture-user", "Fixture-only-temporary-password-2026", "Fixture-only-user-password-2026")
user_token, _ = api.auth_store.login("fixture-user", "Fixture-only-user-password-2026")
request_handler = handler(api)
def get(path, token):
request = request_handler.__new__(request_handler)
request.path = path
request.headers = {"Cookie": f"akyldash_session={token}"}
request.wfile = io.BytesIO()
request.send_response = Mock()
request.send_header = Mock()
request.end_headers = Mock()
request.do_GET()
return request
admin_account = json.loads(get("/auth/me", admin_token).wfile.getvalue())["account"]
user_account = json.loads(get("/auth/me", user_token).wfile.getvalue())["account"]
self.assertEqual((admin_account["role"], admin_account["can_manage_users"]), ("Администратор", True))
self.assertEqual((user_account["role"], user_account["can_manage_users"]), ("Пользователь", False))
get("/admin/users", admin_token).send_response.assert_called_once_with(200)
get("/admin/users", user_token).send_response.assert_called_once_with(403)
def test_search_pagination_filters_and_highlight(self):
with tempfile.TemporaryDirectory() as temporary:
api = self.make_api(Path(temporary))
+23 -11
View File
@@ -5,9 +5,10 @@ const repo = path.resolve(__dirname, '../..');
const artifacts = process.env.VISUAL_REVIEW_ARTIFACTS || path.join(repo, 'docs/product/visual-review');
const html = fs.readFileSync(path.join(repo, 'backend/search/review.html'), 'utf8');
const root = path.join(repo, 'backend/search/static/fonts');
const {docs, reviewDocs, catalogs, passwordChangeAccount, article, articleKy} = require('./fixtures.cjs');
const {docs, reviewDocs, catalogs, passwordChangeAccount, ordinaryAccount, article, articleKy} = require('./fixtures.cjs');
fs.mkdirSync(artifacts, {recursive: true});
const fixtureState = {users:[{username:'fixture-user',role:'user',must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}],passwordChanges:0,userCreates:0,passwordResets:0,userDeletes:0,reviewSaves:0,lastReviewRatings:[]};
let fixtureAccount = passwordChangeAccount;
const fixtureState = {users:[{username:'fixture-user',role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}],passwordChanges:0,userCreates:0,passwordResets:0,userDeletes:0,reviewSaves:0,lastReviewRatings:[]};
let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMetadata = false; let failReviewDocumentB = false; let delayReviewDocumentA = false; let delayNextReviewSearch = false;
(async()=>{
const browser = await chromium.launch({headless:true});
@@ -18,12 +19,12 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
const req=route.request(), url=new URL(req.url());
if (req.isNavigationRequest()) return route.fulfill({status:200,contentType:'text/html; charset=utf-8',body:html});
const p=url.pathname;
if(p==='/auth/me') return route.fulfill({json:{account:passwordChangeAccount}});
if(p==='/auth/me') return route.fulfill({json:{account:fixtureAccount}});
if(p==='/search/filters') return route.fulfill({json:catalogs});
if(p==='/search-reviews' && req.method()==='POST') { fixtureState.reviewSaves++; fixtureState.lastReviewRatings=req.postDataJSON().results.map((result)=>({code:result.code,rating:result.rating})); return route.fulfill({json:{id:42}}); }
if(p==='/auth/change-password' && req.method()==='POST') { fixtureState.passwordChanges++; return route.fulfill({json:{account:passwordChangeAccount}}); }
if(p==='/auth/change-password' && req.method()==='POST') { fixtureState.passwordChanges++; return route.fulfill({json:{account:fixtureAccount}}); }
if(p==='/admin/users' && req.method()==='GET') return route.fulfill({json:{users:fixtureState.users}});
if(p==='/admin/users' && req.method()==='POST') { const body=req.postDataJSON(); fixtureState.userCreates++; fixtureState.users.push({username:body.username,role:'user',must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}); return route.fulfill({status:201,json:{username:body.username}}); }
if(p==='/admin/users' && req.method()==='POST') { const body=req.postDataJSON(); fixtureState.userCreates++; fixtureState.users.push({username:body.username,role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null}); return route.fulfill({status:201,json:{username:body.username}}); }
const resetMatch=p.match(/^\/admin\/users\/([^/]+)\/reset-password$/);
if(resetMatch && req.method()==='POST') { fixtureState.passwordResets++; const username=decodeURIComponent(resetMatch[1]); const user=fixtureState.users.find((candidate)=>candidate.username===username); if(user) user.must_change_password=true; return route.fulfill({json:{username}}); }
const deleteMatch=p.match(/^\/admin\/users\/([^/]+)$/);
@@ -65,7 +66,7 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
console.log('subnavBoxes',await page.evaluate(()=>[...document.querySelector('.search__subnav').children].map(el=>({className:el.className||el.id,text:el.innerText,rect:(()=>{const r=el.getBoundingClientRect();return {left:r.left,right:r.right,width:r.width}})()})))); const navGeometry=await page.evaluate(()=>{const box=s=>{const r=document.querySelector(s).getBoundingClientRect();return {left:r.left,right:r.right}};const language=document.querySelector('.search__filter-language');const select=language.querySelector('select');const r=language.getBoundingClientRect();const filterHeader=document.querySelector('.search__filters .review__panel-heading').getBoundingClientRect(),filterHeading=document.querySelector('#filters-heading').getBoundingClientRect(),filterReset=document.querySelector('#clear-filters').getBoundingClientRect();return {search:box('#search-nav'),sources:box('#sources-open'),lab:box('#search-review-nav'),labNavVisible:!document.querySelector('#search-review-nav').hidden,filterHeadingSameRow:filterHeading.top<filterReset.bottom&&filterHeading.bottom>filterReset.top,filterLanguageGap:Math.round(language.getBoundingClientRect().top-filterHeader.bottom),splitterWidth:document.querySelector('#search-splitter').getBoundingClientRect().width,filterHeadingSize:parseFloat(getComputedStyle(document.querySelector('#filters-heading')).fontSize),resultsHeadingSize:parseFloat(getComputedStyle(document.querySelector('#search-results-heading')).fontSize),languageInFilters:document.querySelector('#search-filters').contains(language),languageWidth:r.width,languageSelectWidth:select.getBoundingClientRect().width,searchTextAlign:getComputedStyle(document.querySelector('#search-nav')).justifyContent,status:box('#active-filters .search__chip'),account:box('#search-account-open'),accountContentFits:(()=>{const e=document.querySelector('#search-account-open');return e.scrollWidth<=e.clientWidth})()}}); console.log('navGeometry',navGeometry); const documentTitleStyle=await page.locator('#public-document-heading').evaluate(el=>({fontSize:parseFloat(getComputedStyle(el).fontSize),lineHeight:parseFloat(getComputedStyle(el).lineHeight),fontFamily:getComputedStyle(el).fontFamily})); const resultHighlight=await page.locator('.search__result--selected .search__result-snippet mark').first().textContent(); const openedCount=await page.locator('.search__opened').count(); await page.locator('#public-document-panel').evaluate(el=>el.scrollTop=0); await page.evaluate(()=>document.activeElement.blur()); console.log('desktopBoxes', await page.evaluate(()=>Object.fromEntries(['.search__chrome','.search__layout','.search__footer','.search__document'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); console.log('docElements',await page.evaluate(()=>Object.fromEntries(['.search__document-head','#public-document-heading','#public-document-meta','.search__document-toolbar','.search__find','#document-note','#public-document-body h2'].map(s=>[s,(()=>{const r=document.querySelector(s).getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}})()])))); await page.screenshot({path:path.join(artifacts,'desktop-1280x800.png'),fullPage:false});
const splitterDefault=await page.evaluate(()=>{const r=s=>document.querySelector(s).getBoundingClientRect();return {filters:r('#search-filters').width,results:r('.search__results').width,document:r('#public-document-panel').width};}); const splitter=page.locator('#search-splitter'); const splitterStart=Number(await splitter.getAttribute('aria-valuenow')); await splitter.focus(); await page.keyboard.press('ArrowRight'); const splitterKeyboard=Number(await splitter.getAttribute('aria-valuenow')); const keyboardMin=Number(await splitter.getAttribute('aria-valuemin')),keyboardMax=Number(await splitter.getAttribute('aria-valuemax')); await page.keyboard.press('Home'); const splitterHome=Number(await splitter.getAttribute('aria-valuenow')); await page.keyboard.press('End'); const splitterEnd=Number(await splitter.getAttribute('aria-valuenow')); const drag=async x=>{const box=await splitter.boundingBox();await page.mouse.move(box.x+6,box.y+box.height/2);await page.mouse.down();await page.mouse.move(x,box.y+box.height/2,{steps:5});await page.mouse.up();}; await drag(0); const splitterLeft=await page.evaluate(()=>({value:Number(document.querySelector('#search-splitter').getAttribute('aria-valuenow')),filters:document.querySelector('#search-filters').getBoundingClientRect().width,results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); await drag(1280); const splitterRight=await page.evaluate(()=>({value:Number(document.querySelector('#search-splitter').getAttribute('aria-valuenow')),filters:document.querySelector('#search-filters').getBoundingClientRect().width,results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); const splitterPointer=splitterLeft.value; const splitterStored=await page.evaluate(()=>localStorage.getItem('akyldash-search-results-share'));
delayNextPublicMetadata=true; const delayedPublicRequest=page.waitForRequest(request=>new URL(request.url()).pathname==='/documents/doc-1/editions'); await page.locator('.search__result-title').first().click(); await delayedPublicRequest; await page.locator('.search__result-title').nth(2).click(); await page.waitForFunction(()=>document.querySelector('#document-facts').textContent==='Fixture editions error'); await page.waitForTimeout(300); const stalePublicSelection=await page.evaluate(()=>({selected:document.querySelector('.search__result--selected').dataset.code,markers:document.querySelectorAll('.search__opened').length})); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); const markerAfterReload=await page.locator('.search__opened').count();
await page.evaluate(()=>{localStorage.setItem('akyldash-search-results-share','36');}); await page.reload(); await page.locator('#search-results-heading').waitFor(); await page.locator('.search__result-title').first().waitFor(); const splitterRestored=await page.locator('#search-splitter').evaluate(el=>({value:Number(el.getAttribute('aria-valuenow')),min:Number(el.getAttribute('aria-valuemin')),max:Number(el.getAttribute('aria-valuemax'))})); await page.evaluate(()=>localStorage.setItem('akyldash-search-results-share','55')); await page.reload(); await page.locator('.search__result-title').first().waitFor(); await page.waitForFunction(()=>document.querySelector('#search-splitter')?.getAttribute('aria-valuenow')!=='36'); const splitterStoredMax=await page.locator('#search-splitter').evaluate(el=>({value:Number(el.getAttribute('aria-valuenow')),max:Number(el.getAttribute('aria-valuemax'))})); await page.setViewportSize({width:1440,height:800}); await page.evaluate(()=>window.dispatchEvent(new Event('resize'))); const splitterResize=await page.evaluate(()=>({results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); await page.setViewportSize({width:1280,height:800}); await page.evaluate(()=>window.dispatchEvent(new Event('resize'))); await page.evaluate(()=>localStorage.setItem('akyldash-search-results-share','25')); await page.reload(); await page.locator('.search__result-title').first().waitFor(); await page.waitForFunction(()=>document.querySelector('#search-splitter')?.getAttribute('aria-valuenow')!=='36'); const splitterStoredMin=await page.locator('#search-splitter').evaluate(el=>({value:Number(el.getAttribute('aria-valuenow')),min:Number(el.getAttribute('aria-valuemin'))}));
await page.evaluate(()=>{localStorage.setItem('akyldash-search-results-share','36');}); await page.reload(); await page.locator('#search-results-heading').waitFor(); await page.locator('.search__result-title').first().waitFor(); const splitterRestored=await page.locator('#search-splitter').evaluate(el=>({value:Number(el.getAttribute('aria-valuenow')),min:Number(el.getAttribute('aria-valuemin')),max:Number(el.getAttribute('aria-valuemax'))})); await page.evaluate(()=>localStorage.setItem('akyldash-search-results-share','55')); await page.reload(); await page.locator('.search__result-title').first().waitFor(); await page.waitForFunction(()=>document.querySelector('#search-splitter')?.getAttribute('aria-valuenow')!=='36'); const splitterStoredMax=await page.locator('#search-splitter').evaluate(el=>({value:Number(el.getAttribute('aria-valuenow')),max:Number(el.getAttribute('aria-valuemax'))})); await page.setViewportSize({width:1440,height:800}); await page.evaluate(()=>window.dispatchEvent(new Event('resize'))); await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); const splitterResize=await page.evaluate(()=>({results:document.querySelector('.search__results').getBoundingClientRect().width,document:document.querySelector('#public-document-panel').getBoundingClientRect().width})); await page.setViewportSize({width:1280,height:800}); await page.evaluate(()=>window.dispatchEvent(new Event('resize'))); await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); await page.evaluate(()=>localStorage.setItem('akyldash-search-results-share','25')); await page.reload(); await page.locator('.search__result-title').first().waitFor(); await page.waitForFunction(()=>document.querySelector('#search-splitter')?.getAttribute('aria-valuenow')!=='36'); const splitterStoredMin=await page.locator('#search-splitter').evaluate(el=>({value:Number(el.getAttribute('aria-valuenow')),min:Number(el.getAttribute('aria-valuemin'))}));
const chipsBefore=await page.locator('#active-filters .search__chip').count(); await page.locator('input[name="filter-document_type"][value="code"]').check(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('document_type')==='code'); const chipsWithTwo=await page.locator('#active-filters .search__chip').count(); await page.locator('#active-filters button').first().click(); await page.waitForFunction(()=>new URL(location.href).searchParams.get('status')===null); const preservedFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); await page.locator('#active-filters button').first().click(); const urlFilters=await page.evaluate(()=>({status:new URL(location.href).searchParams.get('status'),documentType:new URL(location.href).searchParams.get('document_type')})); const firstFindCount=await page.locator('#document-find-count').textContent(); const findClearedAfterFilterChange=!(await page.locator('#document-find-next').isVisible()); await page.locator('input[name="filter-status"][value="active"]').check(); await page.locator('.search__result-title').first().click(); await page.locator('#public-document-body h2').first().waitFor(); await page.locator('#document-find').fill('налог на прибыль'); await page.waitForFunction(()=>!document.querySelector('#document-find-next').disabled); await page.locator('#document-find-next').click(); const nextFindCount=await page.locator('#document-find-count').textContent(); await page.locator('#ui-ky').click(); const documentLanguage=await page.locator('#document-language').inputValue(); const kyrgyzUi=await page.evaluate(()=>({htmlLang:document.documentElement.lang,queryLabel:document.querySelector('label[for=public-query]').textContent,filterHeading:document.querySelectorAll('#filter-fields h3')[1].textContent,statusHeading:document.querySelectorAll('#filter-fields h3')[0].textContent,authority:document.querySelector('#filter-authority option').textContent,activeStatus:document.querySelector('#active-filters .search__chip').textContent.trim(),queryPlaceholder:document.querySelector('#public-query').placeholder,resultCount:document.querySelector('#result-count').textContent,findCount:document.querySelector('#document-find-count').textContent,opened:document.querySelector('.search__opened').textContent,statusValues:[...document.querySelectorAll('input[name="filter-status"]')].map(input=>{const spans=input.parentElement.querySelectorAll('span');return `${spans[0].textContent} (${spans[1].textContent})`}),documentTypes:[...document.querySelectorAll('input[name="filter-document_type"]')].map(input=>{const spans=input.parentElement.querySelectorAll('span');return `${spans[0].textContent} (${spans[1].textContent})`})})); await page.locator('#ui-ru').click(); await page.locator('#document-expand').click(); const expanded=await page.locator('#search-layout').evaluate(e=>e.classList.contains('is-expanded')); await page.keyboard.press('Escape'); const expandedEscapeClosed=await page.locator('#search-layout').evaluate(e=>!e.classList.contains('is-expanded')); const expandedFocusRestored=await page.evaluate(()=>document.activeElement.id); await page.locator('#search-account-open').click(); const accountActions=await page.locator('#account-dialog').evaluate(d=>{const close=d.querySelector('.review__dialog-heading button'),profile=d.querySelector('dl');return {open:d.open,heading:d.querySelector('#account-heading').textContent,reviewVisible:!d.querySelector('#review-nav').hidden,reviewHref:d.querySelector('#review-nav').getAttribute('href'),usersVisible:!d.querySelector('#search-users-open').hidden,usersHref:d.querySelector('#search-users-open').getAttribute('href'),usersLabel:d.querySelector('#search-users-open').textContent,logoutVisible:!d.querySelector('#search-logout').hidden,closeIcon:Boolean(close.querySelector('svg')),closeText:close.textContent.trim(),closeLabel:close.getAttribute('aria-label'),profileGrid:getComputedStyle(profile).display,profileRows:profile.children.length,headingHeight:d.querySelector('.review__dialog-heading').getBoundingClientRect().height,headingFontSize:parseFloat(getComputedStyle(d.querySelector('.review__dialog-heading h2')).fontSize),headingFontFamily:getComputedStyle(d.querySelector('.review__dialog-heading h2')).fontFamily,dataBeforeActions:Boolean(profile.compareDocumentPosition(d.querySelector('.search__account-actions'))&Node.DOCUMENT_POSITION_FOLLOWING),dialogFits:d.scrollWidth<=d.clientWidth};}); const accountGeometry=await page.locator('#account-dialog').evaluate(d=>{const actions=d.querySelector('.search__account-actions'),logout=d.querySelector('#search-logout'),first=actions.querySelector('a').getBoundingClientRect(),out=logout.getBoundingClientRect();return {width:d.getBoundingClientRect().width,logoutSeparate:out.top>=first.bottom,logoutMuted:getComputedStyle(logout).backgroundColor==='rgba(0, 0Line truncated
const fonts = await page.evaluate(async()=>({sans:document.fonts.check('400 16px "Noto Sans"'),serif:document.fonts.check('600 24px "Noto Serif"'),kyrgyzSans:(await document.fonts.load('400 16px "Noto Sans"','ӨөҮүҢң')).length>0,kyrgyzSerif:(await document.fonts.load('600 24px "Noto Serif"','ӨөҮүҢң')).length>0,ids:[...document.querySelectorAll('[id]')].map(x=>x.id).filter((id,i,a)=>a.indexOf(id)!==i)}));
const widths={}; for(const width of [1440,1280,1024,1713]){await page.setViewportSize({width,height:900});widths[width]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth,layout:document.querySelector('#search-layout').getBoundingClientRect().width,columns:getComputedStyle(document.querySelector('#search-layout')).gridTemplateColumns.split(' ').map(parseFloat)}));await page.screenshot({path:path.join(artifacts,`desktop-${width}px.png`)});} await page.setViewportSize({width:320,height:800}); widths[320]=await page.evaluate(()=>({scrollWidth:document.documentElement.scrollWidth,viewport:innerWidth}));
@@ -248,7 +249,7 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
["expandedFocusRestored!=='document-expand'", expandedFocusRestored!=='document-expand'],
["!accountActions.open", !accountActions.open],
["!accountActions.heading", !accountActions.heading],
["!accountActions.reviewVisible", !accountActions.reviewVisible],
["accountActions.reviewVisible", accountActions.reviewVisible],
["accountActions.reviewHref!=='/review'", accountActions.reviewHref!=='/review'],
["!accountActions.usersVisible", !accountActions.usersVisible],
["accountActions.usersHref!=='/users'", accountActions.usersHref!=='/users'],
@@ -352,15 +353,15 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
["!reviewFlow.reviewScroll.footerAfterSave", !reviewFlow.reviewScroll.footerAfterSave],
["!reviewFlow.reviewSaved.includes('Оценка сохранена')", !reviewFlow.reviewSaved.includes('Оценка сохранена')]
].filter(([, failed]) => failed).map(([name]) => name); console.log('labLayout',JSON.stringify(labLayout)); console.log('reviewFlow',JSON.stringify(reviewFlow)); console.log('failedCoreChecks', JSON.stringify(failedCoreChecks)); if (failedCoreChecks.length) process.exit(1);
const usersPage=page; await usersPage.setViewportSize({width:320,height:760}); await usersPage.goto('https://fixture.invalid/users'); await usersPage.locator('#user-list .review__user-row').waitFor(); const usersRoute=await usersPage.evaluate(()=>({visibleFooters:[...document.querySelectorAll('body > footer')].filter(el=>getComputedStyle(el).display!=='none'&&el.getBoundingClientRect().height>0).length,visible:!document.querySelector('#users-page').hidden,admin:document.body.classList.contains('is-admin'),accountOpen:document.querySelector('#account-dialog').open,headerVisible:getComputedStyle(document.querySelector('body > header.review__header')).display!=='none',footerAfterMain:Boolean(document.querySelector('#users-page').compareDocumentPosition(document.querySelector('body > footer.review__header')) & Node.DOCUMENT_POSITION_FOLLOWING),reviewActionsHidden:getComputedStyle(document.querySelector('body > .review__actions')).display==='none',footerAfterUsers:document.querySelector('body > footer.review__header').getBoundingClientRect().top>=document.querySelector('#user-list').getBoundingClientRect().bottom,overflow:document.documentElement.scrollWidth>innerWidth})); await usersPage.locator('#users-ui-ky').click(); const usersKy=await usersPage.evaluate(()=>({lang:document.documentElement.lang,heading:document.querySelector('#users-heading').textContent})); console.log('usersRouteCheck',JSON.stringify({usersRoute,usersKy,errors})); if(usersRoute.visibleFooters!==1||!usersRoute.visible||!usersRoute.admin||usersRoute.accountOpen||!usersRoute.headerVisible||!usersRoute.footerAfterMain||!usersRoute.reviewActionsHidden||!usersRoute.footerAfterUsers||usersRoute.overflow||errors.length||usersKy.lang!=='ky'||usersKy.heading!=='Колдонуучулар') process.exit(1);
const usersPage=page; await usersPage.setViewportSize({width:320,height:760}); await usersPage.goto('https://fixture.invalid/users'); await usersPage.locator('#user-list .review__user-row').waitFor(); const usersRoute=await usersPage.evaluate(()=>({visibleFooters:[...document.querySelectorAll('body > footer')].filter(el=>getComputedStyle(el).display!=='none'&&el.getBoundingClientRect().height>0).length,visible:!document.querySelector('#users-page').hidden,admin:document.body.classList.contains('is-admin'),accountOpen:document.querySelector('#account-dialog').open,headerVisible:getComputedStyle(document.querySelector('body > header.review__header')).display!=='none',footerAfterMain:Boolean(document.querySelector('#users-page').compareDocumentPosition(document.querySelector('body > footer.review__header')) & Node.DOCUMENT_POSITION_FOLLOWING),reviewActionsHidden:getComputedStyle(document.querySelector('body > .review__actions')).display==='none',footerAfterUsers:document.querySelector('body > footer.review__header').getBoundingClientRect().top>=document.querySelector('#user-list').getBoundingClientRect().bottom,overflow:document.documentElement.scrollWidth>innerWidth})); await usersPage.locator('#users-ui-ky').click(); await usersPage.waitForFunction(()=>[...document.querySelectorAll('#user-list .review__user-row')].some(row=>row.children[1]?.textContent.trim()==='Колдонуучу')); const usersKy=await usersPage.evaluate(()=>({lang:document.documentElement.lang,heading:document.querySelector('#users-heading').textContent})); const localizedRoleCells=await usersPage.locator('#user-list .review__user-row').evaluateAll(rows=>rows.map(row=>row.children[1].textContent)); const usersNavContract=await usersPage.evaluate(()=>({headerUsers:!document.querySelector('#review-users-open').hidden,dialogUsers:!document.querySelector('#search-users-open').hidden,dialogLab:!document.querySelector('#review-nav').hidden,headerLab:!document.querySelector('#review-review-nav').hidden})); console.log('usersRouteCheck',JSON.stringify({usersRoute,usersKy,usersNavContract,errors})); if(usersRoute.visibleFooters!==1||!usersRoute.visible||!usersRoute.admin||usersRoute.accountOpen||!usersRoute.headerVisible||!usersRoute.footerAfterMain||!usersRoute.reviewActionsHidden||!usersRoute.footerAfterUsers||usersRoute.overflow||errors.length||usersKy.lang!=='ky'||usersKy.heading!=='Колдонуучулар'||!localizedRoleCells.includes('Колдонуучу')||!usersNavContract.headerUsers||usersNavContract.dialogUsers||usersNavContract.dialogLab||!usersNavContract.headerLab) process.exit(1);
const reflowWidth=640; await usersPage.setViewportSize({width:reflowWidth,height:450});
await usersPage.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3'); await usersPage.locator('.search__result-title').first().waitFor();
const search640=await usersPage.evaluate(()=>{const button=document.querySelector('#search-account-open'),r=button.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountVisible:r.width>0&&getComputedStyle(button).visibility!=='hidden',accountWithinViewport:r.left>=0&&r.right<=innerWidth};});
await usersPage.locator('#search-account-open').click();
const accountDialog640=await usersPage.evaluate(()=>{const dialog=document.querySelector('#account-dialog'),r=dialog.getBoundingClientRect(),actions=[...dialog.querySelectorAll('#review-nav,#search-users-open,#search-logout')].filter(el=>!el.hidden);return {open:dialog.open,withinViewport:r.left>=0&&r.right<=innerWidth,actionsVisible:actions.length>0&&actions.every(el=>{const a=el.getBoundingClientRect();return a.width>0&&a.left>=0&&a.right<=innerWidth;})};});
await usersPage.keyboard.press('Escape');
await usersPage.goto('https://fixture.invalid/review'); await usersPage.locator('#review').waitFor();
const review640=await usersPage.evaluate(()=>{const account=document.querySelector('.review__nav-account'),logout=document.querySelector('#review-logout'),r=account.getBoundingClientRect(),a=logout.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountWithinViewport:r.left>=0&&r.right<=innerWidth,logoutVisible:a.width>0&&getComputedStyle(logout).visibility!=='hidden',logoutWithinViewport:a.left>=0&&a.right<=innerWidth};});
await usersPage.goto('https://fixture.invalid/review'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='admin'&&!document.body.classList.contains('review__locked'));
const review640=await usersPage.evaluate(()=>{const account=document.querySelector('.review__nav-account'),logout=document.querySelector('#review-logout'),r=account.getBoundingClientRect(),a=logout.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountWithinViewport:r.left>=0&&r.right<=innerWidth,logoutVisible:a.width>0&&getComputedStyle(logout).visibility!=='hidden',logoutWithinViewport:a.left>=0&&a.right<=innerWidth};}); const reviewNavContract=await usersPage.evaluate(()=>({headerUsers:!document.querySelector('#review-users-open').hidden,dialogUsers:!document.querySelector('#search-users-open').hidden,dialogLab:!document.querySelector('#review-nav').hidden,headerLab:!document.querySelector('#review-review-nav').hidden})); if(!reviewNavContract.headerUsers||reviewNavContract.dialogUsers||reviewNavContract.dialogLab||!reviewNavContract.headerLab) throw new Error(`Review account actions duplicate header navigation: ${JSON.stringify(reviewNavContract)}`);
await usersPage.goto('https://fixture.invalid/users'); await usersPage.locator('#user-list .review__user-row').waitFor();
const users640=await usersPage.evaluate(()=>{const account=document.querySelector('.review__nav-account'),logout=document.querySelector('#review-logout'),create=document.querySelector('#create-user-open'),r=account.getBoundingClientRect(),a=logout.getBoundingClientRect(),c=create.getBoundingClientRect();return {documentWidth:document.documentElement.scrollWidth,accountWithinViewport:r.left>=0&&r.right<=innerWidth,logoutVisible:a.width>0&&getComputedStyle(logout).visibility!=='hidden',logoutWithinViewport:a.left>=0&&a.right<=innerWidth,createVisible:c.width>0&&getComputedStyle(create).visibility!=='hidden',createWithinViewport:c.left>=0&&c.right<=innerWidth};});
const reflow640={search640,accountDialog640,review640,users640}; console.log('reflow640',JSON.stringify(reflow640));
@@ -390,7 +391,7 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
}
}
await usersPage.locator('#ui-ru').click();
await usersPage.goto('https://fixture.invalid/review'); await usersPage.locator('#review').waitFor();
await usersPage.goto('https://fixture.invalid/review'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='admin'&&!document.body.classList.contains('review__locked'));
layoutRegressions.reviewNav=await usersPage.evaluate(()=>{
const read=id=>{const el=document.getElementById(id),s=getComputedStyle(el),r=el.getBoundingClientRect();return {tag:el.tagName,href:el.getAttribute('href'),active:el.classList.contains('is-active'),height:r.height,display:s.display,alignItems:s.alignItems,color:s.color,background:s.backgroundColor,borderColor:s.borderTopColor};};
return {search:read('review-search-nav'),lab:read('review-review-nav')};
@@ -402,6 +403,10 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
const read=id=>{const el=document.getElementById(id),s=getComputedStyle(el),r=el.getBoundingClientRect();return {tag:el.tagName,href:el.getAttribute('href'),active:el.classList.contains('is-active'),height:r.height,display:s.display,alignItems:s.alignItems,color:s.color,background:s.backgroundColor,borderColor:s.borderTopColor};};
return {search:read('search-nav'),lab:read('search-review-nav')};
});
await usersPage.locator('#search-account-open').click();
const publicAccountNav=await usersPage.evaluate(()=>({headerUsers:!document.querySelector('#review-users-open').hidden,dialogUsers:!document.querySelector('#search-users-open').hidden,dialogLab:!document.querySelector('#review-nav').hidden,headerLab:!document.querySelector('#search-review-nav').hidden}));
await usersPage.keyboard.press('Escape');
if(publicAccountNav.headerUsers||!publicAccountNav.dialogUsers||publicAccountNav.dialogLab||!publicAccountNav.headerLab) throw new Error(`Public account actions do not match visible header links: ${JSON.stringify(publicAccountNav)}`);
await usersPage.setViewportSize({width:390,height:844});
await usersPage.goto('https://fixture.invalid/?q=%D0%BD%D0%B0%D0%BB%D0%BE%D0%B3+%D0%BD%D0%B0+%D0%BF%D1%80%D0%B8%D0%B1%D1%8B%D0%BB%D1%8C');
await usersPage.locator('.search__result-title').first().waitFor(); await usersPage.locator('.search__result-title').first().click(); await usersPage.locator('#public-document-heading').waitFor();
@@ -424,6 +429,13 @@ let failNextEdition = false; let delayNextEdition = false; let delayNextPublicMe
const navFailures=navQuiet(layoutRegressions.reviewNav.search)||navPrimary(layoutRegressions.reviewNav.lab)||navQuiet(layoutRegressions.publicNav.lab)||navPrimary(layoutRegressions.publicNav.search)||layoutRegressions.reviewNav.focused.id!=='review-search-nav'||layoutRegressions.reviewNav.focused.outline!=='3px';
const mobileFailures=Object.entries(layoutRegressions.mobile).filter(([,value])=>value.overlaps||value.back.tag!=='BUTTON'||value.back.name!=='К результатам'||value.back.height<44||value.textRects.some(rect=>rect.left<value.breadcrumb.left-1||rect.right>value.breadcrumb.right+1)).map(([viewport])=>viewport);
if(brandFailures.length||navFailures||mobileFailures.length) throw new Error(JSON.stringify({brandFailures,navFailures,mobileFailures,layoutRegressions}));
fixtureAccount=ordinaryAccount;
await usersPage.goto('https://fixture.invalid/review'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='fixture-user'&&!document.body.classList.contains('review__locked')); await usersPage.locator('#review').waitFor();
const ordinaryNav=await usersPage.evaluate(()=>({role:document.querySelector('#profile-role').textContent,headerUsers:!document.querySelector('#review-users-open').hidden,accountUsers:!document.querySelector('#search-users-open').hidden,adminPage:document.body.classList.contains('is-admin')}));
await usersPage.goto('https://fixture.invalid/users'); await usersPage.waitForURL('https://fixture.invalid/'); await usersPage.waitForFunction(()=>document.querySelector('#profile-username').textContent==='fixture-user'&&!document.body.classList.contains('review__locked')); await usersPage.locator('#search-results-heading').waitFor();
const ordinaryRoute=await usersPage.evaluate(()=>({path:location.pathname,usersHidden:document.querySelector('#users-page').hidden,adminPage:document.body.classList.contains('is-admin')}));
console.log('ordinaryAccountCapability',JSON.stringify({ordinaryNav,ordinaryRoute}));
if(ordinaryNav.role!=='Пользователь'||ordinaryNav.headerUsers||ordinaryNav.accountUsers||ordinaryNav.adminPage||ordinaryRoute.path!=='/'||!ordinaryRoute.usersHidden||ordinaryRoute.adminPage) throw new Error('Ordinary account must not receive user-management navigation or the users screen.');
await browser.close();
})();
+3 -2
View File
@@ -22,8 +22,9 @@ const visualReviewArticles = {
'review-b':'<h2>Расчёт условной налоговой базы</h2><p>Вымышленный текст для проверки панели чтения и элементов оценки.</p>'
};
const catalogs = {document_types:[{code:'code',labels:{ru:'Кодекс',ky:'Кодекс'},count:12},{code:'law',labels:{ru:'Закон',ky:'Мыйзам'},count:10},{code:'decree',labels:{ru:'Постановление',ky:'Токтом'},count:8}],statuses:[{code:'active',labels:{ru:'Действует',ky:'Күчүндө'},count:24},{code:'repealed',labels:{ru:'Утратил силу',ky:'Күчүн жоготту'},count:5}],authorities:[{code:'ministries_and_committees',labels:{ru:'Министерства и комитеты',ky:'Министрликтер'},count:5}],legal_forces:[]};
const passwordChangeAccount = {username:'fixture-admin',role:'admin',created_at:1780000000,must_change_password:false};
const passwordChangeAccount = {username:'admin',role:'Администратор',can_manage_users:true,created_at:1780000000,must_change_password:false};
const ordinaryAccount = {username:'fixture-user',role:'Пользователь',can_manage_users:false,created_at:1780000000,must_change_password:false};
const article = '<h2>Налог на прибыль</h2><p><a href="../../tmp/old.html">Old local reference</a> <a href="http://cbd.minjust.gov.kg/old">Legacy HTTP reference</a> <a href="https://cbd.minjust.gov.kg/doc-1" ping="https://evil.invalid/ping">Official reference</a> <a href="https://127.0.0.1/private">Private address reference</a><img src="../../tmp/old.png" srcset="https://evil.invalid/pixel 1x, https://192.168.1.10/pixel 2x"><img alt="Private address image" src="https://192.168.1.10/pixel"></p><p><b><span>Раздел I. Общие положения</span></b></p><p><b><span>Глава 1. Основные положения</span></b></p><p><b><span>Статья 1. Основные понятия</span></b></p><p>Обычное упоминание статьи 9 не является заголовком.</p><p id="duplicate-heading"><b><span>Статья 1. Повторный номер</span></b></p><p id="duplicate-heading"><b><span>Раздел II. Налоговая отчётность</span></b></p><p>Текст документа приведён для примера оформления. Здесь будут отображаться положения выбранной редакции. По запросу налог на прибыль читатель найдёт нужный фрагмент.</p><h2>Налоговая отчётность</h2><p>В этом разделе отображается текст выбранной редакции. Поиск внутри документа помогает перейти к нужному фрагменту.</p><p>Далее приводятся положения о налоге на прибыль.</p><p>Упоминание запроса налог на прибыль в следующем разделе.</p>';
const articleKy = '<h2>Киреше салыгы</h2><p><b><span>БӨЛҮМ 1. Жалпы жоболор</span></b></p><p><b><span>Глава 1. Негизги жоболор</span></b></p><p><b><span>Берене 1. Негизги түшүнүктөр</span></b></p><p>Текстте берене 9 жөнүндө кадимки эскерүү бар.</p>';
module.exports = {docs, reviewDocs, visualDocs, visualReviewDocs, visualArticle, visualReviewArticles, catalogs, passwordChangeAccount, article, articleKy};
module.exports = {docs, reviewDocs, visualDocs, visualReviewDocs, visualArticle, visualReviewArticles, catalogs, passwordChangeAccount, ordinaryAccount, article, articleKy};
@@ -9,7 +9,7 @@ const htmlPath = path.join(repo, 'backend/search/review.html');
const staticRoot = path.join(repo, 'backend/search/static');
const html = fs.readFileSync(htmlPath, 'utf8');
const port = Number(process.argv[2] || 4173);
let users = [{username:'fixture-user',role:'user',must_change_password:false,last_login_at:null,first_login_at:1780000000,password_reset_at:null}];
let users = [{username:'admin',role:'Администратор',can_manage_users:true,must_change_password:false,last_login_at:null,first_login_at:1780000000,password_reset_at:null},{username:'fixture-user',role:'Пользователь',can_manage_users:false,must_change_password:false,last_login_at:null,first_login_at:1780000000,password_reset_at:null}];
const warning = `<div style="position:relative;z-index:1000;padding:5px 12px;background:#38262a;color:#fff;font:600 12px/1.4 sans-serif;text-align:center;letter-spacing:.02em">ЛОКАЛЬНЫЙ PREVIEW · только синтетические данные · внешние ссылки отключены</div><script>document.addEventListener('click',event=>{const link=event.target.closest('a[href]');if(!link)return;const target=new URL(link.href,location.href);if(target.origin!==location.origin){event.preventDefault();event.stopImmediatePropagation();}},true);</script>`;
const previewHtml = html.replace('<body>', `<body>${warning}`);
@@ -60,7 +60,7 @@ const server = http.createServer(async (request, response) => {
if (method === 'GET' && url.pathname === '/admin/users') return json(response, 200, {users});
if (method === 'POST' && url.pathname === '/admin/users') {
const body = await readBody(request); const username = String(body.username || 'fixture-created');
users.push({username,role:'user',must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null});
users.push({username,role:'Пользователь',can_manage_users:false,must_change_password:true,last_login_at:null,first_login_at:1780000000,password_reset_at:null});
return json(response, 201, {username});
}
if (method === 'POST' && /^\/admin\/users\/[^/]+\/reset-password$/.test(url.pathname)) return json(response, 200, {ok:true});