Мастер: шапка на месте; домен — только логин и пароль; модуль «1С: база мидл-офиса» (тест)
- Шапка мастера: правый блок (шлюз НРД, пользователь, тема) снова справа — после правила [hidden] скрытое меню перестало его распирать. - Домен в шаге «Учётные записи»: bj сам находит домен и контроллеры (логин с @домен, домены поиска DNS, rootDSE DNS-серверов, SRV _ldap._tcp.dc._msdcs); адрес и домен — в расширенных настройках, открываются сами, если не нашлось. Контроллеры по очереди, LDAPS → LDAP+StartTLS. - Неудачная попытка ничего не теряет: введённое (и пароль) — черновиком, форма заполнена; причины словами (неизвестный ЦС, имя в сертификате, IP вместо имени, порт закрыт, нет в DNS). - Сертификат неизвестного центра: карточка «кем выдан, отпечаток (SHA-1 как в Windows и SHA-256)» и «Доверять и подключить»; после входа bj берёт из домена сертификат центра (AIA / Certification Authorities), подписавший сертификат контроллера, — подходят все контроллеры. То же в Настройка → Пользователи при «Проверить». - Лицензия: модуль onec-middle «1С: база мидл-офиса (тест)»; раздел настроек — адрес публикации, пользователь, пароль, проверка через OData $metadata; в мастере — в «Модулях». - ЛК (callback, API, готовность) — только при модуле «ЕСИА Финанс»; карточка режима QUIK и стили настроек как в мастере. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
package lkgateway
|
||||
|
||||
// Поиск домена без вопросов (07.10.2026): человеку в мастере достаточно ввести учётную запись и пароль — домен и
|
||||
// его контроллеры bj находит сам. Откуда: суффикс в логине (ivanov@corp.example.ru), домены поиска DNS и имя
|
||||
// сервера bj; если их нет — DNS-серверы сервера bj (в сети Active Directory это обычно сами контроллеры): у
|
||||
// контроллера без пароля читается rootDSE — имя домена и самого контроллера. Список контроллеров — по DNS
|
||||
// (_ldap._tcp.dc._msdcs.<домен>). Не нашлось — мастер открывает расширенные настройки.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
// adFound — найденный домен.
|
||||
type adFound struct {
|
||||
Domain string `json:"domain"`
|
||||
DCs []string `json:"dcs"` // контроллеры (имена из DNS)
|
||||
From string `json:"from"` // откуда узнали домен — для подписи в мастере
|
||||
}
|
||||
|
||||
// adDiscover — домен и контроллеры. domain — если уже известен (из логина или расширенных настроек).
|
||||
func adDiscover(ctx context.Context, domain string) *adFound {
|
||||
if domain = strings.ToLower(strings.Trim(strings.TrimSpace(domain), ".")); domain != "" {
|
||||
dcs := adLookupDCs(ctx, domain)
|
||||
return &adFound{Domain: domain, DCs: dcs, From: "указан"}
|
||||
}
|
||||
for _, c := range adDomainCandidates() {
|
||||
if dcs := adLookupDCs(ctx, c.name); len(dcs) > 0 {
|
||||
return &adFound{Domain: c.name, DCs: dcs, From: c.from}
|
||||
}
|
||||
}
|
||||
for _, ns := range adNameservers() {
|
||||
dom, host := adRootDSEDomain(ctx, ns)
|
||||
if dom == "" {
|
||||
continue
|
||||
}
|
||||
dcs := adLookupDCs(ctx, dom)
|
||||
if len(dcs) == 0 && host != "" {
|
||||
dcs = []string{host}
|
||||
}
|
||||
return &adFound{Domain: dom, DCs: dcs, From: "DNS-сервер " + ns + " — контроллер этого домена"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type adCand struct{ name, from string }
|
||||
|
||||
// adDomainCandidates — домены поиска DNS и домен в имени сервера (с родительскими, но не короче двух уровней).
|
||||
func adDomainCandidates() []adCand {
|
||||
var out []adCand
|
||||
seen := map[string]bool{}
|
||||
add := func(name, from string) {
|
||||
name = strings.ToLower(strings.Trim(name, "."))
|
||||
for name != "" && strings.Count(name, ".") >= 1 {
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
out = append(out, adCand{name, from})
|
||||
}
|
||||
_, name, _ = strings.Cut(name, ".")
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"/run/systemd/resolve/resolv.conf", "/etc/resolv.conf"} {
|
||||
b, _ := os.ReadFile(f)
|
||||
for _, ln := range strings.Split(string(b), "\n") {
|
||||
fs := strings.Fields(ln)
|
||||
if len(fs) > 1 && (fs[0] == "search" || fs[0] == "domain") {
|
||||
for _, d := range fs[1:] {
|
||||
add(d, "домен поиска DNS сервера bj")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if h, err := os.Hostname(); err == nil {
|
||||
if _, d, ok := strings.Cut(h, "."); ok {
|
||||
add(d, "имя сервера bj")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// adNameservers — DNS-серверы сервера bj (настоящие, не локальная заглушка systemd-resolved).
|
||||
func adNameservers() []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, f := range []string{"/run/systemd/resolve/resolv.conf", "/etc/resolv.conf"} {
|
||||
b, _ := os.ReadFile(f)
|
||||
for _, ln := range strings.Split(string(b), "\n") {
|
||||
fs := strings.Fields(ln)
|
||||
if len(fs) < 2 || fs[0] != "nameserver" {
|
||||
continue
|
||||
}
|
||||
ip := net.ParseIP(fs[1])
|
||||
if ip == nil || ip.IsLoopback() || seen[fs[1]] {
|
||||
continue
|
||||
}
|
||||
seen[fs[1]] = true
|
||||
out = append(out, fs[1])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// adLookupDCs — контроллеры домена по записям DNS SRV (по приоритету).
|
||||
func adLookupDCs(ctx context.Context, domain string) []string {
|
||||
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
|
||||
defer cancel()
|
||||
var srvs []*net.SRV
|
||||
for _, name := range []string{"dc._msdcs." + domain, domain} {
|
||||
if _, s, err := net.DefaultResolver.LookupSRV(ctx, "ldap", "tcp", name); err == nil && len(s) > 0 {
|
||||
srvs = s
|
||||
break
|
||||
}
|
||||
}
|
||||
sort.SliceStable(srvs, func(i, j int) bool { return srvs[i].Priority < srvs[j].Priority })
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, s := range srvs {
|
||||
h := strings.ToLower(strings.TrimSuffix(s.Target, "."))
|
||||
if h != "" && !seen[h] {
|
||||
seen[h] = true
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// adRootDSEDomain — спросить у сервера (порт 389, без пароля) rootDSE: контроллер AD отвечает именем домена
|
||||
// (defaultNamingContext) и своим именем (dnsHostName). Не контроллер — пусто.
|
||||
func adRootDSEDomain(ctx context.Context, host string) (domain, dnsHost string) {
|
||||
d := &net.Dialer{Timeout: 3 * time.Second}
|
||||
conn, err := ldap.DialURL("ldap://"+net.JoinHostPort(host, "389"), ldap.DialWithDialer(d))
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetTimeout(4 * time.Second)
|
||||
sr, err := conn.Search(ldap.NewSearchRequest("", ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 4, false,
|
||||
"(objectClass=*)", []string{"defaultNamingContext", "dnsHostName"}, nil))
|
||||
if err != nil || len(sr.Entries) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
e := sr.Entries[0]
|
||||
return adDNToDomain(e.GetAttributeValue("defaultNamingContext")), strings.ToLower(e.GetAttributeValue("dnsHostName"))
|
||||
}
|
||||
|
||||
// adDNToDomain — DC=corp,DC=example,DC=ru → corp.example.ru.
|
||||
func adDNToDomain(dn string) string {
|
||||
var parts []string
|
||||
for _, p := range strings.Split(dn, ",") {
|
||||
k, v, ok := strings.Cut(strings.TrimSpace(p), "=")
|
||||
if ok && strings.EqualFold(k, "DC") && v != "" {
|
||||
parts = append(parts, strings.ToLower(v))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
// adDiscoverHTTP — GET /admin/setup/users/ad-discover: что bj нашёл сам (для подписи в мастере).
|
||||
func (h *setupHandlers) adDiscoverHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
f := adDiscover(ctx, r.URL.Query().Get("domain"))
|
||||
if f == nil || len(f.DCs) == 0 {
|
||||
jsonOut(w, map[string]any{"ok": false, "found": f})
|
||||
return
|
||||
}
|
||||
jsonOut(w, map[string]any{"ok": true, "found": f})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// BJ_AD_LIVE=1 go test -run TestADDiscoverLive — поиск домена в настоящей сети (только DNS и rootDSE, без паролей).
|
||||
func TestADDiscoverLive(t *testing.T) {
|
||||
if os.Getenv("BJ_AD_LIVE") == "" {
|
||||
t.Skip("BJ_AD_LIVE не задан")
|
||||
}
|
||||
f := adDiscover(context.Background(), "")
|
||||
if f == nil {
|
||||
t.Fatal("домен не найден")
|
||||
}
|
||||
t.Logf("домен %s (%s), контроллеры %v", f.Domain, f.From, f.DCs)
|
||||
}
|
||||
|
||||
func TestADCertLive(t *testing.T) {
|
||||
if os.Getenv("BJ_AD_LIVE") == "" {
|
||||
t.Skip("BJ_AD_LIVE не задан")
|
||||
}
|
||||
for _, dc := range []string{"dc1.corp.fontvielle.ru", "dc2.corp.fontvielle.ru"} {
|
||||
a := AuthSettings{ADServer: "ldaps://" + dc, ADDomain: "corp.fontvielle.ru"}
|
||||
_, err := adProbe(a)
|
||||
c, ferr := adFetchCert(a)
|
||||
if c != nil {
|
||||
t.Logf("%s: probe=%v; cert %q от %q до %s CA=%v", dc, err, c.Subject, c.Issuer, c.Until, c.IsCA)
|
||||
} else {
|
||||
t.Logf("%s: probe=%v fetch=%v", dc, err, ferr)
|
||||
}
|
||||
if err != nil {
|
||||
t.Logf(" объяснение: %s", adExplain(err, a, c))
|
||||
}
|
||||
if c != nil {
|
||||
b := a
|
||||
b.ADCAPEM = c.PEM
|
||||
_, err2 := adProbe(b)
|
||||
t.Logf(" после «Доверять»: probe=%v", err2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,12 +87,13 @@ var templateFuncs = template.FuncMap{
|
||||
v, certs := setupProbes(globalRC)
|
||||
return v != "" && certs > 0
|
||||
},
|
||||
"withRet": func(d any, ret string) map[string]any { return map[string]any{"D": d, "Ret": ret} },
|
||||
"whForm": func(w WorkHoursSettings, ret string) map[string]any { return map[string]any{"W": w, "Ret": ret} },
|
||||
"weekdayNames": func() []string { return weekdayShort },
|
||||
"depoAccPrefix": depoAccPrefix,
|
||||
"certDaysLeft": certDaysLeft,
|
||||
"currentContour": func() string { return contourOf(currentNSDProfile()) },
|
||||
"adDraft": adDraftForView, // неудачная попытка подключить домен — форма открывается заполненной
|
||||
"withRet": func(d any, ret string) map[string]any { return map[string]any{"D": d, "Ret": ret} },
|
||||
"whForm": func(w WorkHoursSettings, ret string) map[string]any { return map[string]any{"W": w, "Ret": ret} },
|
||||
"weekdayNames": func() []string { return weekdayShort },
|
||||
"depoAccPrefix": depoAccPrefix,
|
||||
"certDaysLeft": certDaysLeft,
|
||||
"currentContour": func() string { return contourOf(currentNSDProfile()) },
|
||||
"httpsCert": func() map[string]any {
|
||||
cn, until, err := tlsCertInfo()
|
||||
return map[string]any{"CN": cn, "Until": until, "Err": err, "Days": certDaysLeft(until)}
|
||||
@@ -549,7 +550,7 @@ func RegisterAdmin(mux *http.ServeMux, svc *Service, rc *RuntimeConfig, getOpts
|
||||
case p == "help/database":
|
||||
render(w, a.helpDatabase, nowPage("База данных", "help"))
|
||||
case p == "help/lk-api":
|
||||
if !moduleAllowed(globalRC, "web-cabinet") && !devMode(globalRC) { // без модуля ЛК описания его API нет
|
||||
if !moduleAllowed(globalRC, "esia-finance") && !devMode(globalRC) { // без модуля «ЕСИА Финанс» описания его API нет
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
+366
-21
@@ -5,9 +5,19 @@ package lkgateway
|
||||
// выбрать сотрудника поиском: он входит своим доменным логином и паролем, bj проверяет пароль в домене.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
@@ -16,39 +26,121 @@ import (
|
||||
func (h *setupHandlers) registerADWizard(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/admin/setup/users/ad-connect", h.adConnect)
|
||||
mux.HandleFunc("/admin/setup/users/ad-search", h.adSearch)
|
||||
mux.HandleFunc("/admin/setup/users/ad-trust", h.adTrust)
|
||||
mux.HandleFunc("/admin/setup/users/ad-discover", h.adDiscoverHTTP)
|
||||
}
|
||||
|
||||
// adConnect — POST: адрес контроллера, домен, сервисная учётная запись (для поиска сотрудников); остальные
|
||||
// настройки входа не трогает. Сразу проверяет связь: контроллер отвечает и сервисная запись входит.
|
||||
// adConnect — POST: учётная запись домена и пароль; домен и контроллер — из расширенных настроек или bj находит
|
||||
// их сам (addiscover.go). Контроллеры пробуются по очереди: сначала LDAPS (636), если порт закрыт — LDAP с
|
||||
// StartTLS (389). Остальные настройки входа не трогает.
|
||||
func (h *setupHandlers) adConnect(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
a := h.rc.Snapshot().Auth
|
||||
a.ADServer = strings.TrimSpace(r.FormValue("ad_server"))
|
||||
a.ADDomain = strings.TrimSpace(strings.TrimPrefix(r.FormValue("ad_domain"), "@"))
|
||||
a.ADBindUser = strings.TrimSpace(r.FormValue("ad_bind_user"))
|
||||
if pw := r.FormValue("ad_bind_password"); pw != "" {
|
||||
a.ADBindPassword = pw
|
||||
cur := h.rc.Snapshot().Auth
|
||||
draft := adDraftAuth()
|
||||
login := strings.TrimSpace(r.FormValue("ad_bind_user"))
|
||||
pw := r.FormValue("ad_bind_password")
|
||||
if pw == "" && draft != nil && draft.ADBindUser == login {
|
||||
pw = draft.ADBindPassword // пароль, введённый в прошлой неудачной попытке
|
||||
}
|
||||
if a.ADServer != "" && !strings.Contains(a.ADServer, "://") {
|
||||
a.ADServer = "ldaps://" + a.ADServer // как в проводнике вводят имя — по умолчанию защищённое подключение
|
||||
if pw == "" && cur.ADBindUser == login {
|
||||
pw = cur.ADBindPassword
|
||||
}
|
||||
form := adForm{Server: strings.TrimSpace(r.FormValue("ad_server")), Domain: strings.ToLower(strings.Trim(strings.TrimSpace(strings.TrimPrefix(r.FormValue("ad_domain"), "@")), "."))}
|
||||
a := cur
|
||||
a.ADBindUser, a.ADBindPassword = login, pw
|
||||
keep := func(b AuthSettings, msg string, cert *adCertInfo, adv bool) {
|
||||
form.Adv = adv || form.Server != "" || form.Domain != ""
|
||||
adDraftSet(b, msg, cert, form)
|
||||
h.usersFlash(w, r, "Домен: "+msg)
|
||||
}
|
||||
var miss []string
|
||||
for _, f := range []struct{ v, n string }{{a.ADServer, "контроллер домена"}, {a.ADDomain, "домен"}, {a.ADBindUser, "сервисная учётная запись"}, {a.ADBindPassword, "её пароль"}} {
|
||||
if f.v == "" {
|
||||
miss = append(miss, f.n)
|
||||
}
|
||||
if login == "" {
|
||||
miss = append(miss, "учётная запись домена")
|
||||
}
|
||||
if pw == "" {
|
||||
miss = append(miss, "пароль")
|
||||
}
|
||||
if len(miss) > 0 {
|
||||
h.usersFlash(w, r, "Домен: не заполнено: "+strings.Join(miss, ", "))
|
||||
keep(a, "не заполнено: "+strings.Join(miss, ", "), nil, false)
|
||||
return
|
||||
}
|
||||
domain := form.Domain
|
||||
if _, d, ok := strings.Cut(login, "@"); ok && domain == "" {
|
||||
domain = strings.ToLower(d)
|
||||
}
|
||||
var servers []string
|
||||
if form.Server != "" {
|
||||
servers = []string{form.Server}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
if domain == "" || len(servers) == 0 {
|
||||
if f := adDiscover(ctx, domain); f != nil {
|
||||
if domain == "" {
|
||||
domain = f.Domain
|
||||
}
|
||||
if len(servers) == 0 {
|
||||
servers = f.DCs
|
||||
}
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
if domain == "" {
|
||||
keep(a, "bj не нашёл домен сам: в сетевых настройках сервера bj его нет, а DNS-серверы не отвечают как контроллеры домена. Укажите домен в «Расширенных настройках» (например corp.example.ru) или учётную запись с доменом: svc-bj@corp.example.ru", nil, true)
|
||||
return
|
||||
}
|
||||
a.ADDomain = domain
|
||||
if len(servers) == 0 {
|
||||
keep(a, "контроллеры домена "+domain+" не нашлись в DNS (_ldap._tcp.dc._msdcs."+domain+") — укажите контроллер в «Расширенных настройках»", nil, true)
|
||||
return
|
||||
}
|
||||
if draft != nil && draft.ADCAPEM != "" && draft.ADDomain == domain {
|
||||
a.ADCAPEM = draft.ADCAPEM // сертификат, которому уже доверились
|
||||
}
|
||||
var errs []string
|
||||
for _, srv := range servers {
|
||||
for _, v := range adVariants(srv) {
|
||||
b := a
|
||||
b.ADServer, b.ADStartTLS = v.url, v.startTLS
|
||||
probe, cert, msg, next := adAttempt(b)
|
||||
if probe != "" {
|
||||
h.adSave(w, r, b, probe)
|
||||
return
|
||||
}
|
||||
if !next {
|
||||
keep(b, msg, cert, false)
|
||||
return
|
||||
}
|
||||
errs = append(errs, msg)
|
||||
}
|
||||
}
|
||||
keep(a, "ни один контроллер домена "+domain+" не подключился: "+strings.Join(errs, "; "), nil, true)
|
||||
}
|
||||
|
||||
type adVariant struct {
|
||||
url string
|
||||
startTLS bool
|
||||
}
|
||||
|
||||
// adVariants — как подключаться к контроллеру: адрес со схемой — как указан; имя — LDAPS, затем LDAP + StartTLS.
|
||||
func adVariants(srv string) []adVariant {
|
||||
if strings.Contains(srv, "://") {
|
||||
return []adVariant{{srv, strings.HasPrefix(srv, "ldap://")}}
|
||||
}
|
||||
return []adVariant{{"ldaps://" + srv, false}, {"ldap://" + srv, true}}
|
||||
}
|
||||
|
||||
// adAttempt — подключиться к одному контроллеру и войти сервисной записью. Успех — описание контроллера; иначе
|
||||
// причина словами, сертификат (если дело в нём) и стоит ли пробовать другой контроллер или способ.
|
||||
func adAttempt(a AuthSettings) (probe string, cert *adCertInfo, msg string, next bool) {
|
||||
probe, err := adProbe(a)
|
||||
if err != nil {
|
||||
h.usersFlash(w, r, "Домен: контроллер не отвечает — "+err.Error()+". Настройки не сохранены.")
|
||||
return
|
||||
if adCertUntrusted(err) {
|
||||
cert, _ = adFetchCert(a)
|
||||
}
|
||||
return "", cert, adExplain(err, a, cert), cert == nil
|
||||
}
|
||||
conn, err := adDial(a)
|
||||
if err == nil {
|
||||
@@ -56,15 +148,268 @@ func (h *setupHandlers) adConnect(w http.ResponseWriter, r *http.Request) {
|
||||
conn.Close()
|
||||
}
|
||||
if err != nil {
|
||||
h.usersFlash(w, r, "Домен: "+err.Error()+". Настройки не сохранены.")
|
||||
return
|
||||
return "", nil, err.Error() + " — поправьте учётную запись или пароль", false
|
||||
}
|
||||
return probe, nil, "", false
|
||||
}
|
||||
|
||||
// adSave — подключение прошло: сохранить. Если доверились сертификату самого контроллера, bj берёт из домена
|
||||
// сертификат центра, который его выдал: тогда подходят все контроллеры и их новые сертификаты.
|
||||
func (h *setupHandlers) adSave(w http.ResponseWriter, r *http.Request, a AuthSettings, probe string) {
|
||||
note := ""
|
||||
if a.ADCAPEM != "" {
|
||||
if caPEM, ca, err := adUpgradeCA(a); err == nil && caPEM != "" {
|
||||
a.ADCAPEM = caPEM
|
||||
note = " Сертификаты контроллеров проверяются по центру сертификации «" + ca + "» — подойдут все контроллеры домена."
|
||||
} else if err != nil {
|
||||
log.Printf("ad: сертификат ЦС из домена не получен (%v) — проверка по сертификату контроллера", err)
|
||||
note = " Сертификат контроллера сохранён; если его заменят, bj попросит подтвердить новый."
|
||||
}
|
||||
}
|
||||
if safeReturnPath(r.FormValue("return_to")) != "" {
|
||||
a.ADEnabled = true // в мастере подключение домена и есть включение входа через домен; в настройках — своя галочка
|
||||
}
|
||||
a.ADEnabled = true
|
||||
if err := h.rc.UpdateAuth(a); err != nil {
|
||||
adDraftSet(a, "настройки не сохранены — "+err.Error(), nil, adForm{})
|
||||
h.usersFlash(w, r, "Домен: настройки не сохранены — "+err.Error())
|
||||
return
|
||||
}
|
||||
h.usersFlash(w, r, "Домен подключён ("+probe+"): можно добавлять сотрудников из домена — они входят своим доменным логином и паролем.")
|
||||
adDraftClear()
|
||||
h.usersFlash(w, r, "Домен подключён ("+probe+"): можно добавлять сотрудников из домена — они входят своим доменным логином и паролем."+note)
|
||||
}
|
||||
|
||||
// adUpgradeCA — сохранён сертификат самого контроллера (контроллер не прислал цепочку): войти по нему и прочитать
|
||||
// в домене сертификат выдавшего центра (адрес — в сертификате, AIA; иначе — контейнер центров сертификации).
|
||||
// Берётся только центр, чьей подписью подписан сертификат контроллера. Уже центр — ничего не делает.
|
||||
func adUpgradeCA(a AuthSettings) (string, string, error) {
|
||||
blk, _ := pem.Decode([]byte(a.ADCAPEM))
|
||||
if blk == nil {
|
||||
return "", "", fmt.Errorf("сертификат не разобран")
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(blk.Bytes)
|
||||
if err != nil || leaf.IsCA {
|
||||
return "", "", err
|
||||
}
|
||||
conn, err := adDial(a)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer conn.Close()
|
||||
if err := adBindService(conn, a); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var bases []string
|
||||
for _, u := range leaf.IssuingCertificateURL {
|
||||
if dn, ok := strings.CutPrefix(u, "ldap:///"); ok {
|
||||
dn, _, _ = strings.Cut(dn, "?")
|
||||
if d, err := url.PathUnescape(dn); err == nil {
|
||||
bases = append(bases, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
if def := adDefaultBase(conn); def != "" {
|
||||
bases = append(bases, "CN=AIA,CN=Public Key Services,CN=Services,CN=Configuration,"+def,
|
||||
"CN=Certification Authorities,CN=Public Key Services,CN=Services,CN=Configuration,"+def)
|
||||
}
|
||||
for _, base := range bases {
|
||||
sr, err := conn.Search(ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 50, 10, false,
|
||||
"(objectClass=certificationAuthority)", []string{"cACertificate"}, nil))
|
||||
if err != nil || sr == nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range sr.Entries {
|
||||
for _, raw := range e.GetRawAttributeValues("cACertificate") {
|
||||
ca, err := x509.ParseCertificate(raw)
|
||||
if err != nil || !ca.IsCA || leaf.CheckSignatureFrom(ca) != nil {
|
||||
continue
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Raw})), ca.Subject.CommonName, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", fmt.Errorf("центр сертификации %q в домене не найден", leaf.Issuer.CommonName)
|
||||
}
|
||||
|
||||
// adTrust — POST: «Доверять» сертификату, который показал контроллер (черновик неудачной попытки). Отпечаток из
|
||||
// формы должен совпасть с полученным: человек подтверждает именно тот сертификат, который видел на экране.
|
||||
func (h *setupHandlers) adTrust(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
adDraft.Lock()
|
||||
a, cert, form := adDraft.a, adDraft.cert, adDraft.form
|
||||
adDraft.Unlock()
|
||||
if a == nil || cert == nil || cert.SHA256 != r.FormValue("fp") {
|
||||
h.usersFlash(w, r, "Домен: сертификат на странице устарел — нажмите «Подключить» ещё раз")
|
||||
return
|
||||
}
|
||||
b := *a
|
||||
b.ADCAPEM, b.ADSkipVerify = cert.PEM, false
|
||||
probe, c2, msg, _ := adAttempt(b)
|
||||
if probe == "" {
|
||||
adDraftSet(b, msg, c2, form)
|
||||
h.usersFlash(w, r, "Домен: "+msg)
|
||||
return
|
||||
}
|
||||
h.adSave(w, r, b, probe)
|
||||
}
|
||||
|
||||
// ---------- черновик подключения и сертификат контроллера ----------
|
||||
|
||||
// adCertInfo — сертификат, который предъявил контроллер домена (для решения «доверять»).
|
||||
type adCertInfo struct {
|
||||
Subject, Issuer, Until, SHA256 string
|
||||
SHA1 string // «Отпечаток» в окне сертификата Windows — чтобы сверить на контроллере
|
||||
IsCA bool // центр сертификации из цепочки (иначе — сертификат самого контроллера)
|
||||
PEM string // что сохранится в настройках, если доверять
|
||||
}
|
||||
|
||||
// adForm — расширенные настройки, как их ввёл человек (пусто — bj ищет сам).
|
||||
type adForm struct {
|
||||
Server, Domain string
|
||||
Adv bool // открыть расширенные настройки: домен или контроллер не нашлись сами
|
||||
}
|
||||
|
||||
// adDraftView — неудачная попытка подключить домен: что было введено и что не так (для формы).
|
||||
type adDraftView struct {
|
||||
Server string // контроллер, с которым не получилось (для карточки сертификата)
|
||||
BindUser string
|
||||
HasPassword bool
|
||||
Form adForm
|
||||
Err string
|
||||
Cert *adCertInfo
|
||||
}
|
||||
|
||||
const adDraftTTL = 2 * time.Hour
|
||||
|
||||
var adDraft struct {
|
||||
sync.Mutex
|
||||
a *AuthSettings
|
||||
err string
|
||||
cert *adCertInfo
|
||||
form adForm
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func adDraftSet(a AuthSettings, msg string, cert *adCertInfo, form adForm) {
|
||||
adDraft.Lock()
|
||||
defer adDraft.Unlock()
|
||||
adDraft.a, adDraft.err, adDraft.cert, adDraft.form, adDraft.at = &a, msg, cert, form, time.Now()
|
||||
}
|
||||
|
||||
func adDraftClear() {
|
||||
adDraft.Lock()
|
||||
defer adDraft.Unlock()
|
||||
adDraft.a, adDraft.err, adDraft.cert = nil, "", nil
|
||||
}
|
||||
|
||||
// adDraftAuth — введённое в последней неудачной попытке (nil — попытки не было или она давно).
|
||||
func adDraftAuth() *AuthSettings {
|
||||
adDraft.Lock()
|
||||
defer adDraft.Unlock()
|
||||
if adDraft.a == nil || time.Since(adDraft.at) > adDraftTTL {
|
||||
return nil
|
||||
}
|
||||
a := *adDraft.a
|
||||
return &a
|
||||
}
|
||||
|
||||
// adDraftForView — для шаблона: черновик без пароля.
|
||||
func adDraftForView() *adDraftView {
|
||||
adDraft.Lock()
|
||||
defer adDraft.Unlock()
|
||||
if adDraft.a == nil || time.Since(adDraft.at) > adDraftTTL {
|
||||
return nil
|
||||
}
|
||||
a := adDraft.a
|
||||
host := a.ADServer
|
||||
if u, err := url.Parse(a.ADServer); err == nil && u.Hostname() != "" {
|
||||
host = u.Hostname()
|
||||
}
|
||||
return &adDraftView{Server: host, BindUser: a.ADBindUser, HasPassword: a.ADBindPassword != "", Form: adDraft.form, Err: adDraft.err, Cert: adDraft.cert}
|
||||
}
|
||||
|
||||
// adCertUntrusted — TLS не прошёл из-за того, что bj не знает, кто выдал сертификат контроллера.
|
||||
func adCertUntrusted(err error) bool {
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "x509: certificate signed by unknown authority") || strings.Contains(s, "certificate is not trusted")
|
||||
}
|
||||
|
||||
// adExplain — причина неудачи словами и что сделать.
|
||||
func adExplain(err error, a AuthSettings, cert *adCertInfo) string {
|
||||
s := err.Error()
|
||||
host := a.ADServer
|
||||
if u, e := url.Parse(a.ADServer); e == nil && u.Hostname() != "" {
|
||||
host = u.Hostname()
|
||||
}
|
||||
switch {
|
||||
case adCertUntrusted(err) && cert != nil:
|
||||
return "контроллер " + host + " отвечает, но его сертификат выдан центром сертификации «" + cert.Issuer + "», которого bj пока не знает (обычно это центр сертификации самого домена). Проверьте сертификат ниже и нажмите «Доверять»."
|
||||
case adCertUntrusted(err):
|
||||
return "контроллер " + host + " отвечает, но его сертификат выдан неизвестным центром сертификации; вставьте сертификат корпоративного ЦС в Настройка → Пользователи → Active Directory"
|
||||
case strings.Contains(s, "doesn't contain any IP SANs"):
|
||||
return "контроллер указан IP-адресом, а сертификат выдан на имя — укажите имя контроллера, например dc1." + pickStr(a.ADDomain, "corp.local")
|
||||
case strings.Contains(s, "x509: certificate is valid for"):
|
||||
names := s[strings.Index(s, "x509: certificate is valid for")+len("x509: certificate is valid for "):]
|
||||
if i := strings.Index(names, ", not "); i >= 0 {
|
||||
names = names[:i]
|
||||
}
|
||||
return "сертификат контроллера выдан на другое имя (" + names + ") — укажите контроллер одним из этих имён"
|
||||
case strings.Contains(s, "certificate has expired") || strings.Contains(s, "is not yet valid"):
|
||||
return "срок сертификата контроллера " + host + " истёк или ещё не начался — обновите сертификат на контроллере или проверьте время на сервере bj"
|
||||
case strings.Contains(s, "connection refused"):
|
||||
port := "636"
|
||||
if strings.HasPrefix(a.ADServer, "ldap://") {
|
||||
port = "389"
|
||||
}
|
||||
return "контроллер " + host + " не принимает подключения на порту " + port + ": на нём не включён LDAPS или порт закрыт сетевым экраном"
|
||||
case strings.Contains(s, "no such host"):
|
||||
return "имя " + host + " не находится в DNS сервера bj — проверьте имя контроллера или укажите его полностью (dc1." + pickStr(a.ADDomain, "corp.local") + ")"
|
||||
case strings.Contains(s, "i/o timeout") || strings.Contains(s, "deadline exceeded"):
|
||||
return "контроллер " + host + " не ответил вовремя — проверьте, что сервер bj видит его по сети (порт 636)"
|
||||
}
|
||||
return "не удалось подключиться к контроллеру: " + s
|
||||
}
|
||||
|
||||
// adFetchCert — сертификат, который предъявляет контроллер: центр сертификации из цепочки (верхний, что прислал
|
||||
// контроллер), а если цепочки нет — сертификат самого контроллера. Соединение — без проверки и только для чтения
|
||||
// сертификата; пароль не отправляется.
|
||||
func adFetchCert(a AuthSettings) (*adCertInfo, error) {
|
||||
b := a
|
||||
b.ADSkipVerify, b.ADCAPEM = true, ""
|
||||
conn, err := adDial(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
st, ok := conn.TLSConnectionState()
|
||||
if !ok || len(st.PeerCertificates) == 0 {
|
||||
return nil, fmt.Errorf("контроллер не предъявил сертификат")
|
||||
}
|
||||
pcs := st.PeerCertificates
|
||||
c := pcs[0]
|
||||
for i := len(pcs) - 1; i >= 1; i-- {
|
||||
if pcs[i].IsCA {
|
||||
c = pcs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
sum := sha256.Sum256(c.Raw)
|
||||
hx := strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||
var fp []string
|
||||
for i := 0; i < len(hx); i += 2 {
|
||||
fp = append(fp, hx[i:i+2])
|
||||
}
|
||||
s1 := sha1.Sum(c.Raw)
|
||||
name := func(n pkix.Name) string {
|
||||
if n.CommonName != "" {
|
||||
return n.CommonName
|
||||
}
|
||||
return n.String()
|
||||
}
|
||||
return &adCertInfo{Subject: name(c.Subject), Issuer: name(c.Issuer), Until: c.NotAfter.In(time.Local).Format("02.01.2006"),
|
||||
SHA256: strings.Join(fp, ":"), SHA1: strings.ToLower(hex.EncodeToString(s1[:])), IsCA: c.IsCA, PEM: string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Raw}))}, nil
|
||||
}
|
||||
|
||||
// adUserView — сотрудник домена в результатах поиска.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestADDNToDomain(t *testing.T) {
|
||||
if got := adDNToDomain("DC=corp,DC=fontvielle,DC=ru"); got != "corp.fontvielle.ru" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if v := adVariants("dc1.corp.local"); len(v) != 2 || v[0].url != "ldaps://dc1.corp.local" || !v[1].startTLS {
|
||||
t.Fatalf("%+v", v)
|
||||
}
|
||||
if v := adVariants("ldaps://dc1:636"); len(v) != 1 || v[0].startTLS {
|
||||
t.Fatalf("%+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADExplain(t *testing.T) {
|
||||
a := AuthSettings{ADServer: "ldaps://10.10.10.6", ADDomain: "corp.local"}
|
||||
for _, c := range []struct{ err, want string }{
|
||||
{"x509: cannot validate certificate for 10.10.10.6 because it doesn't contain any IP SANs", "укажите имя контроллера"},
|
||||
{"x509: certificate is valid for dc1.corp.local, not dc9", "dc1.corp.local"},
|
||||
{"dial tcp 10.10.10.6:636: connect: connection refused", "порту 636"},
|
||||
{"dial tcp: lookup dc9.corp.local: no such host", "не находится в DNS"},
|
||||
} {
|
||||
if got := adExplain(errors.New(c.err), a, nil); !strings.Contains(got, c.want) {
|
||||
t.Errorf("%s → %s", c.err, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Контроллер с сертификатом неизвестного центра: введённое не теряется, «Доверять» запоминает сертификат,
|
||||
// прежние настройки входа не портятся.
|
||||
func TestADConnectKeepsDraftAndTrust(t *testing.T) {
|
||||
adDraftClear()
|
||||
defer adDraftClear()
|
||||
dc := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer dc.Close()
|
||||
srv := "ldaps://" + strings.TrimPrefix(dc.URL, "https://")
|
||||
rc := testRC(t)
|
||||
h := &setupHandlers{rc: rc}
|
||||
post := func(path string, v url.Values) string {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(v.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
switch path {
|
||||
case "/admin/setup/users/ad-connect":
|
||||
h.adConnect(w, r)
|
||||
default:
|
||||
h.adTrust(w, r)
|
||||
}
|
||||
loc, _ := url.QueryUnescape(w.Header().Get("Location"))
|
||||
return loc
|
||||
}
|
||||
loc := post("/admin/setup/users/ad-connect", url.Values{"ad_bind_user": {"svc-bj"}, "ad_bind_password": {"pw1"}, "ad_domain": {"corp.test"}, "ad_server": {srv}})
|
||||
if !strings.Contains(loc, "центром сертификации") {
|
||||
t.Fatalf("нет понятной причины: %s", loc)
|
||||
}
|
||||
d := adDraftForView()
|
||||
if d == nil || d.Cert == nil || d.BindUser != "svc-bj" || !d.HasPassword || d.Form.Server != srv || d.Form.Domain != "corp.test" {
|
||||
t.Fatalf("черновик: %+v", d)
|
||||
}
|
||||
if rc.Snapshot().Auth.ADEnabled || rc.Snapshot().Auth.ADServer != "" {
|
||||
t.Fatal("неудачная попытка изменила настройки входа")
|
||||
}
|
||||
// повтор без пароля — пароль берётся из черновика
|
||||
post("/admin/setup/users/ad-connect", url.Values{"ad_bind_user": {"svc-bj"}, "ad_domain": {"corp.test"}, "ad_server": {srv}})
|
||||
if a := adDraftAuth(); a == nil || a.ADBindPassword != "pw1" {
|
||||
t.Fatal("пароль из черновика потерян")
|
||||
}
|
||||
// чужой отпечаток не принимается
|
||||
if loc := post("/admin/setup/users/ad-trust", url.Values{"fp": {"00:11"}}); !strings.Contains(loc, "устарел") {
|
||||
t.Fatalf("чужой отпечаток: %s", loc)
|
||||
}
|
||||
fp := adDraftForView().Cert.SHA256
|
||||
loc = post("/admin/setup/users/ad-trust", url.Values{"fp": {fp}})
|
||||
if strings.Contains(loc, "центром сертификации") {
|
||||
t.Fatalf("после «Доверять» TLS всё ещё не проходит: %s", loc)
|
||||
}
|
||||
if a := adDraftAuth(); a == nil || a.ADCAPEM == "" {
|
||||
t.Fatal("сертификат не запомнен в черновике")
|
||||
}
|
||||
}
|
||||
@@ -909,11 +909,13 @@ func moduleForPath(p string) string {
|
||||
return "web-cabinet"
|
||||
case p == "/admin/setup/lk", has("/admin/setup/lk-check"), has("/admin/setup/lk-integration"), has("/admin/setup/lk-pull"):
|
||||
return "esia-finance" // интеграция с платформой ЕСИА Финанс — отдельный модуль лицензии
|
||||
case has("/admin/setup/onec"):
|
||||
return "onec-middle"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var moduleTitles = map[string]string{"esia-finance": "ЕСИА Финанс", "quik-mnp": "QUIK: неторговые поручения и обмен с DEPO", "quik-export": "Экспорт биржевой информации QUIK", "web-cabinet": "Личный кабинет клиента", "npr": "Риск-монитор НПР / УДС"}
|
||||
var moduleTitles = map[string]string{"esia-finance": "ЕСИА Финанс", "quik-mnp": "QUIK: неторговые поручения и обмен с DEPO", "quik-export": "Экспорт биржевой информации QUIK", "web-cabinet": "Личный кабинет клиента", "npr": "Риск-монитор НПР / УДС", "onec-middle": "1С: база мидл-офиса"}
|
||||
|
||||
// moduleTitle — признак лицензии словами (в мастере и на странице лицензии — не коды вроде quik-mnp).
|
||||
func moduleTitle(f string) string {
|
||||
@@ -1430,7 +1432,12 @@ func (h *setupHandlers) testAD(w http.ResponseWriter, r *http.Request) {
|
||||
if login == "" || pw == "" {
|
||||
msg, err := adProbe(a)
|
||||
if err != nil {
|
||||
h.usersFlash(w, r, "AD: недоступен — "+err.Error())
|
||||
var cert *adCertInfo
|
||||
if adCertUntrusted(err) {
|
||||
cert, _ = adFetchCert(a)
|
||||
}
|
||||
adDraftSet(a, adExplain(err, a, cert), cert, adForm{Server: a.ADServer, Domain: a.ADDomain})
|
||||
h.usersFlash(w, r, "AD: "+adExplain(err, a, cert))
|
||||
return
|
||||
}
|
||||
h.usersFlash(w, r, "AD: доступен ("+msg+"). Для проверки ролей введите логин и пароль пользователя домена.")
|
||||
|
||||
@@ -58,7 +58,7 @@ func CheckAll(ctx context.Context, o CheckOptions) SystemStatus {
|
||||
out.Checks = append(out.Checks, checkCryptoSocket(o))
|
||||
out.Checks = append(out.Checks, checkNSDAdapter(ctx, o))
|
||||
out.Checks = append(out.Checks, ishVersionStatus(ctx))
|
||||
if globalRC == nil || moduleAllowed(globalRC, "web-cabinet") || devMode(globalRC) { // без модуля ЛК нечего проверять
|
||||
if globalRC == nil || moduleAllowed(globalRC, "esia-finance") || devMode(globalRC) { // без модуля «ЕСИА Финанс» нечего проверять
|
||||
out.Checks = append(out.Checks, checkLKCallback(ctx, o))
|
||||
}
|
||||
|
||||
|
||||
@@ -555,7 +555,9 @@ func depoPollOrders(ctx context.Context, db *sql.DB, rc *RuntimeConfig) {
|
||||
|
||||
var depoHandbookWritten time.Time
|
||||
|
||||
func depoWriteHandbook(rc *RuntimeConfig) error { return depoWriteHandbookCtx(context.Background(), rc) }
|
||||
func depoWriteHandbook(rc *RuntimeConfig) error {
|
||||
return depoWriteHandbookCtx(context.Background(), rc)
|
||||
}
|
||||
|
||||
// depoWriteHandbookCtx — то же с контекстом вызывающего: после подготовки DEPO (действие человека) вход идёт и во
|
||||
// время паузы после прежнего неудачного входа — иначе справочник ждал бы своего расписания («таблица не создана»).
|
||||
|
||||
@@ -43,6 +43,7 @@ var knownFeatures = []struct{ Key, Title string }{
|
||||
{"quik-export", "QUIK: экспорт биржевой информации"},
|
||||
{"npr", "Риск-монитор НПР / УДС"},
|
||||
{"esia-finance", "ЕСИА Финанс (платформа личного кабинета)"},
|
||||
{"onec-middle", "1С: база мидл-офиса (тест)"},
|
||||
}
|
||||
|
||||
func licenseFeatureViews(rc *RuntimeConfig) []LicenseFeatureView {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package lkgateway
|
||||
|
||||
// База 1С мидл-офиса — модуль лицензии onec-middle (07.10.2026, тестовый). Пока это подключение: адрес
|
||||
// публикации базы на веб-сервере, пользователь и пароль 1С; bj проверяет их через стандартный интерфейс OData
|
||||
// ($metadata). Обмен данными с базой — следующий шаг модуля.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OneCSettings — подключение к базе 1С мидл-офиса.
|
||||
type OneCSettings struct {
|
||||
URL string `json:"url,omitempty"` // адрес публикации: http://1c-server/middle
|
||||
User string `json:"user,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
CheckedAt time.Time `json:"checked_at,omitempty"`
|
||||
CheckOK bool `json:"check_ok,omitempty"`
|
||||
CheckMsg string `json:"check_msg,omitempty"`
|
||||
}
|
||||
|
||||
func (r *RuntimeConfig) UpdateOneC(s OneCSettings) error {
|
||||
r.mu.Lock()
|
||||
r.data.OneC = s
|
||||
r.data.UpdatedAt = time.Now().UTC()
|
||||
r.mu.Unlock()
|
||||
return r.save()
|
||||
}
|
||||
|
||||
// oneCBase — адрес публикации без хвоста /odata/… и без завершающего «/».
|
||||
func oneCBase(u string) string {
|
||||
u = strings.TrimSpace(u)
|
||||
if i := strings.Index(strings.ToLower(u), "/odata/"); i >= 0 {
|
||||
u = u[:i]
|
||||
}
|
||||
u = strings.TrimRight(u, "/")
|
||||
if u != "" && !strings.Contains(u, "://") {
|
||||
u = "http://" + u
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// saveOneC — POST /admin/setup/onec: сохранить подключение и сразу проверить его.
|
||||
func (h *setupHandlers) saveOneC(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := h.rc.Snapshot().OneC
|
||||
s.URL = oneCBase(r.FormValue("onec_url"))
|
||||
s.User = strings.TrimSpace(r.FormValue("onec_user"))
|
||||
if pw := r.FormValue("onec_password"); pw != "" {
|
||||
s.Password = pw
|
||||
}
|
||||
var miss []string
|
||||
for _, f := range []struct{ v, n string }{{s.URL, "адрес публикации базы"}, {s.User, "пользователь 1С"}, {s.Password, "пароль"}} {
|
||||
if f.v == "" {
|
||||
miss = append(miss, f.n)
|
||||
}
|
||||
}
|
||||
if len(miss) > 0 {
|
||||
setupFlash(w, r, "1С: не заполнено: "+strings.Join(miss, ", "))
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
s.CheckOK, s.CheckMsg = oneCCheck(ctx, s)
|
||||
cancel()
|
||||
s.CheckedAt = time.Now()
|
||||
if err := h.rc.UpdateOneC(s); err != nil {
|
||||
setupFlash(w, r, "1С: настройки не сохранены — "+err.Error())
|
||||
return
|
||||
}
|
||||
if s.CheckOK {
|
||||
setupFlash(w, r, "1С: подключение сохранено — "+s.CheckMsg)
|
||||
return
|
||||
}
|
||||
setupFlash(w, r, "1С: подключение сохранено, но проверка не прошла — "+s.CheckMsg)
|
||||
}
|
||||
|
||||
// oneCCheck — база отвечает по стандартному интерфейсу OData и пускает пользователя.
|
||||
func oneCCheck(ctx context.Context, s OneCSettings) (bool, string) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, oneCBase(s.URL)+"/odata/standard.odata/$metadata", nil)
|
||||
if err != nil {
|
||||
return false, "адрес не разобран: " + err.Error()
|
||||
}
|
||||
req.SetBasicAuth(s.User, s.Password)
|
||||
cl := &http.Client{Transport: &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 8 * time.Second}).DialContext}}
|
||||
resp, err := cl.Do(req)
|
||||
if err != nil {
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
return false, "сервер 1С не ответил вовремя — проверьте адрес и что веб-сервер с публикацией базы доступен с сервера bj"
|
||||
}
|
||||
if strings.Contains(err.Error(), "certificate") {
|
||||
return false, "сертификат HTTPS веб-сервера 1С не доверенный — укажите адрес по http или выпустите доверенный сертификат (" + err.Error() + ")"
|
||||
}
|
||||
return false, "сервер 1С не отвечает: " + err.Error()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return false, "1С не пустила пользователя «" + s.User + "»: неверный логин или пароль, либо у пользователя нет права на внешнее подключение"
|
||||
case http.StatusNotFound:
|
||||
return false, "по адресу нет стандартного интерфейса OData: в конфигураторе 1С — Администрирование → Публикация на веб-сервере, отметьте «Публиковать стандартный интерфейс OData» и переопубликуйте базу"
|
||||
default:
|
||||
t := strings.TrimSpace(string(body))
|
||||
if len([]rune(t)) > 200 {
|
||||
t = string([]rune(t)[:200]) + "…"
|
||||
}
|
||||
return false, fmt.Sprintf("веб-сервер 1С ответил %d %s", resp.StatusCode, t)
|
||||
}
|
||||
if !strings.Contains(string(body), "edmx") {
|
||||
return false, "по адресу отвечает не 1С (нет описания OData) — проверьте адрес публикации базы"
|
||||
}
|
||||
n := strings.Count(string(body), "<EntityType ")
|
||||
if n == 0 {
|
||||
return true, "база отвечает, пользователь входит; объекты для OData ещё не открыты — их состав задаётся в базе 1С (УстановитьСоставСтандартногоИнтерфейсаOData)"
|
||||
}
|
||||
return true, fmt.Sprintf("база отвечает, пользователь входит; объектов в OData — %d", n)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package lkgateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOneCBase(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"http://1c/middle/": "http://1c/middle",
|
||||
"1c-server/middle": "http://1c-server/middle",
|
||||
"https://1c/mid/odata/standard.odata/$metadata": "https://1c/mid",
|
||||
" http://1c/mid/OData/standard.odata ": "http://1c/mid",
|
||||
} {
|
||||
if got := oneCBase(in); got != want {
|
||||
t.Errorf("oneCBase(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneCCheck(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
u, p, _ := r.BasicAuth()
|
||||
switch {
|
||||
case !strings.HasPrefix(r.URL.Path, "/mid/odata/standard.odata/$metadata"):
|
||||
http.NotFound(w, r)
|
||||
case u != "bj" || p != "секрет":
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
default:
|
||||
w.Write([]byte(`<edmx:Edmx><edmx:DataServices><Schema><EntityType Name="Catalog_Клиенты"/><EntityType Name="Document_Поручение"/></Schema></edmx:DataServices></edmx:Edmx>`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
ctx := context.Background()
|
||||
if ok, msg := oneCCheck(ctx, OneCSettings{URL: srv.URL + "/mid", User: "bj", Password: "секрет"}); !ok || !strings.Contains(msg, "объектов в OData — 2") {
|
||||
t.Fatalf("рабочее подключение: %v %q", ok, msg)
|
||||
}
|
||||
if ok, msg := oneCCheck(ctx, OneCSettings{URL: srv.URL + "/mid", User: "bj", Password: "не тот"}); ok || !strings.Contains(msg, "не пустила") {
|
||||
t.Fatalf("неверный пароль: %v %q", ok, msg)
|
||||
}
|
||||
if ok, msg := oneCCheck(ctx, OneCSettings{URL: srv.URL + "/other", User: "bj", Password: "секрет"}); ok || !strings.Contains(msg, "Публиковать стандартный интерфейс OData") {
|
||||
t.Fatalf("без OData: %v %q", ok, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneCModuleGate(t *testing.T) {
|
||||
if f := moduleForPath("/admin/setup/onec"); f != "onec-middle" {
|
||||
t.Fatalf("раздел 1С без модуля лицензии: %q", f)
|
||||
}
|
||||
if moduleTitle("onec-middle") == "onec-middle" {
|
||||
t.Fatal("нет названия модуля для лицензии")
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ var prepareRun struct {
|
||||
var prepareBoot = time.Now().UnixNano()
|
||||
|
||||
// prepareCoreFeatures — признаки лицензии, которые работают внутри ядра (отдельных файлов нет).
|
||||
var prepareCoreFeatures = []string{"quik-mnp", "esia-finance"}
|
||||
var prepareCoreFeatures = []string{"quik-mnp", "esia-finance", "onec-middle"}
|
||||
|
||||
// prepareItems — состав лицензии этой установки и состояние каждого модуля.
|
||||
func prepareItems(rc *RuntimeConfig, running, finished bool, runErr string) []PrepareItem {
|
||||
|
||||
@@ -43,6 +43,7 @@ type Settings struct {
|
||||
HTTPS HTTPSSettings `json:"https,omitempty"`
|
||||
Time TimeSettings `json:"time,omitempty"`
|
||||
WorkHours WorkHoursSettings `json:"work_hours,omitempty"` // рабочее время: запросы M2M вне него — отказ M2M06
|
||||
OneC OneCSettings `json:"onec,omitempty"` // база 1С мидл-офиса (модуль onec-middle)
|
||||
LastTest *TestRunResult `json:"last_test,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -793,8 +794,9 @@ func (r *RuntimeConfig) save() error {
|
||||
// Readiness — сводная готовность подсистемы.
|
||||
type Readiness struct {
|
||||
Name string `json:"name"`
|
||||
Ready bool `json:"ready"` // полностью настроена и проверена
|
||||
Configured bool `json:"configured"` // есть пользовательский конфиг (не stub)
|
||||
Title string `json:"title,omitempty"` // для «Обзора»: по-человечески
|
||||
Ready bool `json:"ready"` // полностью настроена и проверена
|
||||
Configured bool `json:"configured"` // есть пользовательский конфиг (не stub)
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
@@ -804,28 +806,29 @@ func (r *RuntimeConfig) ReadinessSummary() []Readiness {
|
||||
out := []Readiness{
|
||||
{
|
||||
Name: "postgres",
|
||||
Title: "База bj",
|
||||
Configured: s.Postgres.DSN != "",
|
||||
Ready: false, // настоящий ping будет в checks.go
|
||||
Message: posMsg(s.Postgres.DSN),
|
||||
},
|
||||
{
|
||||
Name: "crypto-service",
|
||||
Title: "СКЗИ «Валидата»",
|
||||
Configured: s.Crypto.Provider != "" && s.Crypto.Provider != "stub",
|
||||
Ready: false,
|
||||
Message: cryptoMsg(s.Crypto),
|
||||
},
|
||||
{
|
||||
Name: "nsd-adapter",
|
||||
Title: "Шлюз НРД",
|
||||
Configured: s.NSD.IGWBaseURL != "" && s.NSD.Profile != "",
|
||||
Ready: false,
|
||||
Message: nsdMsg(s.NSD),
|
||||
},
|
||||
{
|
||||
Name: "lk-callback",
|
||||
Configured: s.LK.CallbackURL != "",
|
||||
Ready: false,
|
||||
Message: lkMsg(s.LK),
|
||||
},
|
||||
}
|
||||
// уведомления в личный кабинет — только при модуле ЛК в лицензии: без него нечего настраивать
|
||||
if moduleAllowed(r, "esia-finance") || devMode(r) {
|
||||
out = append(out, Readiness{Name: "lk-callback", Title: "Уведомления в ЕСИА Финанс", Configured: s.LK.CallbackURL != "", Message: lkMsg(s.LK)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service
|
||||
mux.HandleFunc("/admin/setup/lk-simulate", h.lkSimulate)
|
||||
mux.HandleFunc("/admin/setup/lk-pull", h.lkPull)
|
||||
mux.HandleFunc("/admin/setup/workhours", h.saveWorkHours)
|
||||
mux.HandleFunc("/admin/setup/onec", h.saveOneC)
|
||||
mux.HandleFunc("/admin/setup/alerts-smtp", h.saveAlertsSMTP)
|
||||
mux.HandleFunc("/admin/setup/alerts-smtp-test", h.testAlertsSMTP)
|
||||
mux.HandleFunc("/admin/setup/alerts-zabbix", h.saveAlertsZabbix)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<p class="muted">PostgreSQL: подключение, схемы <code>fansy</code> / <code>fansy_staging</code> / <code>m2m_core</code>, что подгружается извне (ETL Fansy) и что пишет сама система. Учётные записи, гранты, миграции.</p>
|
||||
</div>
|
||||
</a>{{end}}
|
||||
{{if or (mod "web-cabinet") devOn}}<a href="/admin/help/lk-api" style="text-decoration:none">
|
||||
{{if or (mod "esia-finance") devOn}}<a href="/admin/help/lk-api" style="text-decoration:none">
|
||||
<div class="card" style="height:100%">
|
||||
<h2 style="color:var(--accent)">API личного кабинета →</h2>
|
||||
<p class="muted">REST-контракт ESIA Finance V1: <code>POST /api/v1/back_office/claims/</code>, GET/PATCH-операции, формат callback'ов, аутентификация Basic, примеры запросов curl.</p>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<button data-sec="nsd"><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="M3 21h18M4 21V10M20 21V10M3 10l9-6 9 6M8 21v-7M12 21v-7M16 21v-7"/></svg></span>НРД<span class="ind {{if .Settings.NSD.IGWBaseURL}}ok{{else}}warn{{end}}"></span></button>
|
||||
{{if mod "web-cabinet"}}<button data-sec="cabinet"><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="12" cy="9" r="3.2"/><path d="M6 19c.6-3 3-4.6 6-4.6s5.4 1.6 6 4.6"/><rect x="3" y="4" width="18" height="16" rx="2"/></svg></span>Кабинет клиента<span class="ind {{if .Settings.LK.APIKey}}ok{{else}}warn{{end}}"></span></button>{{end}}
|
||||
{{if mod "esia-finance"}}<button data-sec="lk"><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="9" cy="11" r="2"/><path d="M6 16c.4-1.4 1.6-2.2 3-2.2s2.6.8 3 2.2"/><path d="M15 10h4M15 13h3"/></svg></span>ЕСИА Финанс<span class="ind {{if .Settings.LK.PublicURL}}ok{{else}}warn{{end}}"></span></button>{{end}}
|
||||
{{if mod "onec-middle"}}<button data-sec="onec"><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="10" cy="6" rx="6" ry="2.5"/><path d="M4 6v10c0 1.4 2.7 2.5 6 2.5"/><path d="M16 6v4"/><path d="M4 11c0 1.4 2.7 2.5 6 2.5"/><path d="M14 16h7M18 13l3 3-3 3"/></svg></span>1С: мидл-офис<span class="ind {{if .Settings.OneC.CheckOK}}ok{{else}}warn{{end}}"></span></button>{{end}}
|
||||
<button data-sec="tests"><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="M9 3h6M10 3v6l-5 8.5A2 2 0 0 0 6.7 21h10.6a2 2 0 0 0 1.7-3.5L14 9V3"/><path d="M8 15h8"/></svg></span>Диагностика</button>
|
||||
<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 .UpdatesAvailable}}<span class="ind warn"></span>{{end}}</button>
|
||||
@@ -36,7 +37,7 @@
|
||||
<div class="grid" style="margin-top:12px">
|
||||
{{range .Readiness}}
|
||||
<div class="stat">
|
||||
<div><span class="dot {{if .Configured}}ok{{else}}err{{end}}"></span><strong>{{.Name}}</strong></div>
|
||||
<div><span class="dot {{if .Configured}}ok{{else}}err{{end}}"></span><strong>{{if .Title}}{{.Title}}{{else}}{{.Name}}{{end}}</strong></div>
|
||||
<div class="muted" style="font-size:12px;margin-top:4px">{{if .Configured}}настроено{{else}}не настроено{{end}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -182,6 +183,7 @@
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><button class="btn btn-ok" type="submit">Сохранить</button></div>
|
||||
</form>
|
||||
{{with adDraft}}{{template "adTrustCard" (withRet . "")}}{{end}}
|
||||
<form method="post" action="/admin/setup/auth/test" style="display:grid;grid-template-columns:1fr 1fr auto;gap:10px;max-width:820px;margin-top:12px;align-items:end">
|
||||
<label>Проверка: логин домена <input type="text" name="test_login" placeholder="ivanov" style="width:100%" autocomplete="off"></label>
|
||||
<label>Пароль <input type="password" name="test_password" style="width:100%" autocomplete="off"></label>
|
||||
@@ -783,6 +785,32 @@
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{/* ============ 1С: БАЗА МИДЛ-ОФИСА (модуль onec-middle, тестовый) ============ */}}
|
||||
{{if mod "onec-middle"}}
|
||||
<section class="settings-section" id="sec-onec">
|
||||
<h1>1С: база мидл-офиса</h1>
|
||||
<p class="muted" style="margin:-8px 0 16px">Связь bj с базой 1С мидл-офиса. Модуль в разработке: сейчас здесь задаётся и проверяется подключение к базе, обмен данными появится в следующих выпусках.</p>
|
||||
<div class="card">
|
||||
<h2><span class="dot {{if .Settings.OneC.CheckOK}}ok{{else}}warn{{end}}"></span>Подключение к базе 1С</h2>
|
||||
<p class="muted">bj обращается к базе через стандартный интерфейс OData. Его включают при публикации базы: конфигуратор 1С → Администрирование → Публикация на веб-сервере → «Публиковать стандартный интерфейс OData».</p>
|
||||
<form method="post" action="/admin/setup/onec" style="margin-top:12px;display:grid;gap:12px;max-width:640px">
|
||||
<div class="fld">
|
||||
<label>Адрес публикации базы</label>
|
||||
<input type="text" name="onec_url" value="{{.Settings.OneC.URL}}" placeholder="http://1c-server/middle" data-need="адрес публикации базы" style="margin-top:4px">
|
||||
<span class="muted" style="font-size:12px">Адрес, по которому база открывается в браузере, без /odata — например http://1c-server/middle.</span>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px">
|
||||
<div class="fld"><label>Пользователь 1С</label><input type="text" name="onec_user" value="{{.Settings.OneC.User}}" data-need="пользователь 1С" autocomplete="off" style="margin-top:4px"></div>
|
||||
<div class="fld"><label>Пароль</label><input type="password" name="onec_password" {{if .Settings.OneC.Password}}placeholder="сохранён — не меняйте, если тот же"{{else}}data-need="пароль"{{end}} autocomplete="new-password" style="margin-top:4px"></div>
|
||||
</div>
|
||||
<span class="muted" style="font-size:12px">Лучше завести в 1С отдельного пользователя для bj — с правом только на нужные данные.</span>
|
||||
<div><button type="submit" class="btn">Сохранить и проверить</button></div>
|
||||
</form>
|
||||
{{with .Settings.OneC}}{{if not .CheckedAt.IsZero}}<p style="margin:14px 0 0;font-size:13.5px"><b style="color:var(--{{if .CheckOK}}ok{{else}}err{{end}})">{{if .CheckOK}}✓ Подключение работает{{else}}✗ Подключение не работает{{end}}</b> — {{.CheckMsg}} <span class="muted">(проверено {{.CheckedAt.Format "02.01.2006 15:04"}})</span></p>{{end}}{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{/* ============ ДИАГНОСТИКА (id sec-tests сохранён для ссылок) ============ */}}
|
||||
<section class="settings-section" id="sec-tests">
|
||||
<h1>Диагностика</h1>
|
||||
|
||||
@@ -52,40 +52,8 @@
|
||||
.wiz-row-l { color:var(--muted); font-weight:400; font-size:14.5px; min-width:170px; margin:0; }
|
||||
.wiz-opt { display:flex; gap:8px; align-items:flex-start; font-weight:400; margin:0 0 6px; }
|
||||
.wiz-opt .muted { font-size:13px; }
|
||||
/* Учёт DEPO: вопросы по порядку (номер — счётчиком), выбор — плитками, под ними — панель выбранного способа */
|
||||
.dq { counter-reset:dq; }
|
||||
.dq-s { padding:18px 0 20px; border-top:1px solid var(--border); }
|
||||
.dq-s:first-child { border-top:0; padding-top:2px; }
|
||||
.dq-h { display:flex; align-items:center; gap:10px; font-size:15.5px; font-weight:650; margin:0 0 12px; }
|
||||
.dq-h::before { counter-increment:dq; content:counter(dq); flex:none; width:22px; height:22px; border-radius:50%; background:var(--accent-weak); color:var(--accent); display:grid; place-items:center; font-size:12px; font-weight:700; }
|
||||
.dq-tiles { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
|
||||
.dq-tile { 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; transition:border-color .15s var(--ease), background .15s var(--ease); }
|
||||
.dq-tile:hover { border-color:var(--border-strong); }
|
||||
.dq-tile:has(input:checked) { border-color:var(--accent); background:var(--accent-weak); }
|
||||
.dq-tile b { display:block; font-weight:600; font-size:14.5px; }
|
||||
.dq-tile small { display:block; color:var(--muted); font-size:13px; line-height:1.4; margin-top:3px; }
|
||||
.dq-alt { display:inline-flex; gap:8px; align-items:flex-start; margin:12px 0 0; font-weight:400; font-size:13.5px; color:var(--text-2); cursor:pointer; }
|
||||
.dq-grid { display:grid; gap:10px 12px; }
|
||||
.dq-grid label { display:block; margin:0; font-size:13.5px; font-weight:500; color:var(--text-2); }
|
||||
.dq-grid input, .dq-grid select { display:block; width:100%; box-sizing:border-box; margin-top:5px; }
|
||||
.dq-srv { grid-template-columns:minmax(0,1.5fr) minmax(0,.55fr) minmax(0,1.25fr); }
|
||||
.dq-2 { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.dq-opt { color:var(--muted); font-weight:400; }
|
||||
.dq-note { color:var(--muted); font-size:13px; margin:8px 0 0; }
|
||||
.dq-panel { margin-top:18px; }
|
||||
.dq-panel + .dq-panel { margin-top:14px; }
|
||||
.wiz-card .dq-go { margin-top:16px; border-radius:999px; padding:10px 22px; font-size:14.5px; font-weight:600; }
|
||||
.wiz-card .dq-mono { font-family:var(--mono); }
|
||||
.dq-sql { margin-top:14px; }
|
||||
.dq-sql-head { display:flex; align-items:center; gap:8px; margin:0 0 6px; justify-content:flex-start; }
|
||||
.dq-sql-st { flex:1; font-size:12.5px; color:var(--muted); }
|
||||
.dq-sql pre:empty::before { content:attr(data-msg); color:var(--muted); font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; font-size:13px; }
|
||||
.dq-sql pre { max-height:380px; min-height:140px; font-size:12px; margin:0; transition:opacity .2s; }
|
||||
.dq-sql pre.dq-busy { opacity:.5; }
|
||||
.dq.is-pg [data-ms], .dq:not(.is-pg) [data-pg] { display:none !important; }
|
||||
.dq-jobname { margin-top:14px; max-width:520px; }
|
||||
.dq-jobname label { display:block; margin:0; font-size:13.5px; font-weight:500; color:var(--text-2); }
|
||||
.dq-jobname input { display:block; width:100%; box-sizing:border-box; margin-top:5px; }
|
||||
/* установка ключа (общий блок part_keywiz) — в виде мастера */
|
||||
.keywiz ol { gap:4px !important; }
|
||||
.keywiz ol li > div > div:first-child { font-weight:500 !important; font-size:14.5px; }
|
||||
@@ -93,37 +61,8 @@
|
||||
.keywiz .card h2 { font-size:16px; margin:0 0 6px; }
|
||||
.keywiz form .btn-ok, .keywiz form .btn-secondary { justify-self:start; border-radius:999px; padding:10px 22px; font-weight:600; }
|
||||
.keywiz form .btn-ok { background:var(--accent); border-color:var(--accent); }
|
||||
.dq-box { border:1px solid var(--border); border-radius:12px; padding:14px 16px 16px; }
|
||||
.dq-box-bj { background:color-mix(in srgb, var(--accent-weak) 45%, transparent); }
|
||||
.dq-box-h { font-weight:600; font-size:14.5px; margin:0 0 10px; }
|
||||
.dq-box-h span { color:var(--muted); font-weight:400; font-size:13px; }
|
||||
.dq-lbl-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; }
|
||||
.wiz-card .dq-link { background:none; border:0; padding:0; color:var(--accent); font-size:12.5px; font-weight:500; cursor:pointer; border-radius:4px; }
|
||||
.dq-link:hover { text-decoration:underline; }
|
||||
.dq-warn { margin:10px 0 0; padding:8px 12px; border-radius:8px; background:var(--warn-weak); color:var(--text-2); font-size:13px; }
|
||||
/* ход подготовки: строки шагов со значком состояния */
|
||||
.dq-run { margin-top:16px; border:1px solid var(--border); border-radius:12px; padding:12px 16px; }
|
||||
.dq-steps { list-style:none; margin:0; padding:0; display:grid; gap:2px; }
|
||||
.dq-st { display:flex; gap:10px; align-items:flex-start; padding:6px 0; font-size:14px; }
|
||||
.dq-st-t { min-width:0; }
|
||||
.dq-st-d { color:var(--muted); font-size:12.5px; margin-top:2px; overflow-wrap:anywhere; }
|
||||
.dq-st-ic { flex:none; position:relative; width:18px; height:18px; border-radius:50%; margin-top:1px; box-sizing:border-box; }
|
||||
.dq-st.is-run .dq-st-ic { border:2px solid var(--accent-weak); border-top-color:var(--accent); animation:dq-spin .8s linear infinite; }
|
||||
.dq-st.is-run { color:var(--text); font-weight:500; }
|
||||
.dq-st.is-ok .dq-st-ic { background:var(--ok); }
|
||||
.dq-st.is-ok .dq-st-ic::after { content:""; position:absolute; left:50%; top:45%; width:4px; height:8px; border:solid #fff; border-width:0 1.8px 1.8px 0; transform:translate(-50%,-50%) rotate(45deg); }
|
||||
.dq-st.is-ok:not(.is-final) { color:var(--text-2); }
|
||||
.dq-st.is-final { font-weight:600; color:var(--ok); }
|
||||
.dq-st.is-warn .dq-st-ic, .dq-st.is-err .dq-st-ic { color:#fff; font-size:12px; font-weight:700; line-height:18px; text-align:center; }
|
||||
.dq-st.is-warn .dq-st-ic { background:var(--warn); }
|
||||
.dq-st.is-warn .dq-st-ic::after { content:"!"; }
|
||||
.dq-st.is-err .dq-st-ic { background:var(--err); }
|
||||
.dq-st.is-err .dq-st-ic::after { content:"×"; }
|
||||
.dq-st.is-err { color:var(--err); }
|
||||
.dq-run-msg { margin-top:8px; padding:10px 12px; border-radius:8px; background:var(--err-weak, rgba(200,40,40,.08)); font-size:13.5px; overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
@keyframes dq-spin { to { transform:rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .dq-st.is-run .dq-st-ic { animation:none; border-color:var(--accent); } }
|
||||
@media (max-width:600px) { .dq-tiles, .dq-srv, .dq-2 { grid-template-columns:minmax(0,1fr); } .dq-sql-head { flex-wrap:wrap; } .dq-sql-st { flex-basis:100%; } }
|
||||
.tooltip { display:inline-block; background:var(--border); color:var(--muted); border-radius:50%; width:16px; height:16px; line-height:16px; text-align:center; font-size:11px; cursor:help; margin-left:4px; }
|
||||
.help-block { background:var(--accent-weak); padding:10px 14px; margin:10px 0; font-size:13px; border-radius:10px; }
|
||||
.help-block strong { color:var(--accent); }
|
||||
@@ -748,8 +687,8 @@
|
||||
<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>
|
||||
{{$A := .Settings.Auth}}
|
||||
<details class="acc-ad" style="margin-top:20px;padding-top:14px;border-top:1px solid var(--border)">
|
||||
{{$A := .Settings.Auth}}{{$D := adDraft}}
|
||||
<details class="acc-ad" {{if $D}}open{{end}} style="margin-top:20px;padding-top:14px;border-top:1px solid var(--border)">
|
||||
<summary style="cursor:pointer;color:var(--accent);font-size:14px">Добавить сотрудника из домена (Active Directory) — необязательно</summary>
|
||||
{{if and $A.ADEnabled $A.ADBindUser}}
|
||||
<p class="muted" style="font-size:13.5px;margin:10px 0 0">Домен <b>{{$A.ADDomain}}</b> подключён. Найдите сотрудника — он будет входить в bj своим доменным логином и паролем; пароль проверяет домен.</p>
|
||||
@@ -777,16 +716,40 @@
|
||||
},350); });
|
||||
})();</script>
|
||||
{{end}}
|
||||
<details {{if not (and $A.ADEnabled $A.ADBindUser)}}open{{end}} style="margin-top:12px"><summary style="cursor:pointer;font-size:13px;color:var(--accent)">{{if and $A.ADEnabled $A.ADBindUser}}Другой домен или учётная запись{{else}}Подключить домен{{end}}</summary>
|
||||
<p class="muted" style="font-size:13px;margin:8px 0 0">Нужны адрес контроллера домена и учётная запись для поиска сотрудников — подойдёт обычная, без прав администратора домена. bj сразу проверит связь.</p>
|
||||
<form method="post" action="/admin/setup/users/ad-connect" class="acc-form">
|
||||
<details id="ad-connect" {{if or $D (not (and $A.ADEnabled $A.ADBindUser))}}open{{end}} style="margin-top:12px"><summary style="cursor:pointer;font-size:13px;color:var(--accent)">{{if and $A.ADEnabled $A.ADBindUser}}Другой домен или учётная запись{{else}}Подключить домен{{end}}</summary>
|
||||
{{$bu := $A.ADBindUser}}{{$pw := $A.ADBindPassword}}{{$fs := ""}}{{$fd := ""}}{{$adv := false}}
|
||||
{{if $D}}{{$bu = $D.BindUser}}{{$pw = ""}}{{if $D.HasPassword}}{{$pw = "введён"}}{{end}}{{$fs = $D.Form.Server}}{{$fd = $D.Form.Domain}}{{$adv = $D.Form.Adv}}{{end}}
|
||||
<p class="muted" style="font-size:13px;margin:8px 0 0">Введите учётную запись домена и пароль — домен и его контроллеры bj найдёт сам. Прав администратора домена не нужно; лучше отдельная служебная запись (например svc-bj): bj хранит её пароль, чтобы искать сотрудников.</p>
|
||||
<div id="ad-found" class="muted" style="font-size:13px;margin-top:8px" aria-live="polite"></div>
|
||||
<form method="post" action="/admin/setup/users/ad-connect" class="acc-form" id="ad-form">
|
||||
<input type="hidden" name="return_to" value="/admin/wizard?step=13">
|
||||
<label>Контроллер домена<input type="text" name="ad_server" data-need="контроллер домена" value="{{$A.ADServer}}" placeholder="dc1.corp.local или ldaps://dc1.corp.local:636" autocomplete="off"></label>
|
||||
<label>Домен<input type="text" name="ad_domain" data-need="домен" value="{{$A.ADDomain}}" placeholder="corp.local" autocomplete="off"></label>
|
||||
<label>Учётная запись для поиска<input type="text" name="ad_bind_user" data-need="учётная запись для поиска" value="{{$A.ADBindUser}}" placeholder="svc-bj" autocomplete="off"></label>
|
||||
<label>Пароль{{if $A.ADBindPassword}} <span class="muted">(задан; пусто — оставить)</span>{{end}}<input type="password" name="ad_bind_password" {{if not $A.ADBindPassword}}data-need="пароль учётной записи"{{end}} autocomplete="new-password"></label>
|
||||
<div><button type="submit" class="btn">Подключить и проверить связь</button></div>
|
||||
<label>Учётная запись домена<input type="text" name="ad_bind_user" data-need="учётная запись домена" value="{{$bu}}" placeholder="svc-bj или svc-bj@corp.example.ru" autocomplete="off"></label>
|
||||
<label>Пароль{{if $pw}} <span class="muted">({{$pw}}; пусто — оставить)</span>{{end}}<input type="password" name="ad_bind_password" {{if not $pw}}data-need="пароль"{{end}} autocomplete="new-password"></label>
|
||||
<details id="ad-adv" {{if $adv}}open{{end}} style="grid-column:1/-1;margin-top:2px"><summary style="cursor:pointer;font-size:13px;color:var(--muted)">Расширенные настройки — если домен не найдётся сам</summary>
|
||||
<div class="acc-form" style="margin-top:8px">
|
||||
<label>Домен<input type="text" name="ad_domain" value="{{$fd}}" placeholder="найдётся сам, например corp.example.ru" autocomplete="off"></label>
|
||||
<label>Контроллер домена<input type="text" name="ad_server" value="{{$fs}}" placeholder="найдётся сам, например dc1.corp.example.ru" autocomplete="off"></label>
|
||||
</div>
|
||||
<p class="muted" style="font-size:12.5px;margin:6px 0 0">Пусто — bj ищет сам. Контроллер можно указать именем (bj попробует LDAPS 636, затем LDAP 389 со StartTLS) или адресом со схемой: ldaps://dc1.corp.example.ru:636.</p>
|
||||
</details>
|
||||
<div><button type="submit" class="btn">Подключить</button></div>
|
||||
</form>
|
||||
{{if $D}}<p style="margin:12px 0 0;padding:10px 12px;border-radius:10px;background:var(--card-2);font-size:13.5px;line-height:1.45"><b style="color:var(--err)">Не подключено:</b> {{$D.Err}}{{if not $D.Cert}} Поправьте и нажмите «Подключить» ещё раз — введённое сохранено.{{end}}</p>
|
||||
{{template "adTrustCard" (withRet $D "/admin/wizard?step=13")}}{{end}}
|
||||
<script>(function(){
|
||||
var box=document.getElementById('ad-connect'), outer=box.closest('details.acc-ad'), out=document.getElementById('ad-found'), adv=document.getElementById('ad-adv'), f=document.getElementById('ad-form'), done=false;
|
||||
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&','<':'<','>':'>','"':'"'}[c]})}
|
||||
function look(){ if(done||!box.open||(outer&&!outer.open)) return; done=true; out.textContent='Ищу домен…';
|
||||
fetch('/admin/setup/users/ad-discover',{cache:'no-store',headers:{'Accept':'application/json'}}).then(function(r){return r.json()}).then(function(d){
|
||||
var x=d.found;
|
||||
if(d.ok){ out.innerHTML='<span style="color:var(--ok)">✓</span> Найден домен <b>'+esc(x.domain)+'</b>, контроллеры: '+x.dcs.map(esc).join(', ')+' <span class="muted">('+esc(x.from)+')</span>';
|
||||
f.ad_domain.placeholder=x.domain+' — найден сам'; f.ad_server.placeholder=x.dcs[0]+' — найден сам'; return; }
|
||||
out.textContent=x&&x.domain?'Домен '+x.domain+' найден, но его контроллеры не нашлись в DNS — укажите контроллер в расширенных настройках.':'Домен сам не нашёлся — укажите его в расширенных настройках или впишите учётную запись с доменом: svc-bj@corp.example.ru.';
|
||||
adv.open=true;
|
||||
}).catch(function(){ out.textContent=''; done=false; });
|
||||
}
|
||||
box.addEventListener('toggle',look); if(outer) outer.addEventListener('toggle',look); look();
|
||||
})();</script>
|
||||
</details>
|
||||
</details>
|
||||
<script>(function(){
|
||||
|
||||
@@ -89,7 +89,7 @@ body {
|
||||
.nav .ddmenu { display:none; position:absolute; top:100%; left:0; margin-top:8px; background:var(--card); border:1px solid var(--border);
|
||||
border-radius:10px; box-shadow:0 8px 30px rgba(0,0,0,.14); min-width:210px; z-index:60; padding:6px; }
|
||||
.nav .ddmenu a { display:block; padding:8px 12px; border-radius:7px; }
|
||||
.topbar-right { display:flex; align-items:center; gap:12px; flex:none; padding-left:10px; }
|
||||
.topbar-right { display:flex; align-items:center; gap:12px; flex:none; padding-left:10px; margin-left:auto; }
|
||||
@media (max-width:1100px){ .sys-status span:not(.pulse){ display:none } .sys-status{ padding:5px 8px } .topbar .clock{ display:none } .user-dd .user-role{ display:none } }
|
||||
/* Строка системного статуса — состояние читается всегда */
|
||||
.sys-status { display:inline-flex; align-items:center; gap:7px; font-size:12px; font-weight:600; color:var(--text-2); padding:5px 11px 5px 9px; border:1px solid var(--border); border-radius:20px; background:var(--card-2); white-space:nowrap; }
|
||||
@@ -276,7 +276,15 @@ label { font-size:13px; font-weight:500; }
|
||||
.settings-nav button .ind.err { background:var(--err); }
|
||||
.settings-section { display:none; }
|
||||
.settings-section.active { display:block; }
|
||||
.settings-section > h1 { margin-bottom:18px; }
|
||||
.settings-section > h1 { margin-bottom:18px; font-size:24px; font-weight:700; letter-spacing:-0.015em; }
|
||||
/* «Настройка» в стиле мастера (07.10.2026): скруглённые карточки, спокойные поля, крупные заголовки */
|
||||
.settings-section .card { padding:24px 28px 20px; border-radius:16px; }
|
||||
.settings-section .card > h2:first-child { font-size:17px; font-weight:660; letter-spacing:-0.01em; margin:0 0 6px; }
|
||||
.settings-section .card > h2:first-child + p.muted { font-size:14px; margin:0 0 14px; }
|
||||
.settings-section input:not([type=radio]):not([type=checkbox]):not([type=hidden]):not([type=file]):not([type=submit]), .settings-section select, .settings-section textarea { background:var(--card-2) !important; border:1px solid var(--border) !important; border-radius:10px !important; padding:10px 13px !important; font-size:14.5px; color:var(--text); box-sizing:border-box; }
|
||||
.settings-section input:focus, .settings-section select:focus, .settings-section textarea:focus { background:var(--card) !important; border-color:var(--accent) !important; outline:3px solid var(--accent-weak); }
|
||||
.settings-section .btn, .settings-section button { border-radius:10px; }
|
||||
.settings-section input[type=radio], .settings-section input[type=checkbox] { width:auto; }
|
||||
@media (max-width:820px) {
|
||||
.settings { grid-template-columns:1fr; }
|
||||
.settings-nav { flex-direction:row; overflow-x:auto; position:static; padding-bottom:8px; }
|
||||
@@ -327,6 +335,69 @@ details:has(> pre.copyable) { min-width:0; max-width:100%; }
|
||||
.banner-dev.on{background:color-mix(in srgb,var(--warn) 12%,var(--card))}
|
||||
.banner-dev.off{background:color-mix(in srgb,var(--ok) 10%,var(--card))}
|
||||
.banner-dev button:hover{filter:brightness(.97)}
|
||||
|
||||
/* Компоненты мастера настройки (07.10.2026 — общие: те же плитки, панели и ход по этапам в «Настройке») */
|
||||
/* Учёт DEPO: вопросы по порядку (номер — счётчиком), выбор — плитками, под ними — панель выбранного способа */
|
||||
.dq { counter-reset:dq; }
|
||||
.dq-s { padding:18px 0 20px; border-top:1px solid var(--border); }
|
||||
.dq-s:first-child { border-top:0; padding-top:2px; }
|
||||
.dq-h { display:flex; align-items:center; gap:10px; font-size:15.5px; font-weight:650; margin:0 0 12px; }
|
||||
.dq-h::before { counter-increment:dq; content:counter(dq); flex:none; width:22px; height:22px; border-radius:50%; background:var(--accent-weak); color:var(--accent); display:grid; place-items:center; font-size:12px; font-weight:700; }
|
||||
.dq-tiles { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; }
|
||||
.dq-tile { 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; transition:border-color .15s var(--ease), background .15s var(--ease); }
|
||||
.dq-tile:hover { border-color:var(--border-strong); }
|
||||
.dq-tile:has(input:checked) { border-color:var(--accent); background:var(--accent-weak); }
|
||||
.dq-tile b { display:block; font-weight:600; font-size:14.5px; }
|
||||
.dq-tile small { display:block; color:var(--muted); font-size:13px; line-height:1.4; margin-top:3px; }
|
||||
.dq-alt { display:inline-flex; gap:8px; align-items:flex-start; margin:12px 0 0; font-weight:400; font-size:13.5px; color:var(--text-2); cursor:pointer; }
|
||||
.dq-grid { display:grid; gap:10px 12px; }
|
||||
.dq-grid label { display:block; margin:0; font-size:13.5px; font-weight:500; color:var(--text-2); }
|
||||
.dq-grid input, .dq-grid select { display:block; width:100%; box-sizing:border-box; margin-top:5px; }
|
||||
.dq-srv { grid-template-columns:minmax(0,1.5fr) minmax(0,.55fr) minmax(0,1.25fr); }
|
||||
.dq-2 { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.dq-opt { color:var(--muted); font-weight:400; }
|
||||
.dq-note { color:var(--muted); font-size:13px; margin:8px 0 0; }
|
||||
.dq-panel { margin-top:18px; }
|
||||
.dq-panel + .dq-panel { margin-top:14px; }
|
||||
.dq-sql { margin-top:14px; }
|
||||
.dq-sql-head { display:flex; align-items:center; gap:8px; margin:0 0 6px; justify-content:flex-start; }
|
||||
.dq-sql-st { flex:1; font-size:12.5px; color:var(--muted); }
|
||||
.dq-sql pre:empty::before { content:attr(data-msg); color:var(--muted); font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; font-size:13px; }
|
||||
.dq-sql pre { max-height:380px; min-height:140px; font-size:12px; margin:0; transition:opacity .2s; }
|
||||
.dq-sql pre.dq-busy { opacity:.5; }
|
||||
.dq.is-pg [data-ms], .dq:not(.is-pg) [data-pg] { display:none !important; }
|
||||
.dq-jobname { margin-top:14px; max-width:520px; }
|
||||
.dq-jobname label { display:block; margin:0; font-size:13.5px; font-weight:500; color:var(--text-2); }
|
||||
.dq-jobname input { display:block; width:100%; box-sizing:border-box; margin-top:5px; }
|
||||
.dq-box { border:1px solid var(--border); border-radius:12px; padding:14px 16px 16px; }
|
||||
.dq-box-bj { background:color-mix(in srgb, var(--accent-weak) 45%, transparent); }
|
||||
.dq-box-h { font-weight:600; font-size:14.5px; margin:0 0 10px; }
|
||||
.dq-box-h span { color:var(--muted); font-weight:400; font-size:13px; }
|
||||
.dq-lbl-row { display:flex; justify-content:space-between; align-items:baseline; gap:8px; }
|
||||
.dq-link:hover { text-decoration:underline; }
|
||||
.dq-warn { margin:10px 0 0; padding:8px 12px; border-radius:8px; background:var(--warn-weak); color:var(--text-2); font-size:13px; }
|
||||
.dq-run { margin-top:16px; border:1px solid var(--border); border-radius:12px; padding:12px 16px; }
|
||||
.dq-steps { list-style:none; margin:0; padding:0; display:grid; gap:2px; }
|
||||
.dq-st { display:flex; gap:10px; align-items:flex-start; padding:6px 0; font-size:14px; }
|
||||
.dq-st-t { min-width:0; }
|
||||
.dq-st-d { color:var(--muted); font-size:12.5px; margin-top:2px; overflow-wrap:anywhere; }
|
||||
.dq-st-ic { flex:none; position:relative; width:18px; height:18px; border-radius:50%; margin-top:1px; box-sizing:border-box; }
|
||||
.dq-st.is-run .dq-st-ic { border:2px solid var(--accent-weak); border-top-color:var(--accent); animation:dq-spin .8s linear infinite; }
|
||||
.dq-st.is-run { color:var(--text); font-weight:500; }
|
||||
.dq-st.is-ok .dq-st-ic { background:var(--ok); }
|
||||
.dq-st.is-ok .dq-st-ic::after { content:""; position:absolute; left:50%; top:45%; width:4px; height:8px; border:solid #fff; border-width:0 1.8px 1.8px 0; transform:translate(-50%,-50%) rotate(45deg); }
|
||||
.dq-st.is-ok:not(.is-final) { color:var(--text-2); }
|
||||
.dq-st.is-final { font-weight:600; color:var(--ok); }
|
||||
.dq-st.is-warn .dq-st-ic, .dq-st.is-err .dq-st-ic { color:#fff; font-size:12px; font-weight:700; line-height:18px; text-align:center; }
|
||||
.dq-st.is-warn .dq-st-ic { background:var(--warn); }
|
||||
.dq-st.is-warn .dq-st-ic::after { content:"!"; }
|
||||
.dq-st.is-err .dq-st-ic { background:var(--err); }
|
||||
.dq-st.is-err .dq-st-ic::after { content:"×"; }
|
||||
.dq-st.is-err { color:var(--err); }
|
||||
.dq-run-msg { margin-top:8px; padding:10px 12px; border-radius:8px; background:var(--err-weak, rgba(200,40,40,.08)); font-size:13.5px; overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||
@keyframes dq-spin { to { transform:rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .dq-st.is-run .dq-st-ic { animation:none; border-color:var(--accent); } }
|
||||
@media (max-width:600px) { .dq-tiles, .dq-srv, .dq-2 { grid-template-columns:minmax(0,1fr); } .dq-sql-head { flex-wrap:wrap; } .dq-sql-st { flex-basis:100%; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{{/* Сертификат контроллера домена, которого bj не знает: кто выдал, отпечаток и «Доверять». Вход: withRet <adDraftView> <return_to>. */}}
|
||||
{{define "adTrustCard"}}{{with .D.Cert}}
|
||||
<div class="ad-trust" style="margin-top:14px;padding:14px 16px;border:1px solid color-mix(in srgb,var(--warn) 55%,var(--border));border-radius:12px;background:var(--warn-weak)">
|
||||
<div style="font-weight:600;font-size:14.5px">Сертификат контроллера {{$.D.Server}}</div>
|
||||
<div style="display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 14px;margin-top:8px;font-size:13.5px">
|
||||
<span class="muted">{{if .IsCA}}Центр сертификации{{else}}Выдан на{{end}}</span><span>{{.Subject}}</span>
|
||||
<span class="muted">Кем выдан</span><span>{{.Issuer}}</span>
|
||||
<span class="muted">Действует до</span><span>{{.Until}}</span>
|
||||
{{if .SHA1}}<span class="muted">Отпечаток (как в Windows)</span><code style="font-size:12px;overflow-wrap:anywhere">{{.SHA1}}</code>{{end}}
|
||||
<span class="muted">Отпечаток SHA-256</span><code style="font-size:12px;overflow-wrap:anywhere">{{.SHA256}}</code>
|
||||
</div>
|
||||
{{if .IsCA}}<p class="muted" style="font-size:12.5px;margin:10px 0 0">Если это центр сертификации вашего домена — доверьтесь ему: bj запомнит его и будет проверять по нему все контроллеры. Сверить отпечаток можно на контроллере: certlm.msc → Доверенные корневые центры сертификации → сертификат → Состав → Отпечаток.</p>
|
||||
{{else}}<p class="muted" style="font-size:12.5px;margin:10px 0 0">Контроллер не прислал сертификат центра, поэтому показан его собственный. После «Доверять» bj войдёт в домен, возьмёт оттуда сертификат центра «{{.Issuer}}» (только если именно он подписал этот сертификат) — и дальше подойдут все контроллеры домена и их новые сертификаты. Сверить отпечаток можно на контроллере: certlm.msc → Личное → Сертификаты → сертификат {{.Subject}} → Состав → Отпечаток.</p>{{end}}
|
||||
<form method="post" action="/admin/setup/users/ad-trust" style="margin-top:10px">
|
||||
{{if $.Ret}}<input type="hidden" name="return_to" value="{{$.Ret}}">{{end}}<input type="hidden" name="fp" value="{{.SHA256}}">
|
||||
<button type="submit" class="btn btn-ok">Доверять и подключить</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}{{end}}
|
||||
@@ -141,7 +141,7 @@
|
||||
<div class="dq-jobname" id="quik-acc">
|
||||
<label>Торговый счёт для поручений<input type="text" name="account" value="{{.D.Settings.Quik.Account}}" placeholder="торговый счёт фирмы в QUIK" spellcheck="false" class="dq-mono"></label>
|
||||
</div>
|
||||
<p class="dq-note" id="quik-mode-st" aria-live="polite">Выбор сохраняется сразу. Тонкая настройка — инструменты МНП, коды клиентов, поручения из терминала — в «Настройка → QUIK».</p>
|
||||
<p class="dq-note" id="quik-mode-st" aria-live="polite">Выбор сохраняется сразу.{{if .Ret}} Тонкая настройка — инструменты МНП, коды клиентов, поручения из терминала — в «Настройка → QUIK».{{end}}</p>
|
||||
<noscript><button type="submit" class="btn">Сохранить</button></noscript>
|
||||
</form>
|
||||
<p class="dq-note">{{quikRecipientsInfo}} После первого заполнения справочника депозитариев-получателей попросите администратора QUIK перезапустить модуль неторговых поручений — QUIK читает справочник только при запуске.</p>
|
||||
|
||||
@@ -32,7 +32,7 @@ var wizStepTitles = map[int]string{
|
||||
var wizRequired = map[int]bool{1: true, 2: true, 3: true, 4: true, 5: true, 6: true, 7: true, 8: true}
|
||||
|
||||
// wizModules — лицензируемые модули, которые настраиваются в главе «Запуск».
|
||||
var wizModules = []string{"web-cabinet", "esia-finance", "quik-export", "npr"}
|
||||
var wizModules = []string{"web-cabinet", "esia-finance", "quik-export", "npr", "onec-middle"}
|
||||
|
||||
// WizStep — шаг в пути мастера.
|
||||
type WizStep struct {
|
||||
@@ -202,6 +202,19 @@ func wizModuleViews(rc *RuntimeConfig, s Settings) []WizModule {
|
||||
m.Link = "/admin/setup?from=wizard#quikexport"
|
||||
m.Ready = s.NPR.Enabled && s.NPR.DSN != ""
|
||||
m.Status = map[bool]string{true: "включён, база экспорта QUIK подключена", false: "не включён"}[m.Ready]
|
||||
case "onec-middle":
|
||||
m.Title = "1С: база мидл-офиса"
|
||||
m.What = "Связь с базой 1С мидл-офиса (модуль в разработке): пока — подключение к опубликованной базе и его проверка."
|
||||
m.Link = "/admin/setup?from=wizard#onec"
|
||||
m.Ready = s.OneC.CheckOK
|
||||
switch {
|
||||
case m.Ready:
|
||||
m.Status = "подключение к базе 1С проверено"
|
||||
case s.OneC.URL != "":
|
||||
m.Status = "адрес базы задан, проверка не прошла"
|
||||
default:
|
||||
m.Status = "не настроено: адрес публикации базы, пользователь и пароль 1С"
|
||||
}
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user