feat: show user sign-in and password reset dates
This commit is contained in:
1 parent
734631a12d
commit
075e58fa07
14 files changed
+41
-23
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Backend `0.11.1` и frontend `0.1.1`: в управлении пользователями показываются даты первого и последнего входа, а также последнего сброса пароля.
|
||||
- Backend `0.11.0` и frontend `0.1.0`: лаборатория переоформлена по дизайн-системе «Түндүк»; добавлены учетные записи, обязательная смена временного пароля, личный кабинет и управление пользователями для `admin`.
|
||||
- Backend обновлён до `0.10.4`: многословный поиск получил низковесный OR fallback с требованием совпадения минимум двух терминов; fallback пропускает распознанные названия нормативных актов.
|
||||
- Backend обновлён до `0.10.3`: запрос о налоге на прибыль поднимает действующий
|
||||
|
||||
@@ -10,7 +10,7 @@ Telegram-бот — только часть рабочего окружения
|
||||
## Текущее состояние
|
||||
|
||||
Сейчас реализованы Telegram-бот-секретарь версии `0.2.2`, backend версии
|
||||
`0.11.0` и внутренний frontend лаборатории `0.1.0`. Авторизация хранится в
|
||||
`0.11.1` и внутренний frontend лаборатории `0.1.1`. Авторизация хранится в
|
||||
SQLite; первый пользователь `admin` создаётся интерактивной CLI-командой.
|
||||
Поиск расширяет аббревиатуры организационно-правовых форм и поднимает
|
||||
действующий Налоговый кодекс для запросов о налоге на прибыль.
|
||||
@@ -18,8 +18,8 @@ SQLite; первый пользователь `admin` создаётся инт
|
||||
| Компонент | Версия | Состояние |
|
||||
|---|---:|---|
|
||||
| Telegram-бот | `0.2.2` | на Synology работает `0.2.1`; обновление после слияния |
|
||||
| Backend | `0.11.0` | авторизация и поиск с фильтрами; тематический поиск ОсОО/ЖЧК |
|
||||
| Frontend | `0.1.0` | лаборатория поиска, личный кабинет и управление учетными записями |
|
||||
| Backend | `0.11.1` | авторизация и поиск с фильтрами; тематический поиск ОсОО/ЖЧК |
|
||||
| Frontend | `0.1.1` | лаборатория поиска, личный кабинет и управление учетными записями |
|
||||
| Сбор и обработка правовых данных | `0.8.0` | добавлены relevance set и оценка выдачи |
|
||||
| RAG и база знаний | — | ещё не созданы |
|
||||
|
||||
@@ -62,4 +62,4 @@ python3 -m unittest discover -s tools/telegram-bot -v
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.0 · Frontend v0.1.0
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.1 · Frontend v0.1.1
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Backend Акылдаш
|
||||
|
||||
Версия: `0.11.0`
|
||||
Версия: `0.11.1`
|
||||
|
||||
Первая backend-область проекта — загрузка правовых документов из официального
|
||||
Open Data API ЦБД Минюста Кыргызской Республики. Код расположен в
|
||||
@@ -253,4 +253,4 @@ PYTHONPATH=backend python3 -m search.query "ЖЧК ачуу тартиби" --la
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Backend v0.11.0 · Frontend v0.1.0
|
||||
Акылдаш · Backend v0.11.1 · Frontend v0.1.1
|
||||
@@ -364,7 +364,7 @@ def main() -> int:
|
||||
print(
|
||||
f"discovered={result.discovered} downloaded={result.downloaded} "
|
||||
f"skipped={result.skipped} failed={result.failed}\n"
|
||||
f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.0"
|
||||
f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.1"
|
||||
)
|
||||
return int(result.failed > 0)
|
||||
|
||||
|
||||
@@ -720,7 +720,7 @@ def main() -> int:
|
||||
print(
|
||||
f"discovered={result.discovered} normalized={result.normalized} "
|
||||
f"skipped={result.skipped} failed={result.failed}\n"
|
||||
f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.0"
|
||||
f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.1"
|
||||
)
|
||||
return int(result.failed > 0)
|
||||
|
||||
|
||||
+17
-3
@@ -41,7 +41,10 @@ class AuthStore:
|
||||
password_hash BLOB NOT NULL,
|
||||
password_salt BLOB NOT NULL,
|
||||
must_change_password INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL
|
||||
created_at INTEGER NOT NULL,
|
||||
first_login_at INTEGER,
|
||||
last_login_at INTEGER,
|
||||
password_reset_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token_hash BLOB PRIMARY KEY,
|
||||
@@ -49,6 +52,10 @@ class AuthStore:
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
""")
|
||||
user_columns = {row["name"] for row in self.connection.execute("PRAGMA table_info(users)")}
|
||||
for column in ("first_login_at", "last_login_at", "password_reset_at"):
|
||||
if column not in user_columns:
|
||||
self.connection.execute(f"ALTER TABLE users ADD COLUMN {column} INTEGER")
|
||||
self.connection.execute("PRAGMA foreign_keys = ON")
|
||||
self.connection.commit()
|
||||
|
||||
@@ -92,7 +99,9 @@ class AuthStore:
|
||||
now = int(time.time())
|
||||
self.connection.execute("DELETE FROM sessions WHERE expires_at <= ?", (now,))
|
||||
self.connection.execute("INSERT INTO sessions(token_hash,username,expires_at) VALUES(?,?,?)", (hashlib.sha256(token.encode()).digest(), user["username"], now + SESSION_TTL))
|
||||
self.connection.execute("UPDATE users SET first_login_at=COALESCE(first_login_at,?),last_login_at=? WHERE username=?", (now, now, user["username"]))
|
||||
self.connection.commit()
|
||||
current = self.connection.execute("SELECT * FROM users WHERE username=?", (user["username"],)).fetchone()
|
||||
return token, self._public(current)
|
||||
|
||||
@staticmethod
|
||||
@@ -149,7 +158,7 @@ class AuthStore:
|
||||
salt = secrets.token_bytes(16)
|
||||
password_hash = self._hash(password, salt)
|
||||
with self._lock:
|
||||
cursor = self.connection.execute("UPDATE users SET password_hash=?,password_salt=?,must_change_password=1 WHERE username=?", (password_hash, salt, username))
|
||||
cursor = self.connection.execute("UPDATE users SET password_hash=?,password_salt=?,must_change_password=1,password_reset_at=? WHERE username=?", (password_hash, salt, int(time.time()), username))
|
||||
if not cursor.rowcount:
|
||||
raise AuthError("account not found")
|
||||
self.connection.execute("DELETE FROM sessions WHERE username=?", (username,))
|
||||
@@ -157,4 +166,9 @@ class AuthStore:
|
||||
|
||||
def list_users(self) -> list[dict]:
|
||||
with self._lock:
|
||||
return [self._public(row) for row in self.connection.execute("SELECT * FROM users ORDER BY username COLLATE NOCASE")]
|
||||
users = []
|
||||
for row in self.connection.execute("SELECT * FROM users ORDER BY username COLLATE NOCASE"):
|
||||
account = self._public(row)
|
||||
account.update(first_login_at=row["first_login_at"], last_login_at=row["last_login_at"], password_reset_at=row["password_reset_at"])
|
||||
users.append(account)
|
||||
return users
|
||||
@@ -82,7 +82,7 @@ def main() -> int:
|
||||
raise SystemExit("--top-k must be greater than zero")
|
||||
result = evaluate(load_queries(arguments.relevance_set), arguments.url, arguments.index, arguments.top_k)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.0", file=sys.stderr)
|
||||
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.1", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import Iterator
|
||||
|
||||
from search.catalog import authority_codes, legal_force_code, source_code
|
||||
|
||||
APP_VERSION = "0.11.0"
|
||||
APP_VERSION = "0.11.1"
|
||||
LANGUAGES = {"ru", "ky"}
|
||||
DEFAULT_MAPPING = Path(__file__).with_name("minjust-fragments-index.json")
|
||||
|
||||
@@ -460,7 +460,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.1.0")
|
||||
print(f"documents={documents} fragments={fragments} destination={destination}\nAkyldash · Backend v{APP_VERSION} · Frontend v0.1.1")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ def main() -> int:
|
||||
if arguments.top_k <= 0:
|
||||
raise SystemExit("--top-k must be greater than zero")
|
||||
print(json.dumps(search_documents(arguments.url, arguments.index, arguments.language, arguments.query, arguments.top_k), ensure_ascii=False))
|
||||
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.0")
|
||||
print(f"Akyldash · Backend v{APP_VERSION} · Frontend v0.1.1")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
.review__notice { padding: .75rem; background: #FFF3DA; color: #82520D; border-radius: 6px; }
|
||||
.review__user-list { padding: 0; list-style: none; }
|
||||
.review__user-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .75rem; padding-block: .75rem; border-block-end: 1px solid var(--line); }
|
||||
.review__user-meta { display: grid; gap: .25rem; color: var(--muted); white-space: pre-line; }
|
||||
.review__user-reset { display: flex; flex-wrap: wrap; gap: .5rem; align-items: end; }
|
||||
.review__user-reset label { display: grid; gap: .25rem; margin: 0; }
|
||||
.review__user-reset input { width: min(15rem, 100%); }
|
||||
@@ -175,7 +176,7 @@
|
||||
<dialog id="users-dialog" aria-labelledby="users-heading"><section class="review__page"><h2 id="users-heading">Управление пользователями</h2><form id="create-user-form"><label>Логин<input id="new-username" autocomplete="off" minlength="3" maxlength="64" required></label><label>Временный пароль<input id="temporary-password" type="password" autocomplete="new-password" minlength="12" maxlength="256" required></label><p>Пароль — от 12 символов. Новый пользователь сменит его при первом входе. Передайте временный пароль отдельно.</p><p id="users-error" role="alert" class="review__status review__status--error"></p><button class="review__button--primary" type="submit">Создать пользователя</button></form><ul id="user-list" class="review__user-list"></ul><button type="button" data-close="users-dialog">Закрыть</button></section></dialog>
|
||||
<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>
|
||||
<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.1.0 · Backend v0.11.0 · Внутренняя лаборатория релевантности</div></footer>
|
||||
<footer class="review__header"><div class="review__header-content">Акылдаш · Frontend v0.1.1 · Backend v0.11.1 · Внутренняя лаборатория релевантности</div></footer>
|
||||
<script>
|
||||
document.body.classList.add('review__locked');
|
||||
const DRAFT_KEY = 'akyldash-search-review-draft';
|
||||
@@ -192,7 +193,7 @@
|
||||
let account = null;
|
||||
const reviewRoute = location.pathname === '/review';
|
||||
$('review').hidden = !reviewRoute; $('search-page').hidden = reviewRoute; $('review-nav').classList.toggle('is-active', reviewRoute); $('search-nav').classList.toggle('is-active', !reviewRoute);
|
||||
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.1.0 · Backend v0.11.0'; }
|
||||
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.1.1 · Backend v0.11.1'; }
|
||||
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} · ${user.role}`; $('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'); $('users-open').hidden = user.username.toLowerCase() !== 'admin'; document.body.classList.remove('review__locked'); if (!reviewRoute) initSearchPage(); else if (!hasSeenTour()) setTimeout(openTutorial, 350); };
|
||||
@@ -262,10 +263,12 @@
|
||||
const response = await apiFetch('/admin/users');
|
||||
if (!response.ok) { $('users-error').textContent = 'Не удалось загрузить список пользователей.'; return; }
|
||||
const {users} = await response.json();
|
||||
const formatUserDate = (timestamp) => timestamp ? new Intl.DateTimeFormat('ru-RU', {dateStyle: 'medium', timeStyle: 'short'}).format(new Date(timestamp * 1000)) : '—';
|
||||
const list = $('user-list'); list.replaceChildren();
|
||||
users.forEach((user) => {
|
||||
const row = document.createElement('li'); row.className = 'review__user-row';
|
||||
const label = document.createElement('span'); label.textContent = `${user.username} · Администратор${user.must_change_password ? ' · требуется сменить пароль' : ''}`; row.append(label);
|
||||
const dates = document.createElement('small'); dates.className = 'review__user-meta'; dates.textContent = `Первый вход: ${formatUserDate(user.first_login_at)}\nПоследний вход: ${formatUserDate(user.last_login_at)}\nСброс пароля: ${formatUserDate(user.password_reset_at)}`; row.append(dates);
|
||||
if (user.username.toLowerCase() !== 'admin') {
|
||||
const resetForm = document.createElement('form'); resetForm.className = 'review__user-reset';
|
||||
const password = document.createElement('input'); password.type = 'password'; password.autocomplete = 'new-password'; password.minLength = 12; password.maxLength = 256; password.required = true;
|
||||
|
||||
+1
-1
@@ -41,4 +41,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.0 · Frontend v0.1.0
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.1 · Frontend v0.1.1
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
- Telegram-бот: `0.2.2`
|
||||
- Telegram-бот на Synology: `0.2.1`
|
||||
- Backend: `0.11.0`
|
||||
- Frontend: `0.1.0` — поиск, лаборатория релевантности, аккаунты
|
||||
- Backend: `0.11.1`
|
||||
- Frontend: `0.1.1` — поиск, лаборатория релевантности, аккаунты
|
||||
|
||||
## Краткий итог
|
||||
|
||||
@@ -281,4 +281,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.0 · Frontend v0.1.0
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.1 · Frontend v0.1.1
|
||||
@@ -262,4 +262,4 @@ runtime-зависимостями frontend. Регистрация в стор
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.0 · Frontend v0.1.0
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.1 · Frontend v0.1.1
|
||||
@@ -44,4 +44,4 @@
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.0 · Frontend v0.1.0
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.11.1 · Frontend v0.1.1
|
||||
Reference in new issue
Block a user