Личный кабинет — модуль modules/lk: сборка в релизе, unit в комплекте, ключ вне unit-файла
Код bj-lk перенесён в modules/lk (cmd/bj-lk — запуск, флаг -version); релиз собирает bj-lk со своей версией (LK_VERSION), unit bj-lk.service встроен в комплект установщика и берёт адрес bj-server и ключ доступа из /etc/bj/lk.env, а не из unit-файла. Исправлено: признаки «администратор» и «кабинет клиента» читались из общей переменной последнего запроса (curReq) — при одновременной работе признак мог достаться чужому запросу. Теперь из кук своего запроса. Без BJ_URL/BJ_KEY кабинет показывает подсказку настройки вместо падения службы. Тесты: вход, портфель, health, флаг админа по запросу, без настройки. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,448 @@
|
||||
// Package lk — модуль «Личный кабинет клиента» (служба bj-lk, cmd/bj-lk).
|
||||
//
|
||||
// Ставится на любой веб-сервер клиента и обновляется независимо от ядра. Сам к базам данных НЕ
|
||||
// подключается — работает только с bj-server по HTTP API с ключом доступа (X-BJ-Key). Клиент
|
||||
// входит по логину/паролю, видит свои бумаги и подаёт заявку на перевод другому брокеру.
|
||||
package lk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cookieTTL — срок жизни кук кабинета. Должен быть не меньше срока сессии на
|
||||
// bj-server (12ч), иначе браузер забудет токен раньше, чем тот протухнет, и
|
||||
// клиент увидит «сессия истекла». Кука продлевается при каждом запросе.
|
||||
const cookieTTL = 12 * 3600
|
||||
|
||||
// Config — подключение к bj-server и оформление.
|
||||
type Config struct {
|
||||
BJURL string // адрес bj-server (https://...)
|
||||
BJKey string // ключ доступа модуля (выдаётся в настройках bj-server)
|
||||
Title string // название кабинета в шапке
|
||||
}
|
||||
|
||||
// Server — кабинет.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
bj *bjClient
|
||||
tpl *template.Template
|
||||
}
|
||||
|
||||
// New — кабинет с разобранными шаблонами.
|
||||
func New(cfg Config) (*Server, error) {
|
||||
cfg.BJURL = strings.TrimRight(strings.TrimSpace(cfg.BJURL), "/")
|
||||
cfg.BJKey = strings.TrimSpace(cfg.BJKey)
|
||||
if cfg.Title == "" {
|
||||
cfg.Title = "Личный кабинет"
|
||||
}
|
||||
tpl, err := template.New("t").Funcs(template.FuncMap{
|
||||
"fmtnum": func(f float64) string {
|
||||
if f == float64(int64(f)) {
|
||||
return strconv.FormatInt(int64(f), 10)
|
||||
}
|
||||
return strconv.FormatFloat(f, 'f', -1, 64)
|
||||
},
|
||||
"shortid": func(s string) string {
|
||||
if len(s) > 8 {
|
||||
return s[:8] + "…"
|
||||
}
|
||||
return s
|
||||
},
|
||||
}).Parse(pageTemplates)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{cfg: cfg, bj: &bjClient{cfg: cfg, hc: &http.Client{Timeout: 20 * time.Second}}, tpl: tpl}, nil
|
||||
}
|
||||
|
||||
// Configured — заданы адрес bj-server и ключ доступа.
|
||||
func (s *Server) Configured() bool { return s.cfg.BJURL != "" && s.cfg.BJKey != "" }
|
||||
|
||||
// ---- клиент к bj-server ----
|
||||
|
||||
type bjClient struct {
|
||||
cfg Config
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func (b *bjClient) call(ctx context.Context, method, path, session string, body any, out any) error {
|
||||
var rdr *bytes.Reader
|
||||
if body != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(raw)
|
||||
} else {
|
||||
rdr = bytes.NewReader(nil)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, b.cfg.BJURL+path, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-BJ-Key", b.cfg.BJKey)
|
||||
if session != "" {
|
||||
req.Header.Set("X-BJ-Session", session)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := b.hc.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("нет связи с сервером: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
var e struct{ Error string }
|
||||
_ = json.NewDecoder(resp.Body).Decode(&e)
|
||||
if e.Error == "" {
|
||||
e.Error = fmt.Sprintf("ошибка сервера (%d)", resp.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("%s", e.Error)
|
||||
}
|
||||
if out != nil {
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- модели ответов ----
|
||||
|
||||
type position struct {
|
||||
ISIN string `json:"isin"`
|
||||
SecurityCode string `json:"security_code"`
|
||||
SecurityName string `json:"security_name"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Blocked float64 `json:"blocked"`
|
||||
Available float64 `json:"available"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Transferable bool `json:"transferable"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
type claimRow struct {
|
||||
ID int64 `json:"id"`
|
||||
GUID string `json:"guid"`
|
||||
StatusRu string `json:"status_ru"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Securit int `json:"securities"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// ---- страницы ----
|
||||
|
||||
// render — страница; признаки режима (админ / просмотр кабинета клиента) — из кук этого запроса.
|
||||
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
data["Title"] = s.cfg.Title
|
||||
if _, ok := data["Nav"]; !ok {
|
||||
data["Nav"] = ""
|
||||
}
|
||||
if _, ok := data["IsAdmin"]; !ok {
|
||||
if c, err := r.Cookie("bj_lk_admin"); err == nil && c.Value == "1" {
|
||||
data["IsAdmin"] = true
|
||||
}
|
||||
}
|
||||
if c, err := r.Cookie("bj_lk_as"); err == nil && c.Value != "" {
|
||||
data["AsClient"] = c.Value
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
log.Printf("шаблон %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func setFlag(w http.ResponseWriter, name, val string) {
|
||||
age := cookieTTL
|
||||
if val == "" {
|
||||
age = -1
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: age, SameSite: http.SameSiteLaxMode})
|
||||
}
|
||||
|
||||
func setSession(w http.ResponseWriter, val string) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: val, Path: "/",
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: cookieTTL})
|
||||
}
|
||||
|
||||
// sessionW — как session, но заодно продлевает куку, чтобы у активного
|
||||
// клиента кабинет не сбрасывался на середине работы.
|
||||
func sessionW(w http.ResponseWriter, r *http.Request) string {
|
||||
c, err := r.Cookie("bj_lk")
|
||||
if err != nil || c.Value == "" {
|
||||
return ""
|
||||
}
|
||||
setSession(w, c.Value)
|
||||
return c.Value
|
||||
}
|
||||
|
||||
func session(r *http.Request) string {
|
||||
c, err := r.Cookie("bj_lk")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
// Handler — все страницы кабинета. Без адреса bj-server или ключа — страница с подсказкой настройки.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
if !s.Configured() {
|
||||
return http.HandlerFunc(s.notConfigured)
|
||||
}
|
||||
bj := s.bj
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Вход
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
s.render(w, r, "login", map[string]any{"Flash": r.URL.Query().Get("e")})
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
var out struct {
|
||||
Session string `json:"session"`
|
||||
FullName string `json:"full_name"`
|
||||
Contract string `json:"contract"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
}
|
||||
err := bj.call(r.Context(), http.MethodPost, "/api/lk/login", "", map[string]string{
|
||||
"login": r.FormValue("login"), "password": r.FormValue("password"),
|
||||
}, &out)
|
||||
if err != nil {
|
||||
s.render(w, r, "login", map[string]any{"Flash": err.Error()})
|
||||
return
|
||||
}
|
||||
setSession(w, out.Session)
|
||||
if out.IsAdmin {
|
||||
setFlag(w, "bj_lk_admin", "1")
|
||||
http.Redirect(w, r, "/clients", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
setFlag(w, "bj_lk_admin", "")
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = bj.call(r.Context(), http.MethodPost, "/api/lk/logout", session(r), nil, nil)
|
||||
http.SetCookie(w, &http.Cookie{Name: "bj_lk", Value: "", Path: "/", MaxAge: -1})
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// Портфель
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
sess := sessionW(w, r)
|
||||
if sess == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
var out struct {
|
||||
Contract string `json:"contract"`
|
||||
FullName string `json:"full_name"`
|
||||
Positions []position `json:"positions"`
|
||||
Source string `json:"source"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
}
|
||||
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil {
|
||||
s.render(w, r, "portfolio", map[string]any{"Nav": "p", "Error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.render(w, r, "portfolio", map[string]any{
|
||||
"Nav": "p", "Contract": out.Contract, "FullName": out.FullName,
|
||||
"Positions": out.Positions, "Flash": r.URL.Query().Get("m"),
|
||||
"Source": out.Source, "ReadOnly": out.ReadOnly,
|
||||
})
|
||||
})
|
||||
|
||||
// Заявка на перевод
|
||||
mux.HandleFunc("/transfer", func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionW(w, r)
|
||||
if sess == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
var out struct {
|
||||
Positions []position `json:"positions"`
|
||||
FullName string `json:"full_name"`
|
||||
}
|
||||
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/portfolio", sess, nil, &out); err != nil {
|
||||
s.render(w, r, "transfer", map[string]any{"Nav": "t", "Error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.render(w, r, "transfer", map[string]any{"Nav": "t", "Positions": out.Positions, "FullName": out.FullName})
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
var secs []map[string]any
|
||||
for _, isin := range r.Form["isin"] {
|
||||
q := strings.TrimSpace(r.FormValue("qty_" + isin))
|
||||
if q == "" || q == "0" {
|
||||
continue
|
||||
}
|
||||
f, err := strconv.ParseFloat(strings.ReplaceAll(q, ",", "."), 64)
|
||||
if err != nil || f <= 0 {
|
||||
continue
|
||||
}
|
||||
secs = append(secs, map[string]any{"isin": isin, "quantity": f})
|
||||
}
|
||||
if len(secs) == 0 {
|
||||
s.render(w, r, "transfer", map[string]any{"Nav": "t", "Error": "Укажите количество хотя бы по одной бумаге"})
|
||||
return
|
||||
}
|
||||
var res struct {
|
||||
Message string `json:"message"`
|
||||
GUID string `json:"guid"`
|
||||
}
|
||||
err := bj.call(r.Context(), http.MethodPost, "/api/lk/transfer", sess, map[string]any{
|
||||
"receiving_inn": strings.TrimSpace(r.FormValue("receiving_inn")),
|
||||
"receiving_name": strings.TrimSpace(r.FormValue("receiving_name")),
|
||||
"account_at_recv": strings.TrimSpace(r.FormValue("account_at_recv")),
|
||||
"comment": strings.TrimSpace(r.FormValue("comment")),
|
||||
"securities": secs,
|
||||
}, &res)
|
||||
if err != nil {
|
||||
s.render(w, r, "transfer", map[string]any{"Nav": "t", "Error": err.Error()})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/claims?m="+url.QueryEscape(res.Message), http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// Мои заявки
|
||||
mux.HandleFunc("/claims", func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionW(w, r)
|
||||
if sess == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
var out struct {
|
||||
Claims []claimRow `json:"claims"`
|
||||
}
|
||||
if err := bj.call(r.Context(), http.MethodGet, "/api/lk/claims", sess, nil, &out); err != nil {
|
||||
s.render(w, r, "claims", map[string]any{"Nav": "c", "Error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.render(w, r, "claims", map[string]any{"Nav": "c", "Claims": out.Claims, "Flash": r.URL.Query().Get("m")})
|
||||
})
|
||||
|
||||
// Администратор: список клиентов
|
||||
mux.HandleFunc("/clients", func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionW(w, r)
|
||||
if sess == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
var out struct {
|
||||
Clients []struct {
|
||||
Login string `json:"login"`
|
||||
Contract string `json:"contract_number"`
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
Active bool `json:"active"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
} `json:"clients"`
|
||||
Depo []struct {
|
||||
Contract string `json:"contract"`
|
||||
Name string `json:"name"`
|
||||
Securities int `json:"securities"`
|
||||
} `json:"depo"`
|
||||
DepoError string `json:"depo_error"`
|
||||
}
|
||||
search := strings.TrimSpace(r.URL.Query().Get("search"))
|
||||
path := "/api/lk/admin/clients"
|
||||
if search != "" {
|
||||
path += "?search=" + url.QueryEscape(search)
|
||||
}
|
||||
if err := bj.call(r.Context(), http.MethodGet, path, sess, nil, &out); err != nil {
|
||||
s.render(w, r, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.render(w, r, "clients", map[string]any{"Nav": "a", "IsAdmin": true, "Clients": out.Clients,
|
||||
"Depo": out.Depo, "DepoError": out.DepoError, "Search": search,
|
||||
"Flash": r.URL.Query().Get("m")})
|
||||
})
|
||||
|
||||
// Администратор: войти в кабинет клиента
|
||||
mux.HandleFunc("/impersonate", func(w http.ResponseWriter, r *http.Request) {
|
||||
sess := sessionW(w, r)
|
||||
if sess == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
login := r.FormValue("login")
|
||||
if login == "" {
|
||||
login = r.URL.Query().Get("login")
|
||||
}
|
||||
contract := r.FormValue("contract")
|
||||
if contract == "" {
|
||||
contract = r.URL.Query().Get("contract")
|
||||
}
|
||||
var out struct {
|
||||
Session string `json:"session"`
|
||||
AsClient string `json:"as_client"`
|
||||
}
|
||||
if err := bj.call(r.Context(), http.MethodPost, "/api/lk/admin/impersonate", sess,
|
||||
map[string]string{"login": login, "contract": contract}, &out); err != nil {
|
||||
http.Redirect(w, r, "/clients?m="+url.QueryEscape(err.Error()), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// сохраняем админскую сессию, чтобы можно было вернуться
|
||||
setFlag(w, "bj_lk_admin_sess", sess)
|
||||
setSession(w, out.Session)
|
||||
setFlag(w, "bj_lk_as", out.AsClient)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// Администратор: вернуться из кабинета клиента
|
||||
mux.HandleFunc("/back-to-admin", func(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" {
|
||||
setSession(w, c.Value)
|
||||
}
|
||||
setFlag(w, "bj_lk_as", "")
|
||||
setFlag(w, "bj_lk_admin_sess", "")
|
||||
http.Redirect(w, r, "/clients", http.StatusSeeOther)
|
||||
})
|
||||
|
||||
// Проверка связи с bj-server (для админа при установке)
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
var out map[string]any
|
||||
err := bj.call(r.Context(), http.MethodGet, "/api/lk/ping", "", nil, &out)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": err.Error(), "bj_url": s.cfg.BJURL})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "bj": out, "bj_url": s.cfg.BJURL})
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// notConfigured — кабинет запущен без адреса bj-server или ключа: подсказка вместо падения службы.
|
||||
func (s *Server) notConfigured(w http.ResponseWriter, r *http.Request) {
|
||||
const msg = "Личный кабинет не настроен: укажите адрес bj-server (BJ_URL) и ключ доступа модуля (BJ_KEY) в /etc/bj/lk.env, затем выполните systemctl restart bj-lk. Ключ задаётся в настройках bj-server."
|
||||
if r.URL.Path == "/health" {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": false, "error": msg})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
fmt.Fprintf(w, `<!doctype html><html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head>`+
|
||||
`<body style="font-family:system-ui,sans-serif;max-width:640px;margin:48px auto;padding:0 16px;line-height:1.5"><h1>%s</h1><p>%s</p></body></html>`,
|
||||
template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(s.cfg.Title), template.HTMLEscapeString(msg))
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package lk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeBJ — API bj-server для кабинета: проверка ключа, вход и портфель.
|
||||
func fakeBJ(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
keyOK := func(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Header.Get("X-BJ-Key") != "lk-key" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "неверный ключ модуля"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
mux.HandleFunc("/api/lk/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if keyOK(w, r) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"session": "sess-1", "full_name": "Мостовиков Тест", "is_admin": false})
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/lk/portfolio", func(w http.ResponseWriter, r *http.Request) {
|
||||
if keyOK(w, r) && r.Header.Get("X-BJ-Session") == "sess-1" {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"full_name": "Мостовиков Тест", "contract": "4519", "positions": []any{}})
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/lk/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
if keyOK(w, r) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
||||
}
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestLoginPortfolioHealth(t *testing.T) {
|
||||
bj := fakeBJ(t)
|
||||
s, err := New(Config{BJURL: bj.URL + "/", BJKey: "lk-key", Title: "Кабинет · тест"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := s.Handler()
|
||||
|
||||
form := url.Values{"login": {"client"}, "password": {"secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
|
||||
t.Fatalf("вход: код %d, Location %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
var sess *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == "bj_lk" {
|
||||
sess = c
|
||||
}
|
||||
}
|
||||
if sess == nil || sess.Value != "sess-1" || !sess.HttpOnly {
|
||||
t.Fatalf("кука сессии: %+v", sess)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(sess)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
body := rec.Body.String()
|
||||
if rec.Code != http.StatusOK || !strings.Contains(body, "Мостовиков Тест") || !strings.Contains(body, "Кабинет · тест") {
|
||||
t.Fatalf("портфель: код %d: %s", rec.Code, body)
|
||||
}
|
||||
if strings.Contains(body, `href="/clients"`) {
|
||||
t.Error("клиенту без признака администратора показан раздел «Клиенты»")
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/health", nil))
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"ok":true`) {
|
||||
t.Fatalf("health: код %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Признак администратора берётся из кук своего запроса, а не предыдущего (прежде был общий curReq).
|
||||
func TestAdminFlagPerRequest(t *testing.T) {
|
||||
bj := fakeBJ(t)
|
||||
s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := s.Handler()
|
||||
admin := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
admin.AddCookie(&http.Cookie{Name: "bj_lk_admin", Value: "1"})
|
||||
h.ServeHTTP(httptest.NewRecorder(), admin)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if strings.Contains(rec.Body.String(), `href="/clients"`) {
|
||||
t.Fatal("признак администратора перешёл из чужого запроса")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotConfigured(t *testing.T) {
|
||||
s, err := New(Config{BJURL: "http://127.0.0.1:8080"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Configured() {
|
||||
t.Fatal("без ключа кабинет не настроен")
|
||||
}
|
||||
for _, p := range []string{"/", "/health"} {
|
||||
rec := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, p, nil))
|
||||
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "BJ_KEY") {
|
||||
t.Fatalf("%s: код %d: %s", p, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package lk
|
||||
|
||||
// pageTemplates — встроенные страницы Личного кабинета (без внешних зависимостей,
|
||||
// чтобы модуль был одним файлом и ставился на любой веб-сервер).
|
||||
const pageTemplates = `
|
||||
{{define "head"}}<!DOCTYPE html><html lang="ru"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
:root{--bg:#f6f7f9;--card:#fff;--bd:#e3e6ec;--tx:#1b2029;--mut:#5b6472;--acc:#2563eb;--ok:#15803d;--err:#c22a2a;
|
||||
--bd-strong:#c9cfda;--bg-2:#eef0f4;
|
||||
--bg-neu:rgba(37,99,235,.12);--tx-neu:#1d4ed8;--bg-ok:rgba(21,128,61,.14);--tx-ok:#15803d;--bg-err:rgba(194,42,42,.12);--tx-err:#b91c1c;--bg-warn:rgba(180,120,0,.16);--tx-warn:#a15c07}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:#0f1116;--card:#181b22;--bd:#2a2f3a;--tx:#e8eaed;--mut:#a2acbd;--acc:#7ba7ff;--ok:#5bd085;--err:#ff8f8f;
|
||||
--bd-strong:#3a4150;--bg-2:#20242e;
|
||||
--bg-neu:rgba(123,167,255,.16);--tx-neu:#93c5fd;--bg-ok:rgba(91,208,133,.18);--tx-ok:#86efac;--bg-err:rgba(255,143,143,.18);--tx-err:#fca5a5;--bg-warn:rgba(245,180,80,.18);--tx-warn:#fcd34d}}
|
||||
*{box-sizing:border-box}body{margin:0;font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
background:var(--bg);color:var(--tx);line-height:1.5}
|
||||
.top{background:var(--card);border-bottom:1px solid var(--bd);padding:0 20px;height:56px;display:flex;align-items:center;gap:8px;position:sticky;top:0;z-index:5;overflow-x:auto}
|
||||
.brand{font-weight:700;font-size:16px;white-space:nowrap;margin-right:8px}.top a{color:var(--mut);text-decoration:none;font-size:14px;padding:8px 10px;border-radius:7px;white-space:nowrap}
|
||||
.top a:hover{background:var(--bg);color:var(--tx)}.top a.on{background:var(--bg-neu);color:var(--tx-neu)}
|
||||
.top .right{margin-left:auto;display:flex;align-items:center;gap:12px;font-size:13px;color:var(--mut);white-space:nowrap}
|
||||
main{max-width:980px;margin:0 auto;padding:22px 20px}
|
||||
h1{font-size:22px;margin:0 0 14px}h2{font-size:16px;margin:0 0 12px}
|
||||
.card{background:var(--card);border:1px solid var(--bd);border-radius:12px;padding:18px 20px;margin-bottom:16px}
|
||||
.tw{overflow-x:auto;-webkit-overflow-scrolling:touch;margin:0 -4px}
|
||||
table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--bd);white-space:nowrap}
|
||||
th{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--mut);font-weight:600}
|
||||
.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
code{font-size:12px;background:var(--bg-2);padding:1px 5px;border-radius:4px}
|
||||
input,select,textarea{padding:10px 12px;border:1px solid var(--bd-strong);border-radius:8px;background:var(--card);color:var(--tx);font:inherit;font-size:16px;width:100%}
|
||||
input:focus,select:focus,textarea:focus{outline:2px solid var(--acc);outline-offset:1px;border-color:var(--acc)}
|
||||
label{font-size:13px;font-weight:500;display:block;margin-bottom:4px}
|
||||
.btn{background:var(--acc);color:#fff;border:0;border-radius:8px;padding:11px 18px;font:inherit;font-size:14px;font-weight:600;cursor:pointer;text-decoration:none;display:inline-block;min-height:44px;line-height:22px}
|
||||
.btn:hover{filter:brightness(1.06)}.btn.sec{background:var(--card);color:var(--tx);border:1px solid var(--bd-strong)}
|
||||
.btn.sm{min-height:0;padding:6px 10px;font-size:12px}
|
||||
.msg{padding:12px 14px;border-radius:9px;margin-bottom:14px;font-size:14px}
|
||||
.msg.err{background:var(--bg-err);color:var(--tx-err)}.msg.ok{background:var(--bg-ok);color:var(--tx-ok)}
|
||||
.mut{color:var(--mut);font-size:13px}
|
||||
.badge{display:inline-block;padding:3px 10px;border-radius:20px;font-size:12px;font-weight:700;background:var(--bg-neu);color:var(--tx-neu)}
|
||||
.badge.ok{background:var(--bg-ok);color:var(--tx-ok)}.badge.err{background:var(--bg-err);color:var(--tx-err)}.badge.warn{background:var(--bg-warn);color:var(--tx-warn)}
|
||||
.login-wrap{max-width:380px;margin:8vh auto;padding:0 20px}
|
||||
.ovl{position:fixed;inset:0;background:rgba(10,12,18,.55);display:none;align-items:center;justify-content:center;padding:16px;z-index:20}
|
||||
.ovl.on{display:flex}
|
||||
.modal{background:var(--card);border:1px solid var(--bd);border-radius:14px;max-width:460px;width:100%;padding:22px;max-height:90vh;overflow:auto}
|
||||
.modal h2{margin-top:0}
|
||||
.sumrow{display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid var(--bd);font-size:14px}
|
||||
.warnbox{background:var(--bg-warn);color:var(--tx-warn);border-radius:9px;padding:11px 13px;font-size:13px;font-weight:500;margin:14px 0}
|
||||
@media(max-width:600px){main{padding:16px 14px}.card{padding:15px 15px}h1{font-size:20px}
|
||||
input,select,textarea{font-size:16px}}
|
||||
</style></head><body>{{end}}
|
||||
|
||||
{{define "nav"}}<div class="top"><span class="brand">{{.Title}}</span>
|
||||
{{if .IsAdmin}}<a href="/clients" class="{{if eq .Nav "a"}}on{{end}}">Клиенты</a>{{end}}
|
||||
<a href="/" class="{{if eq .Nav "p"}}on{{end}}">Мои бумаги</a>
|
||||
<a href="/transfer" class="{{if eq .Nav "t"}}on{{end}}">Перевести бумаги</a>
|
||||
<a href="/claims" class="{{if eq .Nav "c"}}on{{end}}">Мои заявки</a>
|
||||
<span class="right">{{with .FullName}}{{.}}{{end}}{{with .Contract}} · договор {{.}}{{end}} <a href="/logout">Выйти</a></span>
|
||||
</div>
|
||||
{{if .AsClient}}<div style="background:#fff4d6;border-bottom:1px solid #e8c877;padding:9px 20px;font-size:13px">
|
||||
👤 Вы просматриваете кабинет клиента <b>{{.AsClient}}</b> от имени администратора ·
|
||||
<a href="/back-to-admin">вернуться к списку клиентов</a></div>{{end}}{{end}}
|
||||
|
||||
{{define "login"}}{{template "head" .}}
|
||||
<div class="login-wrap">
|
||||
<div class="card">
|
||||
<h1>{{.Title}}</h1>
|
||||
<p class="mut">Войдите, чтобы посмотреть свои ценные бумаги и подать заявку на перевод к другому брокеру.</p>
|
||||
{{with .Flash}}<div class="msg err">{{.}}</div>{{end}}
|
||||
<form method="post" action="/login" style="display:grid;gap:12px;margin-top:14px">
|
||||
<div><label for="f-login">Логин</label><input id="f-login" name="login" autocomplete="username" required autofocus></div>
|
||||
<div><label for="f-pass">Пароль</label><input id="f-pass" name="password" type="password" autocomplete="current-password" required></div>
|
||||
<button class="btn" type="submit" style="width:100%">Войти</button>
|
||||
</form>
|
||||
<p class="mut" style="margin-top:14px">🔒 Соединение защищено. Мы не запрашиваем пароль по телефону или в письмах.</p>
|
||||
</div>
|
||||
<p class="mut" style="text-align:center">Логин и пароль выдаёт ваш брокер. Не можете войти — обратитесь в поддержку брокера.</p>
|
||||
</div>
|
||||
</body></html>{{end}}
|
||||
|
||||
{{define "portfolio"}}{{template "head" .}}{{template "nav" .}}
|
||||
<main>
|
||||
<h1>Мои ценные бумаги</h1>
|
||||
{{if .Source}}<p class="mut">Источник данных: {{.Source}}{{if .Contract}} · договор {{.Contract}}{{end}}</p>{{end}}
|
||||
{{with .Flash}}<div class="msg ok">{{.}}</div>{{end}}
|
||||
{{with .Error}}<div class="msg err">{{.}}</div>{{end}}
|
||||
<div class="card">
|
||||
{{if .Positions}}
|
||||
<div class="tw"><table>
|
||||
<thead><tr><th>Бумага</th><th>ISIN</th><th class="num">Всего</th><th class="num">Заблокировано</th><th class="num">Доступно к переводу</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Positions}}
|
||||
<tr>
|
||||
<td>{{if .SecurityName}}{{.SecurityName}}{{else}}{{.SecurityCode}}{{end}}</td>
|
||||
<td><code>{{.ISIN}}</code></td>
|
||||
<td class="num">{{fmtnum .Quantity}}</td>
|
||||
<td class="num">{{if .Blocked}}{{fmtnum .Blocked}}{{else}}—{{end}}</td>
|
||||
<td class="num">{{if .Transferable}}<b>{{fmtnum .Available}}</b>{{else}}<span class="mut" title="{{.Note}}">не переводится</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
<p class="mut" style="margin-top:12px">Через сервис MOEX МОСТ переводятся только российские ценные бумаги (ISIN начинается с RU). Иностранные бумаги показаны для полноты портфеля, но заявку по ним подать нельзя.</p>
|
||||
{{if .ReadOnly}}
|
||||
<p class="mut" style="margin-top:16px">📋 Данные загружены из депозитарного учёта. Подача заявок на перевод для этого договора пока недоступна — раздел работает в режиме просмотра.</p>
|
||||
{{else}}
|
||||
<p style="margin-top:16px"><a class="btn" href="/transfer">Перевести бумаги другому брокеру</a></p>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="mut">Бумаг на счёте не найдено. Если вы считаете, что это ошибка — обратитесь к вашему брокеру.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</main></body></html>{{end}}
|
||||
|
||||
{{define "transfer"}}{{template "head" .}}{{template "nav" .}}
|
||||
<main>
|
||||
<h1>Перевод бумаг другому брокеру</h1>
|
||||
{{with .Error}}<div class="msg err">{{.}}</div>{{end}}
|
||||
<form method="post" action="/transfer" id="tform">
|
||||
<div class="card">
|
||||
<h2>Куда переводим</h2>
|
||||
<p class="mut">Укажите депозитарий брокера, к которому переводите бумаги. Перевод оформляется на вас же — как перевод «самому себе» в другой депозитарий.</p>
|
||||
<div style="display:grid;gap:12px;max-width:520px;margin-top:12px">
|
||||
<div><label for="rinn">ИНН депозитария-получателя *</label>
|
||||
<input id="rinn" name="receiving_inn" required inputmode="numeric" pattern="\d{{"{"}}10{{"}"}}|\d{{"{"}}12{{"}"}}" maxlength="12" placeholder="7722061076"
|
||||
title="ИНН организации — 10 цифр">
|
||||
<p class="mut" style="margin-top:4px">ИНН депозитария вашего нового брокера — уточните его у брокера-получателя.</p></div>
|
||||
<div><label for="rname">Наименование брокера/депозитария</label><input id="rname" name="receiving_name" placeholder="например, ООО «Брокер»"></div>
|
||||
<div><label for="racc">Ваш счёт депо у нового брокера (если известен)</label><input id="racc" name="account_at_recv" placeholder="необязательно"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Что переводим</h2>
|
||||
{{if .Positions}}
|
||||
<div class="tw"><table>
|
||||
<thead><tr><th>Бумага</th><th>ISIN</th><th class="num">Доступно</th><th style="min-width:190px">Сколько перевести</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Positions}}
|
||||
<tr>
|
||||
<td>{{if .SecurityName}}{{.SecurityName}}{{else}}{{.SecurityCode}}{{end}}</td>
|
||||
<td><code>{{.ISIN}}</code></td>
|
||||
<td class="num">{{if .Transferable}}{{fmtnum .Available}}{{else}}—{{end}}</td>
|
||||
<td>{{if .Transferable}}<input type="hidden" name="isin" value="{{.ISIN}}">
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<input class="qty" name="qty_{{.ISIN}}" type="number" min="0" step="any" max="{{printf "%g" .Available}}"
|
||||
inputmode="decimal" placeholder="0" style="max-width:120px"
|
||||
aria-label="Сколько перевести: {{if .SecurityName}}{{.SecurityName}}{{else}}{{.ISIN}}{{end}}"
|
||||
data-name="{{if .SecurityName}}{{.SecurityName}}{{else}}{{.ISIN}}{{end}}" data-isin="{{.ISIN}}" data-avail="{{printf "%g" .Available}}"
|
||||
{{if not .Available}}disabled{{end}}>
|
||||
<button type="button" class="btn sec sm qall">всё</button>
|
||||
</div>
|
||||
{{else}}<span class="mut">{{.Note}}</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div style="margin-top:14px;max-width:520px"><label for="cmt">Комментарий (необязательно)</label><textarea id="cmt" name="comment" rows="2"></textarea></div>
|
||||
<div style="margin-top:16px;display:flex;gap:10px;flex-wrap:wrap">
|
||||
<button class="btn" type="submit">Проверить и подать</button>
|
||||
<a class="btn sec" href="/">Отмена</a>
|
||||
</div>
|
||||
<p class="mut" style="margin-top:12px">Перед подачей вы увидите сводку для проверки. После подачи заявка проходит проверку у брокера, затем направляется в НРД. Статус виден в разделе «Мои заявки».</p>
|
||||
{{else}}<p class="mut">Нет доступных бумаг для перевода.</p>{{end}}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="ovl" id="cov" role="dialog" aria-modal="true" aria-labelledby="ctitle">
|
||||
<div class="modal">
|
||||
<h2 id="ctitle">Проверьте заявку на перевод</h2>
|
||||
<div class="sumrow"><span class="mut">Депозитарий-получатель</span><span id="c-recv" style="text-align:right"></span></div>
|
||||
<div id="c-secs" style="margin-top:4px"></div>
|
||||
<div class="warnbox">⚠️ После подачи заявку нельзя отменить или изменить. Бумаги переводятся другому брокеру. Проверьте получателя и количество.</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||||
<button class="btn" id="cyes" type="button">Подтверждаю, подать</button>
|
||||
<button class="btn sec" id="cno" type="button">Вернуться</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var f=document.getElementById('tform'); if(!f) return;
|
||||
var ov=document.getElementById('cov');
|
||||
// «всё» — заполнить доступным количеством
|
||||
f.querySelectorAll('.qall').forEach(function(b){
|
||||
b.addEventListener('click',function(){
|
||||
var q=b.parentNode.querySelector('.qty'); if(q){q.value=q.getAttribute('data-avail'); q.focus();}
|
||||
});
|
||||
});
|
||||
function fmt(n){ n=Number(n); return (n%1===0)? String(n): String(n); }
|
||||
f.addEventListener('submit',function(e){
|
||||
e.preventDefault();
|
||||
// собрать выбранные бумаги
|
||||
var rows=[], bad=null;
|
||||
f.querySelectorAll('.qty').forEach(function(q){
|
||||
var v=parseFloat((q.value||'').replace(',','.'));
|
||||
if(!q.value||isNaN(v)||v<=0) return;
|
||||
var av=parseFloat(q.getAttribute('data-avail'));
|
||||
if(v>av){ bad=q.getAttribute('data-name')+': запрошено '+fmt(v)+', доступно '+fmt(av); }
|
||||
rows.push({name:q.getAttribute('data-name'), isin:q.getAttribute('data-isin'), qty:v});
|
||||
});
|
||||
var inn=(f.receiving_inn.value||'').trim();
|
||||
if(!/^(\d{10}|\d{12})$/.test(inn)){ f.receiving_inn.focus(); alert('Проверьте ИНН депозитария-получателя: должно быть 10 цифр.'); return; }
|
||||
if(rows.length===0){ alert('Укажите количество хотя бы по одной бумаге.'); return; }
|
||||
if(bad){ alert('Превышен доступный остаток. '+bad); return; }
|
||||
// заполнить сводку
|
||||
var rn=(f.receiving_name.value||'').trim();
|
||||
document.getElementById('c-recv').innerHTML='<b>ИНН '+inn+'</b>'+(rn?('<br><span class="mut">'+rn+'</span>'):'');
|
||||
var h=''; rows.forEach(function(r){
|
||||
h+='<div class="sumrow"><span>'+r.name+' <span class="mut">('+r.isin+')</span></span><b>'+fmt(r.qty)+' шт</b></div>';
|
||||
});
|
||||
document.getElementById('c-secs').innerHTML=h;
|
||||
ov.classList.add('on');
|
||||
});
|
||||
document.getElementById('cno').addEventListener('click',function(){ ov.classList.remove('on'); });
|
||||
ov.addEventListener('click',function(e){ if(e.target===ov) ov.classList.remove('on'); });
|
||||
document.getElementById('cyes').addEventListener('click',function(){
|
||||
var b=this; b.disabled=true; b.textContent='Отправляем…'; f.submit();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</main></body></html>{{end}}
|
||||
|
||||
{{define "claims"}}{{template "head" .}}{{template "nav" .}}
|
||||
<main>
|
||||
<h1>Мои заявки</h1>
|
||||
{{with .Flash}}<div class="msg ok">{{.}}</div>{{end}}
|
||||
{{with .Error}}<div class="msg err">{{.}}</div>{{end}}
|
||||
<div class="card">
|
||||
{{if .Claims}}
|
||||
<div class="tw"><table>
|
||||
<thead><tr><th>Дата</th><th>Номер</th><th class="num">Бумаг</th><th>Статус</th><th>Примечание</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Claims}}
|
||||
<tr>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
<td><code title="{{.GUID}}">{{shortid .GUID}}</code></td>
|
||||
<td class="num">{{.Securit}}</td>
|
||||
<td>{{if or (eq .Status "rejected") (eq .Status "declined") (eq .Status "error")}}<span class="badge err">{{.StatusRu}}</span>
|
||||
{{else if eq .Status "done"}}<span class="badge ok">{{.StatusRu}}</span>
|
||||
{{else}}<span class="badge">{{.StatusRu}}</span>{{end}}</td>
|
||||
<td class="mut" style="white-space:normal;min-width:180px">{{.Reason}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
<p class="mut" style="margin-top:12px">Наведите на номер, чтобы увидеть полный идентификатор заявки. Он понадобится при обращении в поддержку.</p>
|
||||
{{else}}
|
||||
<p class="mut">Заявок пока нет. <a href="/transfer">Подать заявку на перевод</a></p>
|
||||
{{end}}
|
||||
</div>
|
||||
</main></body></html>{{end}}
|
||||
|
||||
{{define "clients"}}{{template "head" .}}{{template "nav" .}}
|
||||
<main>
|
||||
<h1>Клиенты</h1>
|
||||
<p class="mut">Список клиентов личного кабинета. Можно открыть кабинет любого клиента, чтобы посмотреть его бумаги и заявки его глазами.</p>
|
||||
{{with .Flash}}<div class="msg err">{{.}}</div>{{end}}
|
||||
{{with .Error}}<div class="msg err">{{.}}</div>{{end}}
|
||||
<div class="card">
|
||||
{{if .Clients}}
|
||||
<div class="tw"><table>
|
||||
<thead><tr><th>Клиент</th><th>Логин</th><th>Договор</th><th>E-mail</th><th>Статус</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Clients}}
|
||||
<tr>
|
||||
<td>{{if .FullName}}{{.FullName}}{{else}}—{{end}}{{if .IsAdmin}} <span class="badge">админ</span>{{end}}</td>
|
||||
<td><code>{{.Login}}</code></td>
|
||||
<td>{{.Contract}}</td>
|
||||
<td class="mut">{{.Email}}</td>
|
||||
<td>{{if .Active}}<span class="badge ok">активен</span>{{else}}<span class="badge err">заблокирован</span>{{end}}</td>
|
||||
<td>{{if not .IsAdmin}}<a class="btn sec" href="/impersonate?login={{.Login}}">Открыть кабинет</a>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{{else if not .Error}}
|
||||
<p class="mut">Клиентов пока нет.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<h2>Клиенты депозитария</h2>
|
||||
<p class="mut">Реальные договоры из депозитарного учёта. Учётная запись в кабинете им не нужна —
|
||||
администратор открывает кабинет по договору в режиме просмотра.</p>
|
||||
{{with .DepoError}}<div class="msg err">Депозитарий недоступен: {{.}}</div>{{end}}
|
||||
<div class="card">
|
||||
<form method="get" action="/clients" class="row" style="margin-bottom:12px">
|
||||
<input type="text" name="search" value="{{.Search}}" placeholder="номер договора или ФИО, например БР-1001 или Гравс" style="flex:1">
|
||||
<button class="btn" type="submit">Найти</button>
|
||||
{{if .Search}}<a class="btn sec" href="/clients">Сбросить</a>{{end}}
|
||||
</form>
|
||||
{{if .Depo}}
|
||||
<div class="tw"><table>
|
||||
<thead><tr><th>Клиент</th><th>Договор</th><th>Бумаг</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Depo}}
|
||||
<tr>
|
||||
<td>{{if .Name}}{{.Name}}{{else}}—{{end}}</td>
|
||||
<td><code>{{.Contract}}</code></td>
|
||||
<td>{{.Securities}}</td>
|
||||
<td><a class="btn sec" href="/impersonate?contract={{.Contract}}">Открыть кабинет</a></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table></div>
|
||||
<p class="mut">Показаны первые 100 договоров с ненулевыми остатками. Уточните поиск, чтобы найти нужного клиента.</p>
|
||||
{{else if not .DepoError}}
|
||||
<p class="mut">По запросу «{{.Search}}» договоров с бумагами не найдено.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</main></body></html>{{end}}
|
||||
`
|
||||
Reference in New Issue
Block a user