Пароль домена не попадает в страницу; домен в мастере — открыт, крупно, группы; справочник DEPO без переводов; 1С — бэк-офис

- Безопасность: в мастере подпись к паролю выводила сам сохранённый пароль служебной записи домена — теперь только «задан/введён» (тест: пароль не попадает в HTML). Список сессий в настройках больше не содержит токены (по ним можно было войти под чужим именем) — короткий ID.
- Домен: после подключения и «Доверять» блок остаётся открытым, страница возвращается к нему; карточка «Домен подключён» и «Найден домен» — крупно; поиск сотрудников и групп; группе — доступ с ролью (ADGroups), список групп с «убрать».
- «Обмен с DEPO»: справочник FTV_M2M_HANDBOOK считается и когда переводов ещё нет (на свежей установке писало «не создана»).
- Модуль 1С: onec-back «1С: база бэк-офиса (тест)» вместо мидл-офиса (в лицензиях не выдавался).

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-07 01:19:30 +03:00
parent 13a7be17ef
commit 73b246831c
13 changed files with 238 additions and 71 deletions
+84 -11
View File
@@ -28,6 +28,7 @@ func (h *setupHandlers) registerADWizard(mux *http.ServeMux) {
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)
mux.HandleFunc("/admin/setup/users/ad-group", h.adGroupSave)
}
// adConnect — POST: учётная запись домена и пароль; домен и контроллер — из расширенных настроек или bj находит
@@ -412,15 +413,18 @@ func adFetchCert(a AuthSettings) (*adCertInfo, error) {
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 — сотрудник домена в результатах поиска.
// adUserView — сотрудник или группа домена в результатах поиска.
type adUserView struct {
Kind string `json:"kind"` // user | group
Login string `json:"login"` // sAMAccountName
Name string `json:"name"`
Email string `json:"email,omitempty"`
Have bool `json:"have,omitempty"` // уже есть в bj
Desc string `json:"desc,omitempty"` // описание группы
Have bool `json:"have,omitempty"` // сотрудник уже есть в bj / группе уже дан доступ
}
// adSearch — GET ?q=: сотрудники домена по фамилии, логину или почте (до 20, только включённые учётные записи).
// adSearch — GET ?q=: сотрудники (только включённые учётные записи) и группы домена по фамилии, логину, почте
// или названию группы (до 25).
func (h *setupHandlers) adSearch(w http.ResponseWriter, r *http.Request) {
a := h.rc.Snapshot().Auth
q := strings.TrimSpace(r.URL.Query().Get("q"))
@@ -432,7 +436,7 @@ func (h *setupHandlers) adSearch(w http.ResponseWriter, r *http.Request) {
jsonOut(w, map[string]any{"ok": true, "users": []adUserView{}})
return
}
users, err := adSearchUsers(a, q, 20)
users, err := adSearchUsers(a, q, 25)
if err != nil {
jsonOut(w, map[string]any{"ok": false, "error": err.Error()})
return
@@ -455,20 +459,89 @@ func adSearchUsers(a AuthSettings, q string, limit int) ([]adUserView, error) {
base = adDefaultBase(conn)
}
e := ldap.EscapeFilter(q)
filter := fmt.Sprintf("(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(|(sAMAccountName=*%s*)(displayName=*%s*)(sn=*%s*)(mail=*%s*)))", e, e, e, e)
filter := fmt.Sprintf("(|(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(|(sAMAccountName=*%s*)(displayName=*%s*)(sn=*%s*)(mail=*%s*)))"+
"(&(objectCategory=group)(|(sAMAccountName=*%s*)(cn=*%s*)(description=*%s*))))", e, e, e, e, e, e, e)
sr, err := conn.Search(ldap.NewSearchRequest(base, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, limit, 10, false,
filter, []string{"sAMAccountName", "displayName", "mail"}, nil))
filter, []string{"sAMAccountName", "displayName", "mail", "cn", "description", "objectClass"}, nil))
if err != nil && (sr == nil || len(sr.Entries) == 0) { // превышение лимита — не ошибка, берём найденное
return nil, fmt.Errorf("поиск в домене: %v", err)
}
var out []adUserView
var users, groups []adUserView
for _, en := range sr.Entries {
u := adUserView{Login: en.GetAttributeValue("sAMAccountName"), Name: en.GetAttributeValue("displayName"), Email: en.GetAttributeValue("mail")}
u := adUserView{Kind: "user", Login: en.GetAttributeValue("sAMAccountName"), Name: en.GetAttributeValue("displayName"), Email: en.GetAttributeValue("mail")}
if u.Login == "" {
continue
}
u.Have = globalUsers.Get(u.Login) != nil
out = append(out, u)
for _, c := range en.GetAttributeValues("objectClass") {
if strings.EqualFold(c, "group") {
u.Kind, u.Name, u.Email, u.Desc = "group", pickStr(en.GetAttributeValue("cn"), u.Login), "", en.GetAttributeValue("description")
}
return out, nil
}
if u.Kind == "group" {
u.Have = adGroupRoleOf(a, u.Login) != ""
groups = append(groups, u)
continue
}
u.Have = globalUsers.Get(u.Login) != nil
users = append(users, u)
}
return append(users, groups...), nil
}
// Name — имя группы для экрана (из DN — CN).
func (g ADGroupRole) Name() string { return dnCN(g.Group) }
// ADServerHost — имя контроллера без схемы и порта (для экрана).
func (a AuthSettings) ADServerHost() string {
if u, err := url.Parse(a.ADServer); err == nil && u.Hostname() != "" {
return u.Hostname()
}
return a.ADServer
}
// adGroupRoleOf — роль, которую даёт группа домена (пусто — группе доступ не дан).
func adGroupRoleOf(a AuthSettings, group string) string {
for _, g := range a.ADGroups {
if strings.EqualFold(dnCN(g.Group), group) || strings.EqualFold(g.Group, group) {
return g.Role
}
}
return ""
}
// adGroupSave — POST: всем сотрудникам группы домена — вход в bj с ролью (remove=1 — убрать группу). Сотрудник
// входит своим доменным логином; учётная запись в bj заводится при первом входе.
func (h *setupHandlers) adGroupSave(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
a := h.rc.Snapshot().Auth
g := strings.TrimSpace(r.FormValue("group"))
if g == "" {
h.usersFlash(w, r, "Группа домена: не выбрана")
return
}
var keep []ADGroupRole
for _, x := range a.ADGroups {
if !strings.EqualFold(dnCN(x.Group), g) && !strings.EqualFold(x.Group, g) {
keep = append(keep, x)
}
}
msg := "Группа «" + g + "» убрана: её сотрудники больше не входят в bj (кроме заведённых отдельно)."
if r.FormValue("remove") == "" {
rl, ok := roleByKey(r.FormValue("role"))
if !ok || !roleAvailable(rl.Key) {
h.usersFlash(w, r, "Группа домена: выберите роль")
return
}
keep = append(keep, ADGroupRole{Group: g, Role: rl.Key})
msg = "Группа «" + g + "»: все её сотрудники входят в bj своим доменным логином и паролем с ролью «" + rl.Title + "»."
}
a.ADGroups = keep
if err := h.rc.UpdateAuth(a); err != nil {
h.usersFlash(w, r, "Группа домена: "+err.Error())
return
}
h.usersFlash(w, r, msg)
}
+25
View File
@@ -87,3 +87,28 @@ func TestADConnectKeepsDraftAndTrust(t *testing.T) {
t.Fatal("сертификат не запомнен в черновике")
}
}
// Группа домена из мастера: доступ с ролью, повтор меняет роль (без дублей), «убрать» — снимает.
func TestADGroupSave(t *testing.T) {
rc := testRC(t)
h := &setupHandlers{rc: rc}
post := func(v url.Values) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/admin/setup/users/ad-group", strings.NewReader(v.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
h.adGroupSave(w, r)
}
post(url.Values{"group": {"Depo"}, "role": {"operator"}})
post(url.Values{"group": {"depo"}, "role": {"viewer"}})
g := rc.Snapshot().Auth.ADGroups
if len(g) != 1 || g[0].Role != "viewer" {
t.Fatalf("группы: %+v", g)
}
if adGroupRoleOf(rc.Snapshot().Auth, "DEPO") != "viewer" {
t.Fatal("роль группы не находится")
}
post(url.Values{"group": {"Depo"}, "remove": {"1"}})
if len(rc.Snapshot().Auth.ADGroups) != 0 {
t.Fatal("группа не убрана")
}
}
+35 -9
View File
@@ -15,6 +15,7 @@ package lkgateway
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
@@ -479,20 +480,45 @@ func (s *sessionStore) DropUser(login string) {
s.mu.Unlock()
}
func (s *sessionStore) Active() []*Session {
// SessionView — сессия для списка в настройках: без токена (по нему можно войти под чужим именем), вместо него — ID.
type SessionView struct {
ID, Login, IP, UA string
Created, Seen time.Time
}
// sessionID — короткий отпечаток токена: им список ссылается на сессию, сам токен в страницу не попадает.
func sessionID(token string) string {
sum := sha256.Sum256([]byte("bj-session-id:" + token))
return hex.EncodeToString(sum[:9])
}
func (s *sessionStore) Active() []SessionView {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]*Session, 0, len(s.list))
for _, x := range s.list {
out := make([]SessionView, 0, len(s.list))
for t, x := range s.list {
if time.Since(x.Created) < sessionMaxLife {
cp := *x
out = append(out, &cp)
out = append(out, SessionView{ID: sessionID(t), Login: x.Login, IP: x.IP, UA: x.UA, Created: x.Created, Seen: x.Seen})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Seen.After(out[j].Seen) })
return out
}
// DropID — завершить сессию по её ID из списка.
func (s *sessionStore) DropID(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
for t := range s.list {
if id != "" && sessionID(t) == id {
delete(s.list, t)
s.save()
return true
}
}
return false
}
// ---------- защита от перебора ----------
var loginFails = struct {
@@ -910,12 +936,12 @@ func moduleForPath(p string) string {
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 "onec-back"
}
return ""
}
var moduleTitles = map[string]string{"esia-finance": "ЕСИА Финанс", "quik-mnp": "QUIK: неторговые поручения и обмен с DEPO", "quik-export": "Экспорт биржевой информации QUIK", "web-cabinet": "Личный кабинет клиента", "npr": "Риск-монитор НПР / УДС", "onec-middle": "1С: база мидл-офиса"}
var moduleTitles = map[string]string{"esia-finance": "ЕСИА Финанс", "quik-mnp": "QUIK: неторговые поручения и обмен с DEPO", "quik-export": "Экспорт биржевой информации QUIK", "web-cabinet": "Личный кабинет клиента", "npr": "Риск-монитор НПР / УДС", "onec-back": "1С: база бэк-офиса"}
// moduleTitle — признак лицензии словами (в мастере и на странице лицензии — не коды вроде quik-mnp).
func moduleTitle(f string) string {
@@ -1186,7 +1212,7 @@ type UsersView struct {
Roles []roleDef // доступные при текущей лицензии
Hidden []struct{ Title, Missing string } // роли, недоступные без модулей
Modules []struct{ Key, Title string } // подключённые модули
Sessions []*Session
Sessions []SessionView
Open bool
Me *User
ADRecheck ADRecheckStatus
@@ -1373,7 +1399,7 @@ func (h *setupHandlers) dropSession(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
globalSessions.Drop(r.FormValue("token"))
globalSessions.DropID(r.FormValue("id"))
h.usersFlash(w, r, "Сессия завершена.")
}
+11 -10
View File
@@ -138,6 +138,17 @@ func depoQueueLoad(ctx context.Context, rc *RuntimeConfig, limit int) ([]depoQue
out = append(out, r)
}
rows.Close()
// справочник участников — и когда переводов ещё нет (свежая установка): иначе страница писала «не создана»
if depoTableExists(ctx, db, "FTV_M2M_HANDBOOK") {
sum.HasHandbook = true
_ = db.QueryRowContext(ctx, fsql.QQu01).Scan(&sum.Handbook)
if depoHasColumn(ctx, db, "FTV_M2M_HANDBOOK", "HANDBOOK_AT") {
var t sql.NullTime
if db.QueryRowContext(ctx, fsql.QQu02).Scan(&t) == nil && t.Valid {
sum.HandbookAt = t.Time
}
}
}
if len(out) == 0 {
return out, sum, nil
}
@@ -193,16 +204,6 @@ func depoQueueLoad(ctx context.Context, rc *RuntimeConfig, limit int) ([]depoQue
er.Close()
}
}
if depoTableExists(ctx, db, "FTV_M2M_HANDBOOK") {
sum.HasHandbook = true
_ = db.QueryRowContext(ctx, fsql.QQu01).Scan(&sum.Handbook)
if depoHasColumn(ctx, db, "FTV_M2M_HANDBOOK", "HANDBOOK_AT") {
var t sql.NullTime
if db.QueryRowContext(ctx, fsql.QQu02).Scan(&t) == nil && t.Valid {
sum.HandbookAt = t.Time
}
}
}
for i := range out {
sum.Total++
if out[i].Pending > 0 {
+1 -1
View File
@@ -43,7 +43,7 @@ var knownFeatures = []struct{ Key, Title string }{
{"quik-export", "QUIK: экспорт биржевой информации"},
{"npr", "Риск-монитор НПР / УДС"},
{"esia-finance", "ЕСИА Финанс (платформа личного кабинета)"},
{"onec-middle", "1С: база мидл-офиса (тест)"},
{"onec-back", "1С: база бэк-офиса (тест)"},
}
func licenseFeatureViews(rc *RuntimeConfig) []LicenseFeatureView {
+2 -2
View File
@@ -1,6 +1,6 @@
package lkgateway
// База 1С мидл-офиса — модуль лицензии onec-middle (07.10.2026, тестовый). Пока это подключение: адрес
// База 1С бэк-офиса — модуль лицензии onec-back (07.10.2026, тестовый). Пока это подключение: адрес
// публикации базы на веб-сервере, пользователь и пароль 1С; bj проверяет их через стандартный интерфейс OData
// ($metadata). Обмен данными с базой — следующий шаг модуля.
@@ -15,7 +15,7 @@ import (
"time"
)
// OneCSettings — подключение к базе 1С мидл-офиса.
// OneCSettings — подключение к базе 1С бэк-офиса.
type OneCSettings struct {
URL string `json:"url,omitempty"` // адрес публикации: http://1c-server/middle
User string `json:"user,omitempty"`
+2 -2
View File
@@ -47,10 +47,10 @@ func TestOneCCheck(t *testing.T) {
}
func TestOneCModuleGate(t *testing.T) {
if f := moduleForPath("/admin/setup/onec"); f != "onec-middle" {
if f := moduleForPath("/admin/setup/onec"); f != "onec-back" {
t.Fatalf("раздел 1С без модуля лицензии: %q", f)
}
if moduleTitle("onec-middle") == "onec-middle" {
if moduleTitle("onec-back") == "onec-back" {
t.Fatal("нет названия модуля для лицензии")
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ var prepareRun struct {
var prepareBoot = time.Now().UnixNano()
// prepareCoreFeatures — признаки лицензии, которые работают внутри ядра (отдельных файлов нет).
var prepareCoreFeatures = []string{"quik-mnp", "esia-finance", "onec-middle"}
var prepareCoreFeatures = []string{"quik-mnp", "esia-finance", "onec-back"}
// prepareItems — состав лицензии этой установки и состояние каждого модуля.
func prepareItems(rc *RuntimeConfig, running, finished bool, runErr string) []PrepareItem {
+1 -1
View File
@@ -43,7 +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)
OneC OneCSettings `json:"onec,omitempty"` // база 1С бэк-офиса (модуль onec-back)
LastTest *TestRunResult `json:"last_test,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -15,7 +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}}
{{if mod "onec-back"}}<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>
@@ -203,7 +203,7 @@
<h2>Активные сессии ({{len $U.Sessions}})</h2>
{{if $U.Sessions}}<table style="font-size:12.5px"><thead><tr><th>Пользователь</th><th>Вход</th><th>Активность</th><th>Адрес</th><th>Браузер</th><th></th></tr></thead><tbody>
{{range $U.Sessions}}<tr><td><b>{{.Login}}</b></td><td>{{msk .Created}}</td><td>{{msk .Seen}}</td><td><code>{{.IP}}</code></td><td class="muted" style="font-size:11px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{.UA}}</td>
<td><form method="post" action="/admin/setup/users/session-drop"><input type="hidden" name="token" value="{{.Token}}"><button class="btn" type="submit">Завершить</button></form></td></tr>{{end}}
<td><form method="post" action="/admin/setup/users/session-drop"><input type="hidden" name="id" value="{{.ID}}"><button class="btn" type="submit">Завершить</button></form></td></tr>{{end}}
</tbody></table>{{else}}<p class="muted">Сессий нет.</p>{{end}}
</div>
</section>
@@ -785,11 +785,11 @@
</section>
{{end}}
{{/* ============ 1С: БАЗА МИДЛ-ОФИСА (модуль onec-middle, тестовый) ============ */}}
{{if mod "onec-middle"}}
{{/* ============ 1С: БАЗА БЭК-ОФИСА (модуль onec-back, тестовый) ============ */}}
{{if mod "onec-back"}}
<section class="settings-section" id="sec-onec">
<h1>1С: база мидл-офиса</h1>
<p class="muted" style="margin:-8px 0 16px">Связь bj с базой 1С мидл-офиса. Модуль в разработке: сейчас здесь задаётся и проверяется подключение к базе, обмен данными появится в следующих выпусках.</p>
<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>
@@ -663,6 +663,31 @@
.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}
.ad-st{display:flex;gap:12px;align-items:center;margin:12px 0 4px;padding:12px 16px;border-radius:12px;border:1px solid var(--border);background:var(--card-2);max-width:640px;box-sizing:border-box}
.ad-st b{display:block;font-size:15px;font-weight:650}
.ad-st small{display:block;color:var(--muted);font-size:13px;line-height:1.4;margin-top:2px;overflow-wrap:anywhere}
.ad-st-ic{flex:none;position:relative;width:26px;height:26px;border-radius:50%;background:var(--border-strong)}
.ad-st-ok{background:color-mix(in srgb,var(--ok) 9%,var(--card));border-color:color-mix(in srgb,var(--ok) 40%,var(--border))}
.ad-st-ok .ad-st-ic{background:var(--ok)}
.ad-st-ok .ad-st-ic::after{content:"";position:absolute;left:50%;top:45%;width:6px;height:11px;border:solid #fff;border-width:0 2.2px 2.2px 0;transform:translate(-50%,-50%) rotate(45deg)}
.ad-st-warn{background:var(--warn-weak);border-color:color-mix(in srgb,var(--warn) 45%,var(--border))}
.ad-st-warn .ad-st-ic{background:var(--warn)}
.ad-st-warn .ad-st-ic::after{content:"!";position:absolute;inset:0;display:grid;place-items:center;color:#fff;font-weight:700;font-size:15px}
.ad-st-spin{background:none;border:2.5px solid var(--accent-weak);border-top-color:var(--accent);box-sizing:border-box;animation:dq-spin .8s linear infinite}
.ad-q-l{display:block;margin:14px 0 0;font-weight:600;font-size:14px;max-width:640px}
.ad-q-l input{display:block;width:100%;box-sizing:border-box;margin-top:6px;font-size:15px !important;font-weight:400}
.ad-res{list-style:none;margin:8px 0 0;padding:0;display:grid;gap:6px;max-width:640px}
.ad-hit{display:flex;gap:10px;align-items:baseline;width:100%;text-align:left;padding:10px 14px;border:1px solid var(--border);border-radius:10px;background:var(--card);color:var(--text);font:inherit;font-size:14px;cursor:pointer}
.ad-hit:hover:not(:disabled){border-color:var(--border-strong)}
.ad-hit.is-sel{border-color:var(--accent);background:var(--accent-weak)}
.ad-hit:disabled{opacity:.6;cursor:default}
.ad-kind{flex:none;font-size:11px;font-weight:600;padding:2px 8px;border-radius:999px;background:var(--card-2);color:var(--muted);border:1px solid var(--border)}
.ad-kind-g{background:var(--accent-weak);color:var(--accent);border-color:transparent}
.ad-pick{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:12px;padding:12px 14px;border:1px solid var(--accent);border-radius:12px;background:color-mix(in srgb,var(--accent-weak) 50%,var(--card));max-width:640px;box-sizing:border-box}
.ad-pick[hidden]{display:none}
.ad-groups{margin-top:16px;max-width:640px}
.ad-grp{display:flex;justify-content:space-between;align-items:center;gap:10px;padding:8px 12px;border:1px solid var(--border);border-radius:10px;margin-top:6px;font-size:14px}
.ad-grp form{margin:0}
@media (max-width:600px){.acc-roles,.acc-form{grid-template-columns:minmax(0,1fr)}}
</style>
{{with .Fancy}}{{$U := .Users}}
@@ -687,42 +712,58 @@
<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}}{{$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>
<input type="text" id="acc-ad-q" placeholder="Фамилия, логин или почта" autocomplete="off" style="width:100%;max-width:420px;margin-top:10px">
<div id="acc-ad-st" class="muted" style="font-size:13px;margin-top:6px"></div>
<ul id="acc-ad-res" style="list-style:none;margin:8px 0 0;padding:0;display:grid;gap:6px;max-width:640px"></ul>
<form method="post" action="/admin/setup/users" id="acc-ad-form" hidden style="margin-top:12px;display:flex;gap:10px;align-items:end;flex-wrap:wrap">
<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="ad">
{{$A := .Settings.Auth}}{{$D := adDraft}}{{$on := and $A.ADEnabled $A.ADBindUser}}
<details class="acc-ad" id="acc-ad" {{if or $D $on}}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 $on}}
<div class="ad-st ad-st-ok"><span class="ad-st-ic"></span><div><b>Домен {{$A.ADDomain}} подключён</b><small>контроллер {{$A.ADServerHost}} · служебная запись {{$A.ADBindUser}}{{if $A.ADCAPEM}} · сертификат контроллера проверяется{{end}}</small></div></div>
<label class="ad-q-l">Найдите сотрудника или группу<input type="search" id="acc-ad-q" placeholder="Фамилия, логин, почта или название группы" autocomplete="off"></label>
<div id="acc-ad-st" class="muted" style="font-size:13px;margin-top:6px" aria-live="polite"></div>
<ul id="acc-ad-res" class="ad-res"></ul>
<form method="post" action="/admin/setup/users" id="acc-ad-form" class="ad-pick" hidden>
<input type="hidden" name="return_to" value="/admin/wizard?step=13"><input type="hidden" name="section" value="acc-ad"><input type="hidden" name="create" value="1"><input type="hidden" name="active" value="1"><input type="hidden" name="source" value="ad">
<input type="hidden" name="login"><input type="hidden" name="name"><input type="hidden" name="email">
<span>Добавить <b id="acc-ad-who"></b> с ролью</span>
<span>Сотрудник <b id="acc-ad-who"></b> — входит своим доменным логином и паролем, с ролью</span>
<select name="role">{{range .Users.Roles}}<option value="{{.Key}}" {{if eq .Key "operator"}}selected{{end}}>{{.Title}}</option>{{end}}</select>
<button type="submit" class="btn btn-ok">Добавить из домена</button>
<button type="submit" class="btn btn-ok">Добавить сотрудника</button>
</form>
<form method="post" action="/admin/setup/users/ad-group" id="acc-ad-gform" class="ad-pick" hidden>
<input type="hidden" name="return_to" value="/admin/wizard?step=13"><input type="hidden" name="section" value="acc-ad"><input type="hidden" name="group">
<span>Все сотрудники группы <b id="acc-ad-gwho"></b> — входят своим доменным логином и паролем, с ролью</span>
<select name="role">{{range .Users.Roles}}<option value="{{.Key}}" {{if eq .Key "operator"}}selected{{end}}>{{.Title}}</option>{{end}}</select>
<button type="submit" class="btn btn-ok">Дать доступ группе</button>
</form>
{{if $A.ADGroups}}<div class="ad-groups"><div class="muted" style="font-size:13px;margin-bottom:6px">Группы домена с доступом в bj — их сотрудники входят без отдельной учётной записи:</div>
{{range $A.ADGroups}}<div class="ad-grp"><span><b>{{.Name}}</b> → {{roleTitle .Role}}</span>
<form method="post" action="/admin/setup/users/ad-group"><input type="hidden" name="return_to" value="/admin/wizard?step=13"><input type="hidden" name="section" value="acc-ad"><input type="hidden" name="group" value="{{.Name}}"><input type="hidden" name="remove" value="1"><button type="submit" class="dq-link">убрать</button></form></div>{{end}}
</div>{{end}}
<script>(function(){
var q=document.getElementById('acc-ad-q'), st=document.getElementById('acc-ad-st'), ul=document.getElementById('acc-ad-res'), f=document.getElementById('acc-ad-form'), who=document.getElementById('acc-ad-who'), t=0;
var q=document.getElementById('acc-ad-q'), st=document.getElementById('acc-ad-st'), ul=document.getElementById('acc-ad-res'), f=document.getElementById('acc-ad-form'), gf=document.getElementById('acc-ad-gform'), who=document.getElementById('acc-ad-who'), gwho=document.getElementById('acc-ad-gwho'), t=0;
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
q.addEventListener('input',function(){ clearTimeout(t); t=setTimeout(function(){
var v=q.value.trim(); if(v.length<2){ ul.innerHTML=''; st.textContent=''; return; } st.textContent='Ищу в домене…';
var v=q.value.trim(); f.hidden=true; gf.hidden=true; if(v.length<2){ ul.innerHTML=''; st.textContent=''; return; } st.textContent='Ищу в домене…';
fetch('/admin/setup/users/ad-search?q='+encodeURIComponent(v),{cache:'no-store',headers:{'Accept':'application/json'}}).then(function(r){return r.json()}).then(function(d){
if(!d.ok){ st.textContent='Не получилось: '+d.error; ul.innerHTML=''; return; }
st.textContent=d.users.length?'Найдено: '+d.users.length:'Никого не нашлось';
ul.innerHTML=d.users.map(function(u,i){ return '<li><button type="button" class="btn btn-secondary" style="width:100%;text-align:left" data-i="'+i+'"'+(u.have?' disabled':'')+'><b>'+esc(u.name||u.login)+'</b> <span class="muted">'+esc(u.login)+(u.email?' · '+esc(u.email):'')+(u.have?' · уже есть в bj':'')+'</span></button></li>'; }).join('');
ul.querySelectorAll('button').forEach(function(b){ b.onclick=function(){ var u=d.users[+b.dataset.i]; f.login.value=u.login; f.name.value=u.name||''; f.email.value=u.email||''; who.textContent=(u.name||u.login)+' ('+u.login+')'; f.hidden=false; }; });
st.textContent=d.users.length?'Найдено: '+d.users.length+' — выберите, кого добавить':'Никого не нашлось';
ul.innerHTML=d.users.map(function(u,i){ var g=u.kind==='group';
return '<li><button type="button" class="ad-hit" data-i="'+i+'"'+(u.have?' disabled':'')+'><span class="ad-kind'+(g?' ad-kind-g':'')+'">'+(g?'группа':'сотрудник')+'</span><b>'+esc(u.name||u.login)+'</b> <span class="muted">'+(g?esc(u.desc||u.login):esc(u.login)+(u.email?' · '+esc(u.email):''))+(u.have?(g?' · доступ уже дан':' · уже есть в bj'):'')+'</span></button></li>'; }).join('');
ul.querySelectorAll('button').forEach(function(b){ b.onclick=function(){ var u=d.users[+b.dataset.i];
ul.querySelectorAll('.ad-hit').forEach(function(x){ x.classList.toggle('is-sel',x===b); });
if(u.kind==='group'){ gf.group.value=u.login; gwho.textContent='«'+(u.name||u.login)+'»'; gf.hidden=false; f.hidden=true; gf.scrollIntoView({block:'nearest',behavior:'smooth'}); return; }
f.login.value=u.login; f.name.value=u.name||''; f.email.value=u.email||''; who.textContent=(u.name||u.login)+' ('+u.login+')'; f.hidden=false; gf.hidden=true; f.scrollIntoView({block:'nearest',behavior:'smooth'}); }; });
}).catch(function(){ st.textContent='Домен не ответил'; });
},350); });
if(document.getElementById('acc-ad').open && !document.querySelector('#ad-connect[open]')) q.focus({preventScroll:true});
})();</script>
{{end}}
<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}}
{{/* пароль в страницу не попадает никогда — только пометка, что он есть */}}
{{$bu := $A.ADBindUser}}{{$pw := ""}}{{if $A.ADBindPassword}}{{$pw = "задан"}}{{end}}{{$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>
<div id="ad-found" class="ad-st" hidden 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">
<input type="hidden" name="return_to" value="/admin/wizard?step=13"><input type="hidden" name="section" value="acc-ad">
<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>
@@ -739,14 +780,15 @@
<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{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function look(){ if(done||!box.open||(outer&&!outer.open)) return; done=true; out.textContent='Ищу домен…';
function look(){ if(done||!box.open||(outer&&!outer.open)) return; done=true; out.hidden=false; out.className='ad-st'; out.innerHTML='<span class="ad-st-ic ad-st-spin"></span><div><b>Ищу домен…</b><small>по сетевым настройкам сервера bj</small></div>';
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>';
out.hidden=false;
if(d.ok){ out.className='ad-st ad-st-ok'; out.innerHTML='<span class="ad-st-ic"></span><div><b>Найден домен '+esc(x.domain)+'</b><small>контроллеры: '+x.dcs.map(esc).join(', ')+' · '+esc(x.from)+'</small></div>';
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.';
out.className='ad-st ad-st-warn'; out.innerHTML='<span class="ad-st-ic"></span><div><b>'+(x&&x.domain?'Домен '+esc(x.domain)+' найден, но его контроллеры — нет':'Домен сам не нашёлся')+'</b><small>'+(x&&x.domain?'укажите контроллер в расширенных настройках ниже':'укажите домен в расширенных настройках ниже или впишите учётную запись с доменом: svc-bj@corp.example.ru')+'</small></div>';
adv.open=true;
}).catch(function(){ out.textContent=''; done=false; });
}).catch(function(){ out.hidden=true; done=false; });
}
box.addEventListener('toggle',look); if(outer) outer.addEventListener('toggle',look); look();
})();</script>
@@ -12,7 +12,7 @@
{{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}}">
{{if $.Ret}}<input type="hidden" name="return_to" value="{{$.Ret}}"><input type="hidden" name="section" value="acc-ad">{{end}}<input type="hidden" name="fp" value="{{.SHA256}}">
<button type="submit" class="btn btn-ok">Доверять и подключить</button>
</form>
</div>
+4 -4
View File
@@ -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", "onec-middle"}
var wizModules = []string{"web-cabinet", "esia-finance", "quik-export", "npr", "onec-back"}
// WizStep — шаг в пути мастера.
type WizStep struct {
@@ -202,9 +202,9 @@ 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С мидл-офиса (модуль в разработке): пока — подключение к опубликованной базе и его проверка."
case "onec-back":
m.Title = "1С: база бэк-офиса"
m.What = "Связь с базой 1С бэк-офиса (модуль в разработке): пока — подключение к опубликованной базе и его проверка."
m.Link = "/admin/setup?from=wizard#onec"
m.Ready = s.OneC.CheckOK
switch {