222d48e740
- установщик в самом кабинете: до настройки любая страница ведёт в /install; три шага — проверка сервера (каталог данных, https), связь с ядром (режим, адрес, ключ; связь проверяется до сохранения, в режиме ожидания страница ждёт подключения ядра), администратор кабинета (заводится в ядре через /api/lk/admin/bootstrap, только пока администраторов нет); настройки в data/lk.json 0600, окружение перекрывает файл; после установки установщик закрыт - настройки кабинета можно менять на ходу: Server за мьютексом, один mux на всё время - ключ модуля больше не кладётся в архив: его вводят в установщике; на странице настроек ключ показан целиком 10 минут после создания, потом маска - ядро: карточка «Пользователи кабинета» в настройках — список, заведение, смена пароля (гасит сессии), блокировка, права сотрудника, разрешение поручений, удаление; письма не отправляются - инструкция /admin/help/lk дополнена шагами установщика и разделом о подходящем хостинге - тесты: TestInstaller, TestNotConfigured, TestSetupPageLKUsersAndLink; установщик проверен вживую вторым экземпляром против боевого ядра Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
186 lines
7.5 KiB
Go
186 lines
7.5 KiB
Go
package lkgateway
|
|
|
|
// lkusers_admin.go — учётные записи кабинета клиента: управление из настроек ядра и
|
|
// первый администратор из веб-установщика кабинета.
|
|
//
|
|
// Пользователи кабинета живут только здесь, в lk.users: кабинет на хостинге своей
|
|
// базы не имеет. Здесь заводят клиентов и сотрудников, меняют пароли, блокируют.
|
|
// Письма никому не отправляются: почта хранится, но рассылка выключена — данные
|
|
// пока тестовые, и случайное письмо реальному человеку недопустимо.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// globalLKSessions — сессии кабинета: нужны, чтобы гасить их при смене пароля и блокировке.
|
|
var globalLKSessions *lkSessions
|
|
|
|
// lkHasAdmin — есть ли хотя бы один администратор кабинета.
|
|
func lkHasAdmin(ctx context.Context, rc *RuntimeConfig) (bool, error) {
|
|
db, err := lkDB(rc)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
var n int
|
|
if err := db.QueryRowContext(cctx, `SELECT count(*) FROM lk.users WHERE is_admin AND active`).Scan(&n); err != nil {
|
|
return false, err
|
|
}
|
|
return n > 0, nil
|
|
}
|
|
|
|
// lkUserSetFlags — включить/выключить учётную запись, права сотрудника, подачу поручений.
|
|
func lkUserSetFlags(ctx context.Context, rc *RuntimeConfig, id int64, column string, value bool) error {
|
|
switch column {
|
|
case "active", "is_admin", "can_transfer":
|
|
default:
|
|
return errors.New("неизвестный признак")
|
|
}
|
|
db, err := lkDB(rc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
res, err := db.ExecContext(cctx, `UPDATE lk.users SET `+column+`=$1 WHERE id=$2`, value, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return errors.New("учётная запись не найдена")
|
|
}
|
|
if globalLKSessions != nil {
|
|
globalLKSessions.forget(id)
|
|
if !value && column == "active" {
|
|
globalLKSessions.dropUser(id) // заблокированного выбрасываем сразу
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// lkUserUpdate — контакты и договор.
|
|
func lkUserUpdate(ctx context.Context, rc *RuntimeConfig, id int64, contract, fullName, email, phone string) error {
|
|
if strings.TrimSpace(contract) == "" {
|
|
return errors.New("номер договора обязателен")
|
|
}
|
|
db, err := lkDB(rc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
_, err = db.ExecContext(cctx, `UPDATE lk.users SET contract_number=$1, full_name=$2, email=$3, phone=$4 WHERE id=$5`,
|
|
strings.TrimSpace(contract), strings.TrimSpace(fullName), strings.TrimSpace(email), strings.TrimSpace(phone), id)
|
|
if err == nil && globalLKSessions != nil {
|
|
globalLKSessions.forget(id)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// lkUserDelete — удалить учётную запись. Поручения и журнал остаются: они привязаны к договору.
|
|
func lkUserDelete(ctx context.Context, rc *RuntimeConfig, id int64) error {
|
|
db, err := lkDB(rc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
if _, err := db.ExecContext(cctx, `DELETE FROM lk.users WHERE id=$1`, id); err != nil {
|
|
return err
|
|
}
|
|
if globalLKSessions != nil {
|
|
globalLKSessions.dropUser(id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// lkUserByLogin — учётная запись по логину (для смены пароля из настроек).
|
|
func lkUserIDByLogin(ctx context.Context, rc *RuntimeConfig, login string) (int64, error) {
|
|
db, err := lkDB(rc)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
var id int64
|
|
if err := db.QueryRowContext(cctx, `SELECT id FROM lk.users WHERE login=$1`, login).Scan(&id); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, errors.New("учётная запись не найдена")
|
|
}
|
|
return 0, err
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// lkUsersSave — POST /admin/setup/lk/users: заведение, пароль, признаки, удаление.
|
|
func (h *setupHandlers) lkUsersSave(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
|
var err error
|
|
msg := "Пользователи кабинета: сохранено"
|
|
switch op := r.FormValue("op"); op {
|
|
case "create":
|
|
u := LKUser{
|
|
Login: strings.TrimSpace(r.FormValue("login")), Contract: strings.TrimSpace(r.FormValue("contract")),
|
|
FullName: strings.TrimSpace(r.FormValue("full_name")), Email: strings.TrimSpace(r.FormValue("email")),
|
|
Phone: strings.TrimSpace(r.FormValue("phone")), Active: true,
|
|
IsAdmin: r.FormValue("is_admin") == "1", CanTransfer: r.FormValue("can_transfer") == "1",
|
|
}
|
|
if u.IsAdmin && u.Contract == "" {
|
|
u.Contract = "ADMIN" // у сотрудника своего договора нет
|
|
}
|
|
err = lkCreateUser(ctx, h.rc, u, r.FormValue("password"))
|
|
msg = fmt.Sprintf("Учётная запись «%s» создана. Пароль сообщите пользователю лично: письма кабинет не отправляет.", u.Login)
|
|
case "password":
|
|
login := strings.TrimSpace(r.FormValue("login"))
|
|
if err = lkSetPassword(ctx, h.rc, login, r.FormValue("password")); err == nil {
|
|
if uid, e := lkUserIDByLogin(ctx, h.rc, login); e == nil && globalLKSessions != nil {
|
|
globalLKSessions.dropUser(uid) // старые сессии после смены пароля не живут
|
|
}
|
|
msg = fmt.Sprintf("Пароль «%s» изменён, его сессии завершены.", login)
|
|
}
|
|
case "update":
|
|
err = lkUserUpdate(ctx, h.rc, id, r.FormValue("contract"), r.FormValue("full_name"), r.FormValue("email"), r.FormValue("phone"))
|
|
case "block", "unblock":
|
|
err = lkUserSetFlags(ctx, h.rc, id, "active", op == "unblock")
|
|
msg = map[bool]string{true: "Учётная запись разблокирована", false: "Учётная запись заблокирована, сессии завершены"}[op == "unblock"]
|
|
case "admin_on", "admin_off":
|
|
err = lkUserSetFlags(ctx, h.rc, id, "is_admin", op == "admin_on")
|
|
case "transfer_on", "transfer_off":
|
|
err = lkUserSetFlags(ctx, h.rc, id, "can_transfer", op == "transfer_on")
|
|
case "delete":
|
|
err = lkUserDelete(ctx, h.rc, id)
|
|
msg = "Учётная запись удалена"
|
|
default:
|
|
err = errors.New("неизвестное действие")
|
|
}
|
|
if err != nil {
|
|
setupFlash(w, r, "Пользователи кабинета: "+err.Error())
|
|
return
|
|
}
|
|
setupFlash(w, r, msg)
|
|
}
|
|
|
|
// lkUsersForSetup — список для страницы настроек; ошибка базы — строкой, чтобы страница открылась.
|
|
func lkUsersForSetup(rc *RuntimeConfig) ([]LKUser, string) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
list, err := lkListUsers(ctx, rc)
|
|
if err != nil {
|
|
return nil, err.Error()
|
|
}
|
|
return list, ""
|
|
}
|