diff --git a/.codex-session.md b/.codex-session.md new file mode 100644 index 0000000..945ab62 --- /dev/null +++ b/.codex-session.md @@ -0,0 +1,20 @@ +## Сделано + +- Ветка `feature/first-login-guide`, версия приложения/frontend/backend `1.3.1`. +- Шесть шагов подсказок после первой смены пароля, повторное открытие из меню, серверное сохранение завершения в истории действий без миграции БД. +- Полная инструкция и завершение без JavaScript; при ошибке сохранения можно продолжить работу без отметки о завершении. +- Исправлено неблокирующее замечание независимого ревью PR #71: только три новых правила CSS переведены с CRLF на LF, содержимое правил сохранено. +- После исправления: `pytest -q` — 412 passed, 11 skipped; `git diff --check main` — passed. Предыдущая запись о passed для полного diff коммита `5128fac` была неточной: ревью подтвердило три trailing whitespace, теперь они устранены. +- До этой правки: браузерный сценарий `tests/browser_users.py` — passed (1440/320 px, клавиатура, ошибка сохранения, все шаги, без JavaScript); синтаксис четырёх JS-файлов и двух shell-скриптов CI — passed. Браузерный сценарий повторно не запускался: поведение CSS не менялось. + +## Остановились на + +- Открыт PR #71: https://git.vakanaut.ru/admin/miem_workers/pulls/71. Ревью коммита `5128fac` одобрено с одним неблокирующим замечанием; пользователь поручил исправить его. +- Правка готова к коммиту и push в ту же ветку/PR. Доступ к Gitea восстановился; локальный `main` обновлён, основа — `378d7ee`. +- PostgreSQL-проверки пропущены без отдельной тестовой БД. Проверки выполнялись на синтетических SQLite-данных. +- Независимое ревью должно выполняться в отдельной сессии после публикации PR, согласно инструкциям пользователя. + +## Следующий шаг + +- Сохранить правку коммитом, отправить `git push -u origin feature/first-login-guide` в существующий PR #71. +- После push остановиться и ждать независимого ревью и дальнейших указаний пользователя. Не выполнять ревью из авторской сессии и не сливать PR. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5ba168d..4fbb0b7 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: - run: node --check app/static/admin.js - run: node --check app/static/council.js - run: node --check app/static/users.js + - run: node --check app/static/guide.js - run: sh -n infra/postgres-backup.sh - run: sh -n infra/install-postgres-backup-cron.sh diff --git a/README.md b/README.md index 06af733..44e05ca 100644 --- a/README.md +++ b/README.md @@ -285,3 +285,9 @@ python scripts/prepare_council_articles.py app/data/council_articles.csv файлы с LF. После замены файла перезапустите процессы API, чтобы сбросить их кэш. Обновления публикаций из БД продолжают отображаться без перезапуска. + +### Подсказки первого входа + +После первой успешной смены пароля автоматически открываются шесть шагов: навигация, поиск сотрудников и профиль, фильтры диссовета, показатели сотрудника, строка публикации, редактирование и XLSX. Подсказки можно закрыть или пропустить и снова открыть через «Как пользоваться» в меню. Без JavaScript доступна полная инструкция `/admin/guide` и обычная форма завершения. + +Состояние хранится для учётной записи в существующей истории действий (`guide_start` / `guide_finish`), без миграции схемы. Закрытие сохраняется на сервере; повторная смена или сброс пароля не запускают обучение заново. Для существующих пользователей, уже сменивших пароль, автоматический показ не включается. POST `/admin/guide/dismiss` требует входа, смены временного пароля и CSRF. При ошибке сохранения подсказки остаются доступными для повторного завершения. diff --git a/app/security.py b/app/security.py index 48d8d35..3b84852 100644 --- a/app/security.py +++ b/app/security.py @@ -13,7 +13,7 @@ from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.orm import Session from app.config import Settings, get_settings -from app.models import SystemUser, utcnow +from app.models import SystemUser, UserAction, utcnow SESSION_COOKIE = "miem_admin_session" LOGIN_CSRF_COOKIE = "miem_login_csrf" @@ -138,6 +138,10 @@ def optional_user(request: Request, settings: Settings, db: Session, *, allow_pa user = db.scalar(query) if user is None and data['sub'].casefold() == settings.admin_username.strip().casefold(): user = ensure_bootstrap_admin(db, settings) + if (user and user.is_active and user.session_version == data['v'] and not user.must_change_password + and request.method == 'GET' and request.url.path.startswith('/admin')): + request.state.guide_pending = db.scalar(select(UserAction.action).where(UserAction.user_id == user.id, + UserAction.action.in_(['guide_start', 'guide_finish'])).order_by(UserAction.id.desc()).limit(1)) == 'guide_start' except SQLAlchemyError: db.rollback() raise HTTPException(503, 'Не удалось проверить учётную запись. Попробуйте ещё раз') diff --git a/app/static/admin.css b/app/static/admin.css index f3b2738..2a69e02 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -966,3 +966,16 @@ @media (max-width: 600px) { .users .form__input { font-size: 16px; } } .users__history-table .table__cell:nth-child(2) { min-width: 100px; white-space: nowrap; } + +.guide__panel { box-sizing: border-box; display: flex; flex-direction: column; overflow: hidden; } +.guide__panel > :not([data-guide-step]) { flex-shrink: 0; } +.guide__panel .guide__step { min-height: 0; overflow-y: auto; overscroll-behavior: contain; } +.guide__progress { margin-block: 16px; color: #374151; font-variant-numeric: tabular-nums; } +.guide__heading { margin-block: 20px 12px; font-size: 18px; } +.guide__fields { margin-block: 16px; } +.guide__fields dt { margin-block-start: 14px; font-weight: 600; } +.guide__fields dd { margin: 4px 0 0; overflow-wrap: anywhere; } +.guide__actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; margin-block-start: 24px; } +.guide--page { max-width: 75ch; } +.guide--page .guide__step + .guide__step { margin-block-start: 32px; } +.guide .columns-modal__header { flex-wrap: wrap; } diff --git a/app/static/guide.js b/app/static/guide.js new file mode 100644 index 0000000..8311e91 --- /dev/null +++ b/app/static/guide.js @@ -0,0 +1,69 @@ +(() => { + const dialog = document.querySelector('[data-guide-dialog]'); + if (!dialog) return; + const trigger = document.querySelector('[data-guide-open]'); + const steps = Array.from(dialog.querySelectorAll('[data-guide-step]')); + const back = dialog.querySelector('[data-guide-back]'); + const next = dialog.querySelector('[data-guide-next]'); + const close = dialog.querySelector('[data-guide-close]'); + const error = dialog.querySelector('[data-guide-error]'); + const unsaved = dialog.querySelector('[data-guide-unsaved]'); + let index = 0; + let saving = false; + + function showStep() { + steps.forEach((step, number) => { step.hidden = number !== index; }); + dialog.querySelector('[data-guide-progress]').textContent = `Шаг ${index + 1} из ${steps.length}`; + back.disabled = index === 0; + next.textContent = index === steps.length - 1 ? 'Завершить подсказки' : 'Далее'; + steps[index].scrollTop = 0; + steps[index].querySelector('h3').focus({preventScroll: true}); + } + function open() { + index = 0; + error.hidden = true; + unsaved.hidden = true; + dialog.showModal(); + showStep(); + } + async function finish() { + if (saving) return; + if (dialog.dataset.guidePending !== 'true') { + dialog.close(); + return; + } + saving = true; + dialog.setAttribute('aria-busy', 'true'); + close.disabled = next.disabled = back.disabled = true; + error.hidden = true; + try { + const response = await fetch('/admin/guide/dismiss', { + method: 'POST', credentials: 'same-origin', redirect: 'error', + headers: {'X-CSRF-Token': dialog.dataset.guideCsrf, 'Accept': 'application/json'}, signal: AbortSignal.timeout(15000), + }); + if (!response.ok) throw new Error(); + dialog.dataset.guidePending = 'false'; + dialog.close(); + } catch (_failure) { + error.textContent = 'Не удалось сохранить закрытие подсказок. Попробуйте ещё раз или продолжите без сохранения: при следующем открытии страницы подсказки могут появиться снова.'; + error.hidden = false; + unsaved.hidden = false; + } finally { + saving = false; + dialog.removeAttribute('aria-busy'); + close.disabled = next.disabled = false; + back.disabled = index === 0; + } + } + trigger?.addEventListener('click', event => { event.preventDefault(); open(); }); + back.addEventListener('click', () => { index -= 1; showStep(); }); + next.addEventListener('click', () => { + if (index === steps.length - 1) finish(); + else { index += 1; showStep(); } + }); + close.addEventListener('click', finish); + unsaved.addEventListener('click', () => { if (!saving) dialog.close(); }); + dialog.addEventListener('cancel', event => { event.preventDefault(); finish(); }); + dialog.addEventListener('close', () => trigger?.focus()); + if (dialog.dataset.guidePending === 'true') open(); +})(); diff --git a/app/templates/base.html b/app/templates/base.html index 360b73d..27ae68e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -21,6 +21,7 @@ Запуски {% if current_user and current_user.role == 'admin' %}Пользователи{% endif %} Личный кабинет + {% if current_user and not current_user.must_change_password %}Как пользоваться{% endif %}