Files
Bridge-and-Join-s/internal/lkgateway/lkusers.go
T
zuevav 683f516d07 Кабинет: поиск клиентов с подсказками, чистая страница документов, ширина настроек
Поиск клиентов (режим сотрудника):
- поле поиска вынесено наверх страницы, рядом «Найти» и «Сбросить»;
- браузер сам подсказывает выпадающим списком по мере ввода — договоры
  депозитария отдаются страницей в <datalist> (скриптов в кабинете нет,
  CSP остаётся строгой), список кэшируется в ядре на 10 минут;
- по запросу фильтруются и учётки кабинета, и договоры депозитария,
  под формой показывается, сколько найдено;
- шаблон LIKE экранируется, чтобы % и _ в номере договора означали себя.

Документы: убраны пустое табло, «Мои заявки» и форма заказа документа —
остался список готовых документов депозитария и пояснение, куда обращаться.

Настройка: колонка сетки больше не растягивается широкой таблицей
(min-width:0), у страницы пропал горизонтальный ползунок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
2026-09-18 18:45:54 +03:00

217 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lkgateway
import (
"context"
"errors"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
// Пользователи Личного кабинета клиента. Хранятся в PostgreSQL bj (lk.users), пароль —
// только bcrypt-хеш. Клиент входит по логину/паролю, его портфель и заявки определяются
// номером договора (contract_number).
// LKUser — учётная запись клиента в Личном кабинете.
type LKUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
Contract string `json:"contract_number"`
FullName string `json:"full_name"`
LastName string `json:"last_name"`
FirstName string `json:"first_name"`
MiddleName string `json:"middle_name"`
DocType string `json:"doc_type"`
DocSeries string `json:"doc_series"`
DocNumber string `json:"doc_number"`
Email string `json:"email"`
Phone string `json:"phone"`
Active bool `json:"active"`
IsAdmin bool `json:"is_admin"`
// CanTransfer — клиенту разрешена подача заявок на перевод. Для реальных
// депозитарных клиентов false (только просмотр); включаем точечно тестовым.
CanTransfer bool `json:"can_transfer"`
// ImpersonatedBy — логин администратора, если он вошёл от имени этого клиента.
ImpersonatedBy string `json:"impersonated_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastLoginAt time.Time `json:"last_login_at,omitempty"`
}
var errLKAuth = errors.New("неверный логин или пароль")
// lkCreateUser заводит пользователя ЛК (пароль хешируется bcrypt).
func lkCreateUser(ctx context.Context, rc *RuntimeConfig, u LKUser, password string) error {
if strings.TrimSpace(u.Login) == "" || strings.TrimSpace(u.Contract) == "" {
return errors.New("логин и номер договора обязательны")
}
if err := lkCheckPassword(password); err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
db, err := lkDB(rc)
if err != nil {
return err
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
_, err = db.ExecContext(cctx, `INSERT INTO lk.users (login, password_hash, contract_number, full_name, email, phone, active,
last_name, first_name, middle_name, doc_type, doc_series, doc_number, is_admin, can_transfer)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
u.Login, string(hash), u.Contract, u.FullName, u.Email, u.Phone, u.Active,
u.LastName, u.FirstName, u.MiddleName, u.DocType, u.DocSeries, u.DocNumber, u.IsAdmin, u.CanTransfer)
return err
}
// lkSetPassword меняет пароль пользователя ЛК.
func lkSetPassword(ctx context.Context, rc *RuntimeConfig, login, password string) error {
if err := lkCheckPassword(password); err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
db, err := lkDB(rc)
if err != nil {
return err
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
res, err := db.ExecContext(cctx, "UPDATE lk.users SET password_hash=$1 WHERE login=$2", string(hash), login)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return errors.New("пользователь не найден")
}
return nil
}
// lkAuth проверяет логин/пароль клиента. Возвращает пользователя при успехе.
func lkAuth(ctx context.Context, rc *RuntimeConfig, login, password string) (*LKUser, error) {
db, err := lkDB(rc)
if err != nil {
return nil, err
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
var u LKUser
var hash string
if err := db.QueryRowContext(cctx, `SELECT id, login, password_hash, contract_number,
COALESCE(full_name,''), COALESCE(email,''), COALESCE(phone,''), active,
COALESCE(last_name,''), COALESCE(first_name,''), COALESCE(middle_name,''),
COALESCE(doc_type,''), COALESCE(doc_series,''), COALESCE(doc_number,''), is_admin, can_transfer
FROM lk.users WHERE login=$1`, login).Scan(&u.ID, &u.Login, &hash, &u.Contract,
&u.FullName, &u.Email, &u.Phone, &u.Active,
&u.LastName, &u.FirstName, &u.MiddleName,
&u.DocType, &u.DocSeries, &u.DocNumber, &u.IsAdmin, &u.CanTransfer); err != nil {
// Логина нет. Всё равно считаем bcrypt по фиктивному хешу: иначе ответ
// приходит заметно быстрее и по времени можно перебрать существующие логины.
_ = bcrypt.CompareHashAndPassword([]byte(lkDummyHash), []byte(password))
return nil, errLKAuth // не раскрываем, что логина нет
}
if !u.Active {
return nil, errors.New("учётная запись заблокирована")
}
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) != nil {
return nil, errLKAuth
}
_, _ = db.ExecContext(cctx, "UPDATE lk.users SET last_login_at=now() WHERE id=$1", u.ID) // отметка входа, не критично
return &u, nil
}
// lkListUsers — список пользователей ЛК (для админки).
func lkListUsers(ctx context.Context, rc *RuntimeConfig) ([]LKUser, error) {
db, err := lkDB(rc)
if err != nil {
return nil, err
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
rows, err := db.QueryContext(cctx, `SELECT id, login, contract_number,
COALESCE(full_name,''), COALESCE(email,''), COALESCE(phone,''), active, is_admin, can_transfer
FROM lk.users ORDER BY login`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []LKUser
for rows.Next() {
var u LKUser
if err := rows.Scan(&u.ID, &u.Login, &u.Contract, &u.FullName, &u.Email, &u.Phone, &u.Active, &u.IsAdmin, &u.CanTransfer); err != nil {
return out, err
}
out = append(out, u)
}
return out, rows.Err()
}
// lkDummyHash — заведомо не подходящий bcrypt-хеш: по нему считаем проверку, когда
// логина нет, чтобы ответ занимал столько же времени, сколько при существующем логине.
const lkDummyHash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
// lkCheckPassword — требования к паролю кабинета: в кабинете видны паспортные данные
// и остатки на счетах депо, поэтому шести символов мало.
func lkCheckPassword(password string) error {
if len([]rune(password)) < 10 {
return errors.New("пароль минимум 10 символов")
}
var hasLetter, hasOther bool
for _, r := range password {
switch {
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= 'а' && r <= 'я') || (r >= 'А' && r <= 'Я'):
hasLetter = true
default:
hasOther = true
}
}
if !hasLetter || !hasOther {
return errors.New("пароль должен содержать буквы и хотя бы одну цифру или знак")
}
return nil
}
// lkUserByID — карточка клиента по идентификатору. Нужна сессиям: на диске хранится
// только ссылка на учётную запись, а сами данные читаются отсюда на каждый запрос.
func lkUserByID(ctx context.Context, rc *RuntimeConfig, id int64) (*LKUser, error) {
db, err := lkDB(rc)
if err != nil {
return nil, err
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
var u LKUser
if err := db.QueryRowContext(cctx, `SELECT id, login, contract_number,
COALESCE(full_name,''), COALESCE(email,''), COALESCE(phone,''), active,
COALESCE(last_name,''), COALESCE(first_name,''), COALESCE(middle_name,''),
COALESCE(doc_type,''), COALESCE(doc_series,''), COALESCE(doc_number,''), is_admin, can_transfer
FROM lk.users WHERE id=$1`, id).Scan(&u.ID, &u.Login, &u.Contract,
&u.FullName, &u.Email, &u.Phone, &u.Active,
&u.LastName, &u.FirstName, &u.MiddleName,
&u.DocType, &u.DocSeries, &u.DocNumber, &u.IsAdmin, &u.CanTransfer); err != nil {
return nil, errors.New("учётная запись не найдена")
}
return &u, nil
}
// lkFilterUsers — учётки кабинета, подходящие под строку поиска сотрудника
// (часть логина, номера договора, ФИО или почты; регистр не важен).
func lkFilterUsers(list []LKUser, search string) []LKUser {
q := strings.ToLower(strings.TrimSpace(search))
if q == "" {
return list
}
out := make([]LKUser, 0, len(list))
for _, u := range list {
hay := strings.ToLower(u.Login + " " + u.Contract + " " + u.FullName + " " + u.Email)
if strings.Contains(hay, q) {
out = append(out, u)
}
}
return out
}