Канал ИШ: необязательные параметры — NULL (иначе ИШ молча выкидывал канал); мастер: шаг «Учётные записи», HTTPS сразу, DEPO без процедур — не блокирует

- bj-igate-admin: пустые строки в числовых/интервальных параметрах канала ИШ разбирал с ошибкой и не
  загружал канал («There are no active channels», API 500 «more than one Channel…»); теперь NULL, как пишет
  интерфейс ИШ; старые каналы чинятся при «Создать канал»; папка архивов 1042S — по имени
- проверка «Шлюз НРД»: канал контура действительно загружен и включён (api/admin/channels/wsl)
- «Учёт DEPO»: нет процедур bj2depo — жёлтое «не забудьте доустановить», не блокирует
- HTTPS: перенаправление с http:// включено по умолчанию, только когда HTTPS реально слушает порт
- мастер: шаг «Учётные записи» (роли, создание, «Придумать пароль») перед проверкой; письма с проверки убраны;
  форма «создать» не меняет существующую запись с тем же логином

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-06 23:19:01 +03:00
parent b3f3454e4a
commit 44f00a006a
12 changed files with 264 additions and 47 deletions
+20
View File
@@ -1209,9 +1209,25 @@ func (v UsersView) RoleNote(k string) string {
}
func (h *setupHandlers) usersFlash(w http.ResponseWriter, r *http.Request, msg string) {
if safeReturnPath(r.FormValue("return_to")) != "" { // форма из мастера (шаг «Учётные записи») — туда же
setupFlash(w, r, msg)
return
}
http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg)+"#users", http.StatusSeeOther)
}
// doneUsers — кроме администратора заведена хотя бы одна действующая учётная запись: у каждого сотрудника свой
// вход, и в журнале видно, кто что сделал.
func doneUsers() bool {
n := 0
for _, u := range globalUsers.List() {
if u.Active {
n++
}
}
return n > 1
}
func (h *setupHandlers) saveUser(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
@@ -1230,6 +1246,10 @@ func (h *setupHandlers) saveUser(w http.ResponseWriter, r *http.Request) {
}
u := globalUsers.Get(login)
isNew := u == nil
if !isNew && r.FormValue("create") != "" { // форма «создать» не должна молча менять существующую запись
h.usersFlash(w, r, "Пользователи: логин «"+login+"» уже занят — выберите другой.")
return
}
if isNew {
u = &User{Login: login, Source: "local", Active: true, CreatedAt: time.Now()}
}
+36 -13
View File
@@ -1439,28 +1439,51 @@ var (
)
func depoObjectsMissing(ctx context.Context, db *sql.DB, f FancyMSSQLSettings) string {
if msg := depoTablesMissing(ctx, db, f); msg != "" {
return msg
}
return depoProcsMissing(ctx, db, f)
}
// depoMissing — какие объекты из списка (тип U — таблица, P — процедура) отсутствуют в базе DEPO.
func depoMissing(ctx context.Context, db *sql.DB, names []string, typ string) []string {
var out []string
for _, n := range names {
var ok int
if err := db.QueryRowContext(ctx, "SELECT CASE WHEN OBJECT_ID(@n, @t) IS NULL THEN 0 ELSE 1 END", sql.Named("n", "dbo."+n), sql.Named("t", typ)).Scan(&ok); err == nil && ok == 0 {
out = append(out, n)
}
}
return out
}
// depoTablesMissing — нет таблиц Fancy DEPO с поддержкой M2M: без них обмен невозможен.
func depoTablesMissing(ctx context.Context, db *sql.DB, f FancyMSSQLSettings) string {
if f.IsPostgres() {
return ""
}
missing := func(names []string, typ string) []string {
var out []string
for _, n := range names {
var ok int
if err := db.QueryRowContext(ctx, "SELECT CASE WHEN OBJECT_ID(@n, @t) IS NULL THEN 0 ELSE 1 END", sql.Named("n", "dbo."+n), sql.Named("t", typ)).Scan(&ok); err == nil && ok == 0 {
out = append(out, n)
}
}
return out
}
if m := missing(depoFancyM2MTables, "U"); len(m) > 0 {
if m := depoMissing(ctx, db, depoFancyM2MTables, "U"); len(m) > 0 {
return "в базе " + f.DepoDB() + " нет таблиц Fancy DEPO с поддержкой M2M (" + strings.Join(m, ", ") + "): нужна версия Fancy DEPO с M2M — обратитесь к разработчику Fancy"
}
return ""
}
// depoProcsMissingText — начало сообщения «нет процедур bj2depo» (по нему отличается от других отказов обработки).
const depoProcsMissingText = "нет процедур обработки bj2depo"
// depoProcsMissing — нет процедур обработки bj2depo. С 06.10.2026 это не блокирует запуск: формат обмена с учётной
// системой ещё готовится, процедуры могут поставляться отдельно (в том числе сообщениями в саму учётную систему) —
// проверка готовности напоминает «не забудьте доустановить».
func depoProcsMissing(ctx context.Context, db *sql.DB, f FancyMSSQLSettings) string {
if f.IsPostgres() {
return ""
}
procs := depoBj2depoProcs
if p := strings.TrimSpace(f.DepoJobProc); p != "" && !strings.EqualFold(strings.TrimPrefix(p, "dbo."), "FTV_M2M_JOB") {
procs = []string{strings.TrimPrefix(p, "dbo.")} // своя процедура обработки — проверяем только её
}
if m := missing(procs, "P"); len(m) > 0 {
return "в базе " + f.DepoDB() + " нет процедур обработки bj2depo: " + strings.Join(m, ", ") + ". Их нужно установить в базу DEPO (порядок: WIRING → OPERATION → EVENTS → CREATE → JOB), затем — «Перепроверить всё»"
if m := depoMissing(ctx, db, procs, "P"); len(m) > 0 {
return "в базе " + f.DepoDB() + " " + depoProcsMissingText + ": " + strings.Join(m, ", ") + ". Их нужно установить в базу DEPO (порядок: WIRING → OPERATION → EVENTS → CREATE → JOB), затем — «Перепроверить всё»"
}
return ""
}
+31
View File
@@ -54,6 +54,37 @@ func igateState(ctx context.Context, base string) (bool, string) {
return true, "шлюз отвечает по адресу " + base + ", движок работает"
}
// igateChannelState — загрузил ли ИШ канал с этим составным кодом (<канал контура><код депонента>) и включён ли он.
// Движок может быть Running и без единого канала: ИШ молча выкидывает канал, который не смог прочитать из базы
// (06.10.2026 на .29: пустые строки вместо NULL в необязательных параметрах), — и тогда любой обмен даёт HTTP 500.
func igateChannelState(ctx context.Context, base, code string) (found, enabled bool, err error) {
base = strings.TrimRight(pickStr(strings.TrimSpace(base), igateDefaultURL), "/")
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(cctx, http.MethodGet, base+"/api/admin/channels/wsl", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false, false, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, false, fmt.Errorf("HTTP %s", resp.Status)
}
var list []struct {
Code string `json:"code"`
IsEnabled bool `json:"isEnabled"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&list); err != nil {
return false, false, err
}
for _, c := range list {
if strings.EqualFold(c.Code, code) {
return true, c.IsEnabled, nil
}
}
return false, false, nil
}
func registerIgate(mux *http.ServeMux, rc *RuntimeConfig) {
registerIgateAdmin(mux, rc)
mux.HandleFunc("/admin/setup/igate/state", func(w http.ResponseWriter, r *http.Request) {
+27
View File
@@ -157,3 +157,30 @@ func TestSMTPTestTo(t *testing.T) {
t.Errorf("без поля — прежний: %q", got)
}
}
// Форма «Создать учётную запись» из мастера не меняет существующего пользователя с тем же логином.
func TestCreateUserLoginTaken(t *testing.T) {
old := usersFile
usersFile = filepath.Join(t.TempDir(), "users.json")
defer func() { usersFile = old }()
prev := globalUsers
globalUsers = &userStore{users: map[string]*User{}}
defer func() { globalUsers = prev }()
if err := globalUsers.Put(&User{Login: "admin", Role: "admin", Source: "local", Active: true}); err != nil {
t.Fatal(err)
}
h := &setupHandlers{}
r := httptest.NewRequest(http.MethodPost, "/admin/setup/users", strings.NewReader("login=admin&role=viewer&create=1&password=12345678&return_to=%2Fadmin%2Fwizard%3Fstep%3D13"))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.saveUser(w, r)
if u := globalUsers.Get("admin"); u == nil || u.Role != "admin" {
t.Fatalf("существующий администратор изменён: %+v", u)
}
if loc := w.Header().Get("Location"); !strings.HasPrefix(loc, "/admin/wizard?step=13") || !strings.Contains(loc, "flash=") {
t.Fatalf("возврат в мастер: %q", loc)
}
if doneUsers() {
t.Fatal("один администратор — шаг не выполнен")
}
}
+21 -3
View File
@@ -126,9 +126,19 @@ func TestHTTPSSelfSignedAndRedirect(t *testing.T) {
if _, err := loadTLSCert(nil); err != nil {
t.Fatal(err)
}
rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json")
_ = rc.UpdateHTTPS(HTTPSSettings{Redirect: true})
rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json") // перенаправление — по умолчанию, без настройки
h := httpsRedirect(rc, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }))
httpsUp.Store(false) // HTTPS не поднялся — не перенаправляем, иначе в bj не войти
if rec := httptest.NewRecorder(); func() int {
req := httptest.NewRequest(http.MethodGet, "http://10.10.10.6:8080/admin/setup", nil)
req.RemoteAddr = "10.10.10.5:5000"
h.ServeHTTP(rec, req)
return rec.Code
}() != http.StatusTeapot {
t.Fatal("без работающего HTTPS перенаправлять нельзя")
}
httpsUp.Store(true)
defer httpsUp.Store(false)
cases := []struct {
path, remote string
want int
@@ -150,6 +160,14 @@ func TestHTTPSSelfSignedAndRedirect(t *testing.T) {
t.Errorf("перенаправление: %s", rec.Header().Get("Location"))
}
}
_ = rc.UpdateHTTPS(HTTPSSettings{RedirectOff: true}) // выключено в настройках
req := httptest.NewRequest(http.MethodGet, "http://10.10.10.6:8080/admin/setup", nil)
req.RemoteAddr = "10.10.10.5:5000"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusTeapot {
t.Errorf("выключенное перенаправление: %d", rec.Code)
}
}
func TestReceiverUnknownRejected(t *testing.T) {
@@ -192,7 +210,7 @@ func TestWizardChaptersWithoutLicense(t *testing.T) {
rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json")
done := map[int]bool{2: true, 6: true}
path, chapters := wizPath(rc, done, 4)
want := []int{1, 12, 4, 7, 8, 9, 10} // без лицензии нет учёта DEPO, QUIK и модулей; база и Валидата — сделаны
want := []int{1, 12, 4, 7, 8, 9, 13, 10} // без лицензии нет учёта DEPO, QUIK и модулей; база и Валидата — сделаны; учётные записи — перед проверкой
if fmt.Sprint(path) != fmt.Sprint(want) {
t.Fatalf("путь %v, ожидался %v", path, want)
}
+32 -4
View File
@@ -14,11 +14,14 @@ import (
"os"
"os/exec"
"sort"
"strconv"
"strings"
"sync"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter"
)
// cryptoJarPath — модуль проверки ключа подписи bj-crypto (компонент crypto-service.jar).
@@ -177,6 +180,20 @@ func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck {
c.State, c.Detail = "err", "шлюз отвечает, но контур НРД в bj не выбран"
default:
c.State, c.Detail = "ok", msg+"; контур "+s.NSD.Profile
if prof, err := nsdadapter.LookupProfile(s.NSD.Profile); err == nil && orgCode(s) != "" {
code := prof.Channel + orgCode(s)
switch found, enabled, err := igateChannelState(ctx, s.NSD.IGWBaseURL, code); {
case err != nil:
case !found:
c.State, c.Detail = "err", "движок шлюза работает, но канала "+code+" в нём нет — шлюз не загрузил канал: пересоздайте его на шаге «Шлюз НРД» (кнопка «Создать канал»)"
return c
case !enabled:
c.State, c.Detail = "err", "канал "+code+" в шлюзе выключен — он включается, когда установлен ключ подписи этого контура (шаг «Ключ подписи»)"
return c
default:
c.Detail += "; канал " + code + " работает"
}
}
nsdWatch.Lock()
okAt := nsdWatch.LastPollOK
nsdWatch.Unlock()
@@ -239,8 +256,10 @@ func readinessChecks(ctx context.Context, rc *RuntimeConfig) []ReadyCheck {
c.State, c.Detail = "warn", "сертификат HTTPS: "+err.Error()
case certDaysLeft(until) < 14:
c.State, c.Detail = "warn", fmt.Sprintf("сертификат «%s» истекает %s — замените", cn, until.Format("02.01.2006"))
case !s.HTTPS.Redirect:
c.State, c.Detail = "warn", "вход в bj по http:// не перенаправляется на https:// — пароли могут идти по сети открытым текстом"
case !httpsUp.Load():
c.State, c.Detail = "warn", "HTTPS не запущен (порт "+strconv.Itoa(s.HTTPS.port())+" занят или нет сертификата) — вход только по http://, пароли идут по сети открытым текстом; см. журнал bj-server"
case !s.HTTPS.RedirectOn():
c.State, c.Detail = "warn", "перенаправление с http:// на https:// выключено в настройках — пароли могут идти по сети открытым текстом"
default:
c.State, c.Detail = "ok", fmt.Sprintf("вход только по HTTPS, сертификат «%s» до %s", cn, until.Format("02.01.2006"))
}
@@ -386,17 +405,26 @@ func readyDepo(ctx context.Context, rc *RuntimeConfig) ReadyCheck {
c.State = "err"
notes = append(notes, "таблицы обмена FTV_M2M: "+pickStr(f.DepoTablesCheck, "не проверены"))
}
if msg := depoObjectsMissing(cctx, db, f); msg != "" {
procsPending := false
if msg := depoTablesMissing(cctx, db, f); msg != "" {
c.State = "err"
notes = append(notes, msg)
} else if msg := depoProcsMissing(cctx, db, f); msg != "" {
// процедуры bj2depo ещё не установлены — не блокирует: формат обмена с учётной системой готовится, их
// доустанавливают отдельно; пока их нет, события bj в DEPO копятся необработанными
procsPending = true
notes = append(notes, "процедуры обработки bj2depo ещё не установлены — не забудьте их доустановить. Пока их нет, события bj в базе DEPO ждут обработки")
} else if !f.DepoProcCheckOK && f.DepoProcCheck != "" {
c.State = "err"
notes = append(notes, "обработка событий: "+f.DepoProcCheck)
}
if jmsg, jok := depoJobCheck(cctx, rc); !jok {
if jmsg, jok := depoJobCheck(cctx, rc); !jok && !procsPending {
c.State = "err"
notes = append(notes, "запуск обработки: "+jmsg)
}
if procsPending && c.State == "ok" {
c.State = "warn"
}
if n, _, err := depoAccPrefixInfo(cctx, f); err == nil && n == 0 && !f.IsPostgres() {
c.State = "err"
notes = append(notes, "открытых торговых счетов «"+depoAccPrefix()+"…» в учёте нет — проверьте префикс счёта")
+5 -3
View File
@@ -211,6 +211,7 @@ type WizardData struct {
Alerts bool // оповещения настроены и резервные копии включены
Modules bool // модули по лицензии настроены
Server bool // время сервера синхронизируется
Users bool // кроме администратора заведены учётные записи сотрудников
}
}
@@ -281,8 +282,9 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
}
// Главы (wizchapters.go): в пути только шаги, нужные этой установке — по лицензии и без сделанного
// установщиком. Без ?step — первый несделанный обязательный шаг, а если всё сделано — проверка.
d.Done.Users = doneUsers()
done := map[int]bool{1: d.Done.License, 2: d.Done.Postgres, 3: d.Done.Depo, 4: d.Done.Org, 5: d.Done.Quik, 6: d.Done.Crypto,
7: d.Done.NSD, 8: d.Done.Certs, 9: d.Done.Alerts, 10: d.Done.TestRun, 11: d.Done.Modules, 12: d.Done.Server}
7: d.Done.NSD, 8: d.Done.Certs, 9: d.Done.Alerts, 10: d.Done.TestRun, 11: d.Done.Modules, 12: d.Done.Server, 13: d.Done.Users}
path, _ := wizPath(h.rc, done, 0)
step := wizDefaultStep(path, done)
if v := r.URL.Query().Get("step"); v != "" {
@@ -320,7 +322,7 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) {
}
cancel()
}
if (step >= 3 && step <= 5) || step == 9 || step == 11 { // DEPO, организация, QUIK МНП, оповещения и копии, модули — общие блоки из «Настройки»
if (step >= 3 && step <= 5) || step == 9 || step == 11 || step == 13 { // DEPO, организация, QUIK МНП, оповещения и копии, модули, учётные записи — общие блоки из «Настройки»
fd := h.setupData(w, r, "")
fd.InWizard = true
d.Fancy = &fd
@@ -391,7 +393,7 @@ func depoServerEdition(ctx context.Context, f FancyMSSQLSettings) (string, bool)
// wizardSteps — шагов в мастере: 1 лицензия, 2 база bj, 3 учёт DEPO, 4 наша организация, 5 QUIK МНП,
// 6 Валидата, 7 шлюз НРД, 8 ключ и сертификаты (ключ ставится в профиль ИШ — после шлюза),
// 9 оповещения и резервные копии, 10 проверка готовности.
const wizardSteps = 12 // 11 — модули по лицензии (глава «Запуск»), 12 — время и сеть (глава «Сервер»)
const wizardSteps = 13 // 11 — модули по лицензии (глава «Запуск»), 12 — время и сеть (глава «Сервер»), 13 — учётные записи
// dsnSummary — «host:port, база name» из строки подключения PostgreSQL, без логина и пароля.
func dsnSummary(dsn string) string {
+23 -7
View File
@@ -25,16 +25,25 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// HTTPSSettings — HTTPS веб-интерфейса.
type HTTPSSettings struct {
Port int `json:"port,omitempty"` // 0 — 8443
Redirect bool `json:"redirect,omitempty"` // браузер с HTTP — на HTTPS
Custom bool `json:"custom,omitempty"` // загружен сертификат организации (не самоподписанный)
Port int `json:"port,omitempty"` // 0 — 8443
// RedirectOff — не перенаправлять браузер с HTTP на HTTPS. С 06.10.2026 перенаправление включено сразу
// (самоподписанный сертификат bj создаёт сам): пароли не должны идти по сети открытым текстом.
RedirectOff bool `json:"redirect_off,omitempty"`
Custom bool `json:"custom,omitempty"` // загружен сертификат организации (не самоподписанный)
}
// RedirectOn — браузер с HTTP перенаправляется на HTTPS.
func (h HTTPSSettings) RedirectOn() bool { return !h.RedirectOff }
// httpsUp — HTTPS действительно слушает порт: иначе перенаправление оставило бы без входа в bj.
var httpsUp atomic.Bool
func (h HTTPSSettings) port() int {
if h.Port > 0 && h.Port < 65536 {
return h.Port
@@ -148,7 +157,7 @@ func tlsCertInfo() (subject string, notAfter time.Time, err error) {
func httpsRedirect(rc *RuntimeConfig, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := rc.Snapshot().HTTPS
if h.Redirect && r.TLS == nil && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/healthz" {
if h.RedirectOn() && httpsUp.Load() && r.TLS == nil && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/healthz" {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
@@ -216,9 +225,16 @@ func startHTTPS(rc *RuntimeConfig, handler http.Handler) *http.Server {
ReadHeaderTimeout: 5 * time.Second,
TLSConfig: &tls.Config{GetCertificate: loadTLSCert, MinVersion: tls.VersionTLS12},
}
ln, err := net.Listen("tcp", srv.Addr)
if err != nil {
log.Printf("https: %v — HTTPS выключен, вход по HTTP без перенаправления", err)
return nil
}
httpsUp.Store(true)
go func() {
log.Printf("lk-gateway: listen https %s", srv.Addr)
if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
if err := srv.ServeTLS(ln, "", ""); err != nil && err != http.ErrServerClosed {
httpsUp.Store(false)
log.Printf("https: %v", err)
}
}()
@@ -232,13 +248,13 @@ func registerHTTPS(mux *http.ServeMux, rc *RuntimeConfig) {
return
}
h := rc.Snapshot().HTTPS
h.Redirect = r.FormValue("redirect") != ""
h.RedirectOff = r.FormValue("redirect") == ""
if err := rc.UpdateHTTPS(h); err != nil {
setupFlash(w, r, "HTTPS: "+err.Error())
return
}
msg := "HTTPS: перенаправление с HTTP выключено"
if h.Redirect {
if h.RedirectOn() {
msg = "HTTPS: браузеры теперь перенаправляются на https://…:" + strconv.Itoa(h.port())
}
setupFlash(w, r, msg)
@@ -18,7 +18,7 @@
<button data-sec="alerts"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></span>Оповещения<span class="ind {{if or .Settings.Alerts.SMTP.Enabled .Settings.Alerts.Zabbix.Enabled}}ok{{end}}"></span></button>
<button data-sec="update"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V8M6 12l6-6 6 6"/><path d="M5 4h14"/></svg></span>Обновления{{if .Settings.Update.Available}}<span class="ind warn"></span>{{end}}</button>
<button data-sec="backup"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="6" rx="7" ry="2.5"/><path d="M5 6v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V6"/><path d="M12 17v4M9 19l3 2 3-2"/></svg></span>Резервное копирование{{$bs := backupStatus}}{{if and .Settings.Backup.Enabled $bs}}{{if not $bs.OK}}<span class="ind err"></span>{{end}}{{else if not .Settings.Backup.Enabled}}<span class="ind warn"></span>{{end}}</button>
<button data-sec="server"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/></svg></span>Сервер: HTTPS, время, сеть{{if or (not .Settings.HTTPS.Redirect) (not timeSynced)}}<span class="ind warn"></span>{{end}}</button>
<button data-sec="server"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/></svg></span>Сервер: HTTPS, время, сеть{{if or (not .Settings.HTTPS.RedirectOn) (not timeSynced)}}<span class="ind warn"></span>{{end}}</button>
<button data-sec="license"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="12" r="4"/><path d="M12 12h9M18 12v3M15 12v2"/></svg></span>Лицензия{{if .License.Present}}<span class="ind {{if .License.Valid}}ok{{else}}err{{end}}"></span>{{end}}</button>
<button data-sec="branding"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="8.5" cy="10" r="1.5"/><path d="M21 16l-5-5L5 19"/></svg></span>Оформление{{if .Settings.Branding.LogoDataURI}}<span class="ind ok"></span>{{end}}</button>
{{if devOn}} <button data-sec="advanced"><span class="nico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg></span>Перспективные{{if .AdvancedUnlocked}}<span class="ind ok"></span>{{end}}</button>{{end}}
@@ -942,11 +942,11 @@
<h1>Сервер: HTTPS, время, сеть</h1>
{{$h := httpsCert}}
<div class="card" id="sec-https">
<h2><span class="dot {{if and .Settings.HTTPS.Redirect (not $h.Err)}}ok{{else}}warn{{end}}"></span>HTTPS — шифрование входа в bj</h2>
<h2><span class="dot {{if and .Settings.HTTPS.RedirectOn (not $h.Err)}}ok{{else}}warn{{end}}"></span>HTTPS — шифрование входа в bj</h2>
<p class="muted">Пароли администратора, баз данных, хранилища и ключ продукта не должны идти по сети открытым текстом. bj всегда слушает HTTPS: <a href="https://{{serverIP}}:{{if .Settings.HTTPS.Port}}{{.Settings.HTTPS.Port}}{{else}}8443{{end}}/admin/">https://{{serverIP}}:{{if .Settings.HTTPS.Port}}{{.Settings.HTTPS.Port}}{{else}}8443{{end}}/</a>. Модули bj и API на этом сервере работают по HTTP, как раньше.</p>
<p style="margin:8px 0">{{if $h.Err}}<span style="color:var(--err)">✗ сертификат: {{$h.Err}}</span>{{else}}Сертификат: <b>{{$h.CN}}</b>, до {{$h.Until.Format "02.01.2006"}} — {{if .Settings.HTTPS.Custom}}сертификат организации{{else}}самоподписанный: браузер один раз попросит подтвердить исключение; чтобы не просил — загрузите сертификат организации{{end}}.{{end}}</p>
<form method="post" action="/admin/setup/https" style="margin:8px 0">
<label style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="redirect" style="width:auto"{{if .Settings.HTTPS.Redirect}} checked{{end}}> <b>Открывать bj только по HTTPS</b> — браузер с http:// перенаправляется на https://</label>
<label style="display:flex;align-items:center;gap:8px;font-weight:400"><input type="checkbox" name="redirect" style="width:auto"{{if .Settings.HTTPS.RedirectOn}} checked{{end}}> <b>Открывать bj только по HTTPS</b> — браузер с http:// перенаправляется на https://</label>
<button type="submit" class="btn" style="margin-top:8px">Сохранить</button>
</form>
<details style="margin-top:8px"><summary style="cursor:pointer;color:var(--accent)">Сертификат организации</summary>
@@ -682,6 +682,57 @@
</div>
{{end}}
{{/* ============= ШАГ 13: Учётные записи (глава «Запуск», перед проверкой) ============= */}}
{{if eq .Step 13}}
<div class="card">
<h2><span class="dot {{if .Done.Users}}ok{{else}}warn{{end}}"></span>Учётные записи</h2>
<p>У каждого, кто работает с bj, — своя учётная запись: свой пароль и свои права, а в журнале видно, кто что сделал. Заведите сотрудников по ролям; права, пароли и вход через Active Directory меняются позже в разделе Настройка → Пользователи.</p>
<style>
.acc-roles{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:12px 0 4px}
.acc-role{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;border:1px solid var(--border);border-radius:12px;cursor:pointer;font-weight:400;margin:0}
.acc-role:has(input:checked){border-color:var(--accent);background:var(--accent-weak)}
.acc-role input{margin-top:3px;flex:none;width:auto}
.acc-role b{display:block;font-size:14.5px}
.acc-role small{display:block;color:var(--muted);font-size:13px;line-height:1.4;margin-top:3px}
.acc-role .acc-who{display:block;color:var(--text);font-size:12.5px;margin-top:6px}
.acc-form{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;max-width:640px;margin-top:12px}
.acc-form label{display:block;margin:0}
.acc-form label input{width:100%;box-sizing:border-box;margin-top:4px}
.acc-pw{display:flex;gap:6px;align-items:flex-end}
@media (max-width:600px){.acc-roles,.acc-form{grid-template-columns:minmax(0,1fr)}}
</style>
{{with .Fancy}}{{$U := .Users}}
<form method="post" action="/admin/setup/users" id="acc-form">
<input type="hidden" name="return_to" value="/admin/wizard?step=13"><input type="hidden" name="create" value="1">
<input type="hidden" name="active" value="1"><input type="hidden" name="source" value="local">
<h3 style="margin:16px 0 0">Кого добавить</h3>
<div class="acc-roles">
{{range $r := $U.Roles}}
<label class="acc-role"><input type="radio" name="role" value="{{$r.Key}}" {{if eq $r.Key "operator"}}checked{{end}}>
<span><b>{{$r.Title}}</b><small>{{$r.Desc}}</small>
<span class="acc-who">Уже есть: {{$n := 0}}{{range $U.Users}}{{if eq .Role $r.Key}}{{if $n}}, {{end}}{{$n = 1}}{{.Login}}{{if .Name}} ({{.Name}}){{end}}{{if not .Active}} — заблокирован{{end}}{{end}}{{end}}{{if not $n}}<span class="muted">никого</span>{{end}}</span></span></label>
{{end}}
</div>
<div class="acc-form">
<label>Логин<input type="text" name="login" placeholder="ivanov" required autocomplete="off" pattern="[^ /\@&quot;'<>]+"></label>
<label>Имя<input type="text" name="name" placeholder="Иванов Иван Иванович" autocomplete="off"></label>
<label>E-mail<input type="email" name="email" placeholder="ivanov@example.ru" autocomplete="off"></label>
<div class="acc-pw"><label style="flex:1">Пароль (от 8 символов)<input type="text" name="password" id="acc-pw" minlength="8" required autocomplete="off" spellcheck="false"></label>
<button type="button" class="btn btn-secondary" id="acc-gen" style="white-space:nowrap">Придумать</button></div>
</div>
<p class="muted" style="font-size:12.5px;margin:8px 0 0">Пароль передайте сотруднику лично; сменить его можно в разделе Настройка → Пользователи.</p>
<div style="margin-top:12px"><button type="submit" class="btn btn-ok">Создать учётную запись</button></div>
</form>
<script>(function(){
var g=document.getElementById('acc-gen'), f=document.getElementById('acc-pw'); if(!g||!f||!window.crypto) return;
g.addEventListener('click',function(){ var a='abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789', b=new Uint32Array(12), p='';
crypto.getRandomValues(b); for(var i=0;i<b.length;i++){ p+=a[b[i]%a.length]; if(i===3||i===7) p+='-'; } f.value=p; f.select(); });
})();</script>
{{end}}
{{template "wizNav" .}}
</div>
{{end}}
{{/* ============= ШАГ 10: Проверка готовности (в режиме разработки — ещё тестовая заявка) ============= */}}
{{if eq .Step 10}}
<div class="card">
@@ -730,13 +781,6 @@
})();
</script>
{{template "chainTest" .}}
<div style="margin-top:18px;padding-top:18px;border-top:1px solid var(--border)">
<h2>Письма для подключения</h2>
<p class="muted" style="margin:0 0 6px">Готовые письма с вашими реквизитами — тем, без кого подключение не закончить.</p>
{{template "letterCard" (letter "nsd-test")}}
{{template "letterCard" (letter "nsd-prod")}}
{{if .DepoLicensed}}{{template "letterCard" (letter "arqa")}}{{template "letterCard" (letter "dba")}}{{end}}
</div>
{{template "wizNav" .}}
</div>
{{end}}
+2 -2
View File
@@ -17,7 +17,7 @@ var wizChapters = []struct {
{"Сервер", []int{1, 2, 6, 12}},
{"Учёт и организация", []int{3, 4, 5}},
{"НРД", []int{7, 8}},
{"Запуск", []int{11, 9, 10}},
{"Запуск", []int{11, 9, 13, 10}},
}
// wizStepTitles — короткие названия шагов (подзаголовки внутри главы).
@@ -25,7 +25,7 @@ var wizStepTitles = map[int]string{
1: "Лицензия", 2: "База bj", 6: "Валидата", 12: "Время и сеть",
3: "Учёт DEPO", 4: "Организация", 5: "QUIK МНП",
7: "Шлюз НРД", 8: "Ключ подписи",
11: "Модули", 9: "Оповещения и копии", 10: "Проверка",
11: "Модули", 9: "Оповещения и копии", 13: "Учётные записи", 10: "Проверка",
}
// wizRequired — шаги, без которых обмен не работает: на них мастер возвращает, пока они не сделаны.