Files
ipam_control/docs/changes/035-user-last-login/PLAN.md
T

66 lines
5.5 KiB
Markdown
Raw Normal View History

# Последний вход пользователя: дата и IP на странице «Пользователи» (изменение 035)
## Context
Суперадминистратору нужно видеть в разделе «Пользователи», когда каждый пользователь последний раз входил в систему и с какого IP.
Сейчас это можно найти только поиском по журналу. `known_logins` для этого не подходит: там IP за 30 дней, ротация удаляет старые записи,
и он хранит набор IP, а не последний вход.
**Решение:** два поля в `users` — время и IP последнего успешного входа. Поля обновляются в `POST /auth/login`,
отдаются в `UserOut` и выводятся колонкой в таблице пользователей.
IP берётся тот же, что уже вычисляет `request_context` для журнала: реальный адрес клиента за Caddy (`TRUSTED_PROXIES`, изменение 034).
## Изменения
### БД и модель
- `app/models.py::User`:
- `last_login_at: Mapped[datetime | None]` (`DateTime(timezone=True)`);
- `last_login_ip: Mapped[str | None]` (`INET`);
- комментарий «изменение 035».
- Миграция `alembic/versions/0014_users_last_login.py` (down_revision `0013`):
- две nullable-колонки;
- backfill из журнала: для каждого пользователя берётся последнее событие `session.login` (`DISTINCT ON (username) … ORDER BY username, ts DESC`)
по `audit_log.username = users.username`, из него — `ts` и `client_ip`;
- если журнал ротирован или очищен, значения остаются `NULL` («—» в UI).
- `downgrade` — удалить колонки.
### API
- `app/api/v1/auth.py::login`, ветка успешного входа (рядом с `_remember_login`):
`user.last_login_at = func.now()`, `user.last_login_ip = ip`, в той же транзакции, что и запись `session.login`.
Неудачные попытки поля не трогают.
- `app/schemas.py::UserOut`: `last_login_at: datetime | None = None`, `last_login_ip: str | None = None`.
INET приводить к строке тем же способом, что уже используется для `client_ip` в `AuditOut` и адресов (`ip_text`),
чтобы в JSON был `"192.168.5.9"`, а не объект. `UserOut` собирается через `model_validate` из ORM, поэтому нужен
`@field_validator("last_login_ip", mode="before")` с вызовом `ip_text()`.
`UserOut` отдают и `/users` (только `superadmin`), и `/auth/me` (свои данные) — утечки чужих IP нет.
### UI (`web/app.js::screens.users`)
- Новая колонка «Последний вход» после «Статус»:
- дата — `fmtDate(u.last_login_at)` (как в журнале, UTC);
- под ней или через разделитель — IP моноширинным шрифтом, `muted`;
- «—», если входа не было.
- Расширить `cols` (`selCols(...)`) и заголовок `.th` на одну колонку. Ширину подобрать так, чтобы таблица не переполнялась,
по образцу соседних колонок (≈170–190px).
### Тесты (минимально)
- `tests/test_users.py::test_users_management`: после входа созданного пользователя (`_client(name, ...)`)
`GET /users?q=<name>` → у записи заполнены `last_login_at` и `last_login_ip`. Новых тестов не добавлять.
### Документация (правила проекта)
- `docs/changes/035-user-last-login/PLAN.md` — этот план; по завершении — `SUMMARY.md`.
- `README.md`:
- раздел «Интерфейс»: в «Пользователях» видны дата и IP последнего входа;
- миграции `0001`–`0014`;
- строка 035 в истории изменений.
## Исполнение
По принятой схеме: код и тест пишет агент на Sonnet (без запуска тестов и стенда), затем ревью, пересборка стенда,
проверки и SUMMARY — моя часть.
## Проверка
- `docker compose -p ipam_control_006 up -d --build app` → `alembic current` = `0014`, `alembic check` — чисто;
откат до 0013 и повторный upgrade — без ошибок.
- Backfill: у суперадминистратора из `.env`, который входил, `last_login_at`/`last_login_ip` совпадают с последним `session.login` в журнале.
- Вход через Caddy с LAN-адреса хоста (`--resolve rxipam.rxmsk.ru:443:192.168.5.9`) → `last_login_ip = 192.168.5.9`;
неудачная попытка не меняет поля.
- `GET /users` — поля в JSON строками; `pytest -q` — все зелёные.
- UI: колонка «Последний вход» с датой и IP, «—» у пользователей без входа. В браузере проверяет пользователь, если нужно.